wotann 0.5.85 → 0.5.86

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.
@@ -0,0 +1,50 @@
1
+ /**
2
+ * `wotann skills curator <verb>` — thin CLI shell around skill-curator.
3
+ *
4
+ * Verbs:
5
+ * status — render scheduler state + per-skill lifecycle
6
+ * run [--dry-run] — execute a pass (or preview without mutating)
7
+ * pause / resume — toggle the paused flag
8
+ * pin <name> / unpin <name>— bypass auto-transitions for a specific skill
9
+ *
10
+ * No side effects at import time. The src/index.ts skillsCmd dispatcher
11
+ * lazy-imports this module so the CLI cold path stays slim.
12
+ */
13
+ import { applyAutomaticTransitions, type CuratorState } from "../../learning/skill-curator.js";
14
+ export interface StatusOptions {
15
+ /** Inject a writer for testability. Defaults to console.log. */
16
+ readonly write?: (line: string) => void;
17
+ /** Anchor "now" for the time-ago math. */
18
+ readonly now?: Date;
19
+ }
20
+ export declare function runStatus(opts?: StatusOptions): number;
21
+ export interface RunOptions {
22
+ readonly dryRun?: boolean;
23
+ readonly write?: (line: string) => void;
24
+ readonly now?: Date;
25
+ }
26
+ export declare function runRun(opts?: RunOptions): number;
27
+ export declare function runPause(opts?: {
28
+ write?: (line: string) => void;
29
+ }): number;
30
+ export declare function runResume(opts?: {
31
+ write?: (line: string) => void;
32
+ }): number;
33
+ export declare function runPin(skillName: string, opts?: {
34
+ write?: (line: string) => void;
35
+ }): number;
36
+ export declare function runUnpin(skillName: string, opts?: {
37
+ write?: (line: string) => void;
38
+ }): number;
39
+ /**
40
+ * Daemon-friendly entry point: applies auto-transitions WITHOUT
41
+ * rendering anything. Returns counts so the caller can log / emit
42
+ * telemetry on its own terms.
43
+ *
44
+ * Used by the (future) kairos idle-detector wire. Manual CLI runs
45
+ * go through {@link runRun} which writes the summary string.
46
+ */
47
+ export declare function runSilentPass(now?: Date): {
48
+ readonly transitions: ReturnType<typeof applyAutomaticTransitions>;
49
+ readonly stateAfter: CuratorState;
50
+ };
@@ -0,0 +1,162 @@
1
+ /**
2
+ * `wotann skills curator <verb>` — thin CLI shell around skill-curator.
3
+ *
4
+ * Verbs:
5
+ * status — render scheduler state + per-skill lifecycle
6
+ * run [--dry-run] — execute a pass (or preview without mutating)
7
+ * pause / resume — toggle the paused flag
8
+ * pin <name> / unpin <name>— bypass auto-transitions for a specific skill
9
+ *
10
+ * No side effects at import time. The src/index.ts skillsCmd dispatcher
11
+ * lazy-imports this module so the CLI cold path stays slim.
12
+ */
13
+ import chalk from "chalk";
14
+ import { applyAutomaticTransitions, isPaused, listAgentSkills, loadState, runCurator, saveState, setPaused, setPinned, } from "../../learning/skill-curator.js";
15
+ // ── Time-ago formatter (Hermes-style) ─────────────────────────────
16
+ function formatTimeAgo(iso, now = new Date()) {
17
+ if (iso === null)
18
+ return "never";
19
+ const ms = now.getTime() - new Date(iso).getTime();
20
+ if (!Number.isFinite(ms))
21
+ return "unknown";
22
+ const seconds = Math.max(0, Math.floor(ms / 1000));
23
+ if (seconds < 60)
24
+ return `${seconds}s ago`;
25
+ if (seconds < 3600)
26
+ return `${Math.floor(seconds / 60)}m ago`;
27
+ if (seconds < 86_400)
28
+ return `${Math.floor(seconds / 3600)}h ago`;
29
+ return `${Math.floor(seconds / 86_400)}d ago`;
30
+ }
31
+ export function runStatus(opts = {}) {
32
+ const write = opts.write ?? ((line) => console.log(line));
33
+ const now = opts.now ?? new Date();
34
+ const state = loadState();
35
+ const skills = listAgentSkills();
36
+ write(chalk.bold(formatHeader(state)));
37
+ write(` runs: ${state.runCount}`);
38
+ write(` last run: ${formatTimeAgo(state.lastRunAt, now)}`);
39
+ write(` last summary: ${state.lastRunSummary ?? "(none)"}`);
40
+ if (state.lastRunDurationSeconds !== null) {
41
+ write(` last duration: ${state.lastRunDurationSeconds}s`);
42
+ }
43
+ const buckets = {
44
+ active: [],
45
+ stale: [],
46
+ archived: [],
47
+ };
48
+ const pinned = [];
49
+ for (const skill of skills) {
50
+ buckets[skill.state].push(skill);
51
+ if (skill.pinned)
52
+ pinned.push(skill.name);
53
+ }
54
+ write("");
55
+ if (skills.length === 0) {
56
+ write(chalk.dim(" no agent-created skills"));
57
+ return 0;
58
+ }
59
+ write(` agent-created skills: ${skills.length}`);
60
+ for (const state_ of ["active", "stale", "archived"]) {
61
+ write(` ${state_.padEnd(10)} ${buckets[state_].length}`);
62
+ }
63
+ if (pinned.length > 0) {
64
+ write("");
65
+ write(` pinned (${pinned.length}): ${pinned.join(", ")}`);
66
+ }
67
+ const leastActive = [...buckets.active]
68
+ .sort((a, b) => {
69
+ const aT = a.lastActivityAt ?? a.createdAt;
70
+ const bT = b.lastActivityAt ?? b.createdAt;
71
+ return aT.localeCompare(bT);
72
+ })
73
+ .slice(0, 5);
74
+ if (leastActive.length > 0) {
75
+ write("");
76
+ write(" least recently active (top 5):");
77
+ for (const skill of leastActive) {
78
+ write(` ${skill.name.padEnd(40)} ` +
79
+ `use=${String(skill.useCount).padStart(3)} ` +
80
+ `view=${String(skill.viewCount).padStart(3)} ` +
81
+ `last=${formatTimeAgo(skill.lastActivityAt, now)}`);
82
+ }
83
+ }
84
+ return 0;
85
+ }
86
+ function formatHeader(state) {
87
+ if (state.paused)
88
+ return "curator: PAUSED";
89
+ return "curator: ENABLED";
90
+ }
91
+ export function runRun(opts = {}) {
92
+ const write = opts.write ?? ((line) => console.log(line));
93
+ if (isPaused() && opts.dryRun !== true) {
94
+ write(chalk.yellow("curator is paused. Resume with `wotann skills curator resume`."));
95
+ return 1;
96
+ }
97
+ const result = runCurator(opts.now ?? new Date(), { dryRun: opts.dryRun === true });
98
+ write(chalk.bold(result.dryRun ? "DRY RUN — no mutations" : "Curator pass"));
99
+ write(` ${result.summary}`);
100
+ write(` duration: ${result.durationSeconds.toFixed(2)}s`);
101
+ return 0;
102
+ }
103
+ // ── pause / resume ────────────────────────────────────────────────
104
+ export function runPause(opts = {}) {
105
+ const write = opts.write ?? ((line) => console.log(line));
106
+ setPaused(true);
107
+ write(chalk.yellow("curator paused"));
108
+ return 0;
109
+ }
110
+ export function runResume(opts = {}) {
111
+ const write = opts.write ?? ((line) => console.log(line));
112
+ setPaused(false);
113
+ write(chalk.green("curator resumed"));
114
+ return 0;
115
+ }
116
+ // ── pin / unpin ───────────────────────────────────────────────────
117
+ export function runPin(skillName, opts = {}) {
118
+ const write = opts.write ?? ((line) => console.log(line));
119
+ if (skillName.length === 0) {
120
+ write(chalk.red("usage: wotann skills curator pin <name>"));
121
+ return 2;
122
+ }
123
+ if (setPinned(skillName, true)) {
124
+ write(chalk.green(`pinned: ${skillName}`));
125
+ return 0;
126
+ }
127
+ write(chalk.red(`no agent-created skill on disk: ${skillName}`));
128
+ return 1;
129
+ }
130
+ export function runUnpin(skillName, opts = {}) {
131
+ const write = opts.write ?? ((line) => console.log(line));
132
+ if (skillName.length === 0) {
133
+ write(chalk.red("usage: wotann skills curator unpin <name>"));
134
+ return 2;
135
+ }
136
+ if (setPinned(skillName, false)) {
137
+ write(chalk.green(`unpinned: ${skillName}`));
138
+ return 0;
139
+ }
140
+ write(chalk.red(`no agent-created skill on disk: ${skillName}`));
141
+ return 1;
142
+ }
143
+ // ── transition-only (kairos / daemon entry point) ─────────────────
144
+ /**
145
+ * Daemon-friendly entry point: applies auto-transitions WITHOUT
146
+ * rendering anything. Returns counts so the caller can log / emit
147
+ * telemetry on its own terms.
148
+ *
149
+ * Used by the (future) kairos idle-detector wire. Manual CLI runs
150
+ * go through {@link runRun} which writes the summary string.
151
+ */
152
+ export function runSilentPass(now = new Date()) {
153
+ const transitions = applyAutomaticTransitions(now);
154
+ const prev = loadState();
155
+ saveState({
156
+ ...prev,
157
+ lastRunAt: now.toISOString(),
158
+ lastRunSummary: `silent: checked ${transitions.checked}, archived ${transitions.archived}`,
159
+ runCount: prev.runCount + 1,
160
+ });
161
+ return { transitions, stateAfter: loadState() };
162
+ }
package/dist/index.js CHANGED
@@ -3578,6 +3578,58 @@ skillsCmd
3578
3578
  if (out.exitCode !== 0)
3579
3579
  process.exit(out.exitCode);
3580
3580
  });
3581
+ // ── wotann skills curator ────────────────────────────────────
3582
+ //
3583
+ // Hermes Gap 1 port — inactivity-triggered lifecycle manager for
3584
+ // agent-created skills. State machine lives in src/learning/
3585
+ // skill-curator.ts; this is the thin CLI wrapper. See the module
3586
+ // docblock there for the full lifecycle / invariants discussion.
3587
+ const curatorCmd = skillsCmd
3588
+ .command("curator")
3589
+ .description("Manage the inactivity-triggered skill lifecycle (active/stale/archived)");
3590
+ curatorCmd
3591
+ .command("status")
3592
+ .description("Show curator scheduler state + per-skill lifecycle counts")
3593
+ .action(async () => {
3594
+ const mod = await import("./cli/commands/skills-curator.js");
3595
+ process.exit(mod.runStatus());
3596
+ });
3597
+ curatorCmd
3598
+ .command("run")
3599
+ .description("Execute one curator pass (apply auto-transitions)")
3600
+ .option("--dry-run", "Compute would-be transitions without mutating the library")
3601
+ .action(async (opts) => {
3602
+ const mod = await import("./cli/commands/skills-curator.js");
3603
+ process.exit(mod.runRun({ dryRun: opts.dryRun === true }));
3604
+ });
3605
+ curatorCmd
3606
+ .command("pause")
3607
+ .description("Pause the curator (scheduler will not run automatic passes)")
3608
+ .action(async () => {
3609
+ const mod = await import("./cli/commands/skills-curator.js");
3610
+ process.exit(mod.runPause());
3611
+ });
3612
+ curatorCmd
3613
+ .command("resume")
3614
+ .description("Resume the curator after a pause")
3615
+ .action(async () => {
3616
+ const mod = await import("./cli/commands/skills-curator.js");
3617
+ process.exit(mod.runResume());
3618
+ });
3619
+ curatorCmd
3620
+ .command("pin <name>")
3621
+ .description("Pin a skill so it bypasses auto-transitions (stale + archive)")
3622
+ .action(async (name) => {
3623
+ const mod = await import("./cli/commands/skills-curator.js");
3624
+ process.exit(mod.runPin(name));
3625
+ });
3626
+ curatorCmd
3627
+ .command("unpin <name>")
3628
+ .description("Unpin a skill so it resumes auto-transitions")
3629
+ .action(async (name) => {
3630
+ const mod = await import("./cli/commands/skills-curator.js");
3631
+ process.exit(mod.runUnpin(name));
3632
+ });
3581
3633
  // ── wotann cost ──────────────────────────────────────────────
3582
3634
  //
3583
3635
  // Wave 4G: the cost command now accepts an optional `period` argument
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Skill Curator — inactivity-triggered skill lifecycle manager.
3
+ *
4
+ * Port of Hermes' `agent/curator.py` (NousResearch/hermes-agent, MIT).
5
+ * The Hermes curator runs background reviews of agent-created skills,
6
+ * auto-transitioning them between lifecycle states (active → stale →
7
+ * archived) based on real activity timestamps, and pinning bypasses
8
+ * the auto-transitions entirely. Hermes' full curator also spawns a
9
+ * forked AIAgent to do LLM-graded review; we ship the pure-function
10
+ * scheduler + state machine first and wire the LLM review later when
11
+ * the auxiliary-credential pattern lands.
12
+ *
13
+ * Lifecycle:
14
+ * active → stale (no activity for STALE_AFTER_DAYS, default 30)
15
+ * stale → archived (no activity for ARCHIVE_AFTER_DAYS, default 90)
16
+ * stale → active (activity recorded after marked stale = reactivation)
17
+ * pinned → bypasses all auto-transitions
18
+ *
19
+ * Strict invariants:
20
+ * - Only touches AGENT-CREATED skills (those living under
21
+ * ~/.wotann/skills/agent/ — the directory `skill-forge.ts`
22
+ * promotes into). The curated/scientific registries are static
23
+ * data and out of scope.
24
+ * - Never auto-deletes. Archive moves the directory to
25
+ * ~/.wotann/skills/.archive/<name>/ which is recoverable by hand.
26
+ * - State writes are atomic (writeFileAtomic) so a crashed run
27
+ * never leaves a half-written .curator_state.
28
+ *
29
+ * Persistence layout (under WOTANN_HOME):
30
+ * skills/.curator_state — global curator scheduler state
31
+ * skills/agent/<name>/ — promoted agent-created skill
32
+ * skills/agent/<name>/.activity.json — per-skill activity record
33
+ * skills/.archive/<name>/ — archived (recoverable) skills
34
+ *
35
+ * Quality bars:
36
+ * QB#6 honest failures: corrupt state files return defaults rather
37
+ * than crashing the curator; ENOENT on a missing skill dir is
38
+ * the expected steady-state, not an error.
39
+ * QB#7 per-call state: every function reads from disk on each call
40
+ * and writes back atomically. No module-global caches.
41
+ * QB#11 sibling-site scan: `applyAutomaticTransitions` is the ONE
42
+ * place state mutations live — CLI status/run/pin paths all
43
+ * funnel through it so the bug class "two writers race on the
44
+ * same skill" cannot regress.
45
+ */
46
+ /** 7 days — Hermes default. Override via {@link CuratorConfig.intervalHours}. */
47
+ export declare const DEFAULT_INTERVAL_HOURS: number;
48
+ /** 2 hours of agent inactivity before the curator considers running. */
49
+ export declare const DEFAULT_MIN_IDLE_HOURS = 2;
50
+ /** Skill marked `stale` after this many days without activity. */
51
+ export declare const DEFAULT_STALE_AFTER_DAYS = 30;
52
+ /** Skill `archived` after this many days without activity. */
53
+ export declare const DEFAULT_ARCHIVE_AFTER_DAYS = 90;
54
+ export type SkillState = "active" | "stale" | "archived";
55
+ /** Global curator scheduler state — persisted at `.curator_state`. */
56
+ export interface CuratorState {
57
+ readonly lastRunAt: string | null;
58
+ readonly lastRunDurationSeconds: number | null;
59
+ readonly lastRunSummary: string | null;
60
+ readonly paused: boolean;
61
+ readonly runCount: number;
62
+ }
63
+ /** Per-skill activity record — persisted at `agent/<name>/.activity.json`. */
64
+ export interface SkillActivity {
65
+ readonly name: string;
66
+ readonly state: SkillState;
67
+ readonly pinned: boolean;
68
+ readonly lastActivityAt: string | null;
69
+ readonly createdAt: string;
70
+ readonly useCount: number;
71
+ readonly viewCount: number;
72
+ }
73
+ /** Aggregate counts emitted by {@link applyAutomaticTransitions}. */
74
+ export interface TransitionCounts {
75
+ readonly checked: number;
76
+ readonly markedStale: number;
77
+ readonly archived: number;
78
+ readonly reactivated: number;
79
+ }
80
+ export interface CuratorConfig {
81
+ readonly intervalHours?: number;
82
+ readonly staleAfterDays?: number;
83
+ readonly archiveAfterDays?: number;
84
+ }
85
+ export interface RunResult {
86
+ readonly counts: TransitionCounts;
87
+ readonly durationSeconds: number;
88
+ readonly summary: string;
89
+ readonly dryRun: boolean;
90
+ }
91
+ /**
92
+ * Load the global curator scheduler state. Returns sensible defaults
93
+ * on missing or corrupt files — never throws.
94
+ */
95
+ export declare function loadState(): CuratorState;
96
+ /** Write the global curator state atomically. Creates the parent dir if needed. */
97
+ export declare function saveState(state: CuratorState): void;
98
+ export declare function setPaused(paused: boolean): void;
99
+ export declare function isPaused(): boolean;
100
+ /**
101
+ * Record a use (the user actually invoked the skill in an agent turn).
102
+ * Bumps {@link SkillActivity.useCount}, updates `lastActivityAt`, and
103
+ * reactivates archived skills (the skill clearly isn't dead).
104
+ */
105
+ export declare function recordSkillUse(skillName: string, now?: Date): void;
106
+ /**
107
+ * Record a view (the user inspected the skill via `/skills view` or
108
+ * the palette). Views ALSO count as activity per the Hermes design —
109
+ * a recently-inspected skill shouldn't immediately archive.
110
+ */
111
+ export declare function recordSkillView(skillName: string, now?: Date): void;
112
+ /**
113
+ * Pin or unpin a skill. Pinned skills bypass all auto-transitions.
114
+ * Returns false when the skill has no on-disk presence (we don't
115
+ * silently create a phantom activity record).
116
+ */
117
+ export declare function setPinned(skillName: string, pinned: boolean, now?: Date): boolean;
118
+ /**
119
+ * Walk `~/.wotann/skills/agent/` and return one activity record per
120
+ * agent-created skill. Skills with no activity file yet are seeded
121
+ * with defaults derived from their directory mtime so the first
122
+ * curator pass has somewhere to anchor lifecycle math.
123
+ */
124
+ export declare function listAgentSkills(): SkillActivity[];
125
+ /**
126
+ * Walk every agent-created skill and move active/stale/archived based
127
+ * on the latest activity timestamp. Pinned skills are never touched.
128
+ * Archived skills move on disk to `.archive/<name>/`. Returns aggregate
129
+ * counts so the CLI can render a summary.
130
+ */
131
+ export declare function applyAutomaticTransitions(now?: Date, config?: CuratorConfig): TransitionCounts;
132
+ /**
133
+ * Returns true when the curator should run NOW. Gates:
134
+ * - not paused
135
+ * - last_run_at present AND older than intervalHours
136
+ *
137
+ * First-run behavior: a missing `last_run_at` SEEDS the timestamp and
138
+ * returns false (defer the first real pass by one full interval —
139
+ * matches Hermes' "deferred first run" pattern so the very first
140
+ * background tick after `wotann update` doesn't immediately mutate
141
+ * the library).
142
+ *
143
+ * The min-idle-hours gate is intentionally NOT applied here — callers
144
+ * (kairos idle detector, CLI manual `run`) know their own idle state
145
+ * and apply it at the call site.
146
+ */
147
+ export declare function shouldRunNow(now?: Date, intervalHours?: number): boolean;
148
+ /**
149
+ * Execute one curator pass. When `dryRun` is true, computes what would
150
+ * change but does NOT mutate the skill library or the curator state.
151
+ * Otherwise applies transitions and persists `lastRunAt`/`runCount`/
152
+ * `lastRunSummary` for the next status render.
153
+ */
154
+ export declare function runCurator(now?: Date, config?: CuratorConfig & {
155
+ dryRun?: boolean;
156
+ }): RunResult;
@@ -0,0 +1,402 @@
1
+ /**
2
+ * Skill Curator — inactivity-triggered skill lifecycle manager.
3
+ *
4
+ * Port of Hermes' `agent/curator.py` (NousResearch/hermes-agent, MIT).
5
+ * The Hermes curator runs background reviews of agent-created skills,
6
+ * auto-transitioning them between lifecycle states (active → stale →
7
+ * archived) based on real activity timestamps, and pinning bypasses
8
+ * the auto-transitions entirely. Hermes' full curator also spawns a
9
+ * forked AIAgent to do LLM-graded review; we ship the pure-function
10
+ * scheduler + state machine first and wire the LLM review later when
11
+ * the auxiliary-credential pattern lands.
12
+ *
13
+ * Lifecycle:
14
+ * active → stale (no activity for STALE_AFTER_DAYS, default 30)
15
+ * stale → archived (no activity for ARCHIVE_AFTER_DAYS, default 90)
16
+ * stale → active (activity recorded after marked stale = reactivation)
17
+ * pinned → bypasses all auto-transitions
18
+ *
19
+ * Strict invariants:
20
+ * - Only touches AGENT-CREATED skills (those living under
21
+ * ~/.wotann/skills/agent/ — the directory `skill-forge.ts`
22
+ * promotes into). The curated/scientific registries are static
23
+ * data and out of scope.
24
+ * - Never auto-deletes. Archive moves the directory to
25
+ * ~/.wotann/skills/.archive/<name>/ which is recoverable by hand.
26
+ * - State writes are atomic (writeFileAtomic) so a crashed run
27
+ * never leaves a half-written .curator_state.
28
+ *
29
+ * Persistence layout (under WOTANN_HOME):
30
+ * skills/.curator_state — global curator scheduler state
31
+ * skills/agent/<name>/ — promoted agent-created skill
32
+ * skills/agent/<name>/.activity.json — per-skill activity record
33
+ * skills/.archive/<name>/ — archived (recoverable) skills
34
+ *
35
+ * Quality bars:
36
+ * QB#6 honest failures: corrupt state files return defaults rather
37
+ * than crashing the curator; ENOENT on a missing skill dir is
38
+ * the expected steady-state, not an error.
39
+ * QB#7 per-call state: every function reads from disk on each call
40
+ * and writes back atomically. No module-global caches.
41
+ * QB#11 sibling-site scan: `applyAutomaticTransitions` is the ONE
42
+ * place state mutations live — CLI status/run/pin paths all
43
+ * funnel through it so the bug class "two writers race on the
44
+ * same skill" cannot regress.
45
+ */
46
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync } from "node:fs";
47
+ import { join } from "node:path";
48
+ import { writeFileAtomic } from "../utils/atomic-io.js";
49
+ import { resolveWotannHomeSubdir } from "../utils/wotann-home.js";
50
+ // ── Constants (Hermes defaults) ──────────────────────────────────
51
+ /** 7 days — Hermes default. Override via {@link CuratorConfig.intervalHours}. */
52
+ export const DEFAULT_INTERVAL_HOURS = 24 * 7;
53
+ /** 2 hours of agent inactivity before the curator considers running. */
54
+ export const DEFAULT_MIN_IDLE_HOURS = 2;
55
+ /** Skill marked `stale` after this many days without activity. */
56
+ export const DEFAULT_STALE_AFTER_DAYS = 30;
57
+ /** Skill `archived` after this many days without activity. */
58
+ export const DEFAULT_ARCHIVE_AFTER_DAYS = 90;
59
+ // ── Path helpers ──────────────────────────────────────────────────
60
+ function stateFilePath() {
61
+ return resolveWotannHomeSubdir("skills", ".curator_state");
62
+ }
63
+ function agentSkillsDir() {
64
+ return resolveWotannHomeSubdir("skills", "agent");
65
+ }
66
+ function archiveDir() {
67
+ return resolveWotannHomeSubdir("skills", ".archive");
68
+ }
69
+ function activityFilePath(skillName) {
70
+ return resolveWotannHomeSubdir("skills", "agent", skillName, ".activity.json");
71
+ }
72
+ // ── Global state I/O ──────────────────────────────────────────────
73
+ function defaultState() {
74
+ return {
75
+ lastRunAt: null,
76
+ lastRunDurationSeconds: null,
77
+ lastRunSummary: null,
78
+ paused: false,
79
+ runCount: 0,
80
+ };
81
+ }
82
+ /**
83
+ * Load the global curator scheduler state. Returns sensible defaults
84
+ * on missing or corrupt files — never throws.
85
+ */
86
+ export function loadState() {
87
+ const path = stateFilePath();
88
+ if (!existsSync(path))
89
+ return defaultState();
90
+ try {
91
+ const parsed = JSON.parse(readFileSync(path, "utf-8"));
92
+ if (parsed !== null && typeof parsed === "object") {
93
+ return { ...defaultState(), ...parsed };
94
+ }
95
+ }
96
+ catch {
97
+ // Corrupt JSON — return defaults rather than crash the run.
98
+ }
99
+ return defaultState();
100
+ }
101
+ /** Write the global curator state atomically. Creates the parent dir if needed. */
102
+ export function saveState(state) {
103
+ writeFileAtomic(stateFilePath(), JSON.stringify(state, null, 2) + "\n");
104
+ }
105
+ export function setPaused(paused) {
106
+ saveState({ ...loadState(), paused });
107
+ }
108
+ export function isPaused() {
109
+ return loadState().paused;
110
+ }
111
+ // ── Per-skill activity ────────────────────────────────────────────
112
+ function loadActivity(skillName) {
113
+ const path = activityFilePath(skillName);
114
+ if (!existsSync(path))
115
+ return null;
116
+ try {
117
+ const parsed = JSON.parse(readFileSync(path, "utf-8"));
118
+ if (parsed !== null && typeof parsed === "object" && typeof parsed.name === "string") {
119
+ return {
120
+ name: parsed.name,
121
+ state: parsed.state ?? "active",
122
+ pinned: parsed.pinned === true,
123
+ lastActivityAt: typeof parsed.lastActivityAt === "string" ? parsed.lastActivityAt : null,
124
+ createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : new Date().toISOString(),
125
+ useCount: typeof parsed.useCount === "number" ? parsed.useCount : 0,
126
+ viewCount: typeof parsed.viewCount === "number" ? parsed.viewCount : 0,
127
+ };
128
+ }
129
+ }
130
+ catch {
131
+ // Corrupt activity file — treat as missing.
132
+ }
133
+ return null;
134
+ }
135
+ function saveActivity(activity) {
136
+ writeFileAtomic(activityFilePath(activity.name), JSON.stringify(activity, null, 2) + "\n");
137
+ }
138
+ /**
139
+ * Record a use (the user actually invoked the skill in an agent turn).
140
+ * Bumps {@link SkillActivity.useCount}, updates `lastActivityAt`, and
141
+ * reactivates archived skills (the skill clearly isn't dead).
142
+ */
143
+ export function recordSkillUse(skillName, now = new Date()) {
144
+ const existing = loadActivity(skillName);
145
+ saveActivity({
146
+ name: skillName,
147
+ state: existing?.state === "archived" ? "active" : (existing?.state ?? "active"),
148
+ pinned: existing?.pinned ?? false,
149
+ lastActivityAt: now.toISOString(),
150
+ createdAt: existing?.createdAt ?? now.toISOString(),
151
+ useCount: (existing?.useCount ?? 0) + 1,
152
+ viewCount: existing?.viewCount ?? 0,
153
+ });
154
+ }
155
+ /**
156
+ * Record a view (the user inspected the skill via `/skills view` or
157
+ * the palette). Views ALSO count as activity per the Hermes design —
158
+ * a recently-inspected skill shouldn't immediately archive.
159
+ */
160
+ export function recordSkillView(skillName, now = new Date()) {
161
+ const existing = loadActivity(skillName);
162
+ saveActivity({
163
+ name: skillName,
164
+ state: existing?.state ?? "active",
165
+ pinned: existing?.pinned ?? false,
166
+ lastActivityAt: now.toISOString(),
167
+ createdAt: existing?.createdAt ?? now.toISOString(),
168
+ useCount: existing?.useCount ?? 0,
169
+ viewCount: (existing?.viewCount ?? 0) + 1,
170
+ });
171
+ }
172
+ /**
173
+ * Pin or unpin a skill. Pinned skills bypass all auto-transitions.
174
+ * Returns false when the skill has no on-disk presence (we don't
175
+ * silently create a phantom activity record).
176
+ */
177
+ export function setPinned(skillName, pinned, now = new Date()) {
178
+ const existing = loadActivity(skillName);
179
+ if (existing !== null) {
180
+ saveActivity({ ...existing, pinned });
181
+ return true;
182
+ }
183
+ const skillDir = join(agentSkillsDir(), skillName);
184
+ if (!existsSync(skillDir))
185
+ return false;
186
+ saveActivity({
187
+ name: skillName,
188
+ state: "active",
189
+ pinned,
190
+ lastActivityAt: null,
191
+ createdAt: now.toISOString(),
192
+ useCount: 0,
193
+ viewCount: 0,
194
+ });
195
+ return true;
196
+ }
197
+ // ── Discovery ─────────────────────────────────────────────────────
198
+ /**
199
+ * Walk `~/.wotann/skills/agent/` and return one activity record per
200
+ * agent-created skill. Skills with no activity file yet are seeded
201
+ * with defaults derived from their directory mtime so the first
202
+ * curator pass has somewhere to anchor lifecycle math.
203
+ */
204
+ export function listAgentSkills() {
205
+ const dir = agentSkillsDir();
206
+ if (!existsSync(dir))
207
+ return [];
208
+ const out = [];
209
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
210
+ if (!entry.isDirectory())
211
+ continue;
212
+ // Skip dotfile dirs (.archive, .scratch, etc.).
213
+ if (entry.name.startsWith("."))
214
+ continue;
215
+ const activity = loadActivity(entry.name);
216
+ if (activity !== null) {
217
+ out.push(activity);
218
+ continue;
219
+ }
220
+ // Skill exists on disk but no activity record yet — seed one.
221
+ let createdAt;
222
+ try {
223
+ createdAt = statSync(join(dir, entry.name)).birthtime.toISOString();
224
+ }
225
+ catch {
226
+ createdAt = new Date().toISOString();
227
+ }
228
+ const seeded = {
229
+ name: entry.name,
230
+ state: "active",
231
+ pinned: false,
232
+ lastActivityAt: null,
233
+ createdAt,
234
+ useCount: 0,
235
+ viewCount: 0,
236
+ };
237
+ try {
238
+ saveActivity(seeded);
239
+ }
240
+ catch {
241
+ // Best-effort seed — if write fails (read-only fs in CI), still
242
+ // surface the skill in the report.
243
+ }
244
+ out.push(seeded);
245
+ }
246
+ return out;
247
+ }
248
+ // ── Auto-transitions (pure function over time) ────────────────────
249
+ /**
250
+ * Walk every agent-created skill and move active/stale/archived based
251
+ * on the latest activity timestamp. Pinned skills are never touched.
252
+ * Archived skills move on disk to `.archive/<name>/`. Returns aggregate
253
+ * counts so the CLI can render a summary.
254
+ */
255
+ export function applyAutomaticTransitions(now = new Date(), config = {}) {
256
+ const staleAfter = config.staleAfterDays ?? DEFAULT_STALE_AFTER_DAYS;
257
+ const archiveAfter = config.archiveAfterDays ?? DEFAULT_ARCHIVE_AFTER_DAYS;
258
+ const nowMs = now.getTime();
259
+ const staleCutoff = nowMs - staleAfter * 86_400_000;
260
+ const archiveCutoff = nowMs - archiveAfter * 86_400_000;
261
+ let checked = 0;
262
+ let markedStale = 0;
263
+ let archived = 0;
264
+ let reactivated = 0;
265
+ for (const skill of listAgentSkills()) {
266
+ checked++;
267
+ if (skill.pinned)
268
+ continue;
269
+ const anchorIso = skill.lastActivityAt ?? skill.createdAt;
270
+ const anchor = new Date(anchorIso).getTime();
271
+ if (!Number.isFinite(anchor))
272
+ continue;
273
+ if (anchor <= archiveCutoff && skill.state !== "archived") {
274
+ if (archiveSkillOnDisk(skill.name)) {
275
+ archived++;
276
+ }
277
+ }
278
+ else if (anchor <= staleCutoff && skill.state === "active") {
279
+ saveActivity({ ...skill, state: "stale" });
280
+ markedStale++;
281
+ }
282
+ else if (anchor > staleCutoff && skill.state === "stale") {
283
+ saveActivity({ ...skill, state: "active" });
284
+ reactivated++;
285
+ }
286
+ }
287
+ return { checked, markedStale, archived, reactivated };
288
+ }
289
+ /**
290
+ * Move `skills/agent/<name>/` to `skills/.archive/<name>/`. If the
291
+ * archive slot is already occupied (re-archive after manual restore),
292
+ * suffix with the current timestamp so nothing is overwritten.
293
+ * Returns false when the source doesn't exist (skill already gone).
294
+ */
295
+ function archiveSkillOnDisk(skillName) {
296
+ const src = join(agentSkillsDir(), skillName);
297
+ if (!existsSync(src))
298
+ return false;
299
+ mkdirSync(archiveDir(), { recursive: true });
300
+ let dst = join(archiveDir(), skillName);
301
+ if (existsSync(dst)) {
302
+ dst = `${dst}.${Date.now()}`;
303
+ }
304
+ renameSync(src, dst);
305
+ return true;
306
+ }
307
+ // ── Scheduler ─────────────────────────────────────────────────────
308
+ /**
309
+ * Returns true when the curator should run NOW. Gates:
310
+ * - not paused
311
+ * - last_run_at present AND older than intervalHours
312
+ *
313
+ * First-run behavior: a missing `last_run_at` SEEDS the timestamp and
314
+ * returns false (defer the first real pass by one full interval —
315
+ * matches Hermes' "deferred first run" pattern so the very first
316
+ * background tick after `wotann update` doesn't immediately mutate
317
+ * the library).
318
+ *
319
+ * The min-idle-hours gate is intentionally NOT applied here — callers
320
+ * (kairos idle detector, CLI manual `run`) know their own idle state
321
+ * and apply it at the call site.
322
+ */
323
+ export function shouldRunNow(now = new Date(), intervalHours = DEFAULT_INTERVAL_HOURS) {
324
+ const state = loadState();
325
+ if (state.paused)
326
+ return false;
327
+ if (state.lastRunAt === null) {
328
+ // Seed without running. Defer first pass by one interval.
329
+ saveState({ ...state, lastRunAt: now.toISOString() });
330
+ return false;
331
+ }
332
+ const last = new Date(state.lastRunAt).getTime();
333
+ if (!Number.isFinite(last))
334
+ return true; // corrupt timestamp → run
335
+ const intervalMs = intervalHours * 3_600_000;
336
+ return now.getTime() - last >= intervalMs;
337
+ }
338
+ // ── Run ───────────────────────────────────────────────────────────
339
+ /**
340
+ * Execute one curator pass. When `dryRun` is true, computes what would
341
+ * change but does NOT mutate the skill library or the curator state.
342
+ * Otherwise applies transitions and persists `lastRunAt`/`runCount`/
343
+ * `lastRunSummary` for the next status render.
344
+ */
345
+ export function runCurator(now = new Date(), config = {}) {
346
+ const start = Date.now();
347
+ const dryRun = config.dryRun === true;
348
+ const counts = dryRun
349
+ ? computeWouldTransition(now, config)
350
+ : applyAutomaticTransitions(now, config);
351
+ const end = Date.now();
352
+ const summary = formatSummary(counts, dryRun);
353
+ if (!dryRun) {
354
+ const prev = loadState();
355
+ saveState({
356
+ ...prev,
357
+ lastRunAt: now.toISOString(),
358
+ lastRunDurationSeconds: Math.max(0, Math.round((end - start) / 1000)),
359
+ lastRunSummary: summary,
360
+ runCount: prev.runCount + 1,
361
+ });
362
+ }
363
+ return {
364
+ counts,
365
+ durationSeconds: (end - start) / 1000,
366
+ summary,
367
+ dryRun,
368
+ };
369
+ }
370
+ function computeWouldTransition(now, config) {
371
+ const staleAfter = config.staleAfterDays ?? DEFAULT_STALE_AFTER_DAYS;
372
+ const archiveAfter = config.archiveAfterDays ?? DEFAULT_ARCHIVE_AFTER_DAYS;
373
+ const nowMs = now.getTime();
374
+ const staleCutoff = nowMs - staleAfter * 86_400_000;
375
+ const archiveCutoff = nowMs - archiveAfter * 86_400_000;
376
+ let checked = 0;
377
+ let markedStale = 0;
378
+ let archived = 0;
379
+ let reactivated = 0;
380
+ for (const skill of listAgentSkills()) {
381
+ checked++;
382
+ if (skill.pinned)
383
+ continue;
384
+ const anchor = new Date(skill.lastActivityAt ?? skill.createdAt).getTime();
385
+ if (!Number.isFinite(anchor))
386
+ continue;
387
+ if (anchor <= archiveCutoff && skill.state !== "archived")
388
+ archived++;
389
+ else if (anchor <= staleCutoff && skill.state === "active")
390
+ markedStale++;
391
+ else if (anchor > staleCutoff && skill.state === "stale")
392
+ reactivated++;
393
+ }
394
+ return { checked, markedStale, archived, reactivated };
395
+ }
396
+ function formatSummary(counts, dryRun) {
397
+ const prefix = dryRun ? "[dry-run] " : "";
398
+ return (`${prefix}checked ${counts.checked}, ` +
399
+ `marked stale ${counts.markedStale}, ` +
400
+ `archived ${counts.archived}, ` +
401
+ `reactivated ${counts.reactivated}`);
402
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wotann",
3
- "version": "0.5.85",
3
+ "version": "0.5.86",
4
4
  "description": "WOTANN — The All-Father of AI Agent Harnesses",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",