wotann 0.5.85 → 0.5.87

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
+ }
@@ -36,8 +36,48 @@ export interface ToolResultEvent {
36
36
  readonly toolName: string;
37
37
  readonly result: string;
38
38
  }
39
- export type RunAgentControlEvent = IterationStartEvent | ToolResultEvent;
39
+ /**
40
+ * Emitted at an iteration boundary after the loop drained one or more
41
+ * user-side redirects (Hermes Gap 2 — interrupt-and-redirect). UI
42
+ * consumers can render a system bubble showing the redirected text so
43
+ * the user has visual confirmation the directive landed. The redirect
44
+ * is ALREADY appended to context by the loop before this event fires;
45
+ * receivers MUST NOT re-append it themselves.
46
+ */
47
+ export interface RedirectReceivedEvent {
48
+ readonly kind: "redirect_received";
49
+ readonly count: number;
50
+ readonly messages: readonly string[];
51
+ }
52
+ export type RunAgentControlEvent = IterationStartEvent | ToolResultEvent | RedirectReceivedEvent;
40
53
  export type AgentEvent = StreamChunk | RunAgentControlEvent;
54
+ /**
55
+ * Channel for user-side redirects (Hermes Gap 2). The TUI / RPC client
56
+ * pushes messages when the user sends a new directive WHILE the agent
57
+ * is streaming. The runAgent loop drains at each iteration boundary
58
+ * and appends them to context as new user turns, so the model absorbs
59
+ * the directive on the next turn — no context loss, no abort needed.
60
+ *
61
+ * Implementations MUST be safe to call concurrently (push from the
62
+ * UI thread, drain from the loop's async generator).
63
+ */
64
+ export interface RedirectChannel {
65
+ /** Drain all pending redirects. Returns empty array when none queued. */
66
+ drain(): readonly string[];
67
+ }
68
+ /**
69
+ * Default in-memory channel. Pushes are appended to a queue; drain
70
+ * empties the queue atomically. The queue ignores empty-string pushes
71
+ * so an accidental Ctrl+R on an empty composer doesn't inject a blank
72
+ * user turn into the model context.
73
+ */
74
+ export declare class InMemoryRedirectChannel implements RedirectChannel {
75
+ private queue;
76
+ push(message: string): void;
77
+ drain(): readonly string[];
78
+ /** Snapshot of pending count — for status-bar indicators. Read-only. */
79
+ get pending(): number;
80
+ }
41
81
  export interface RunAgentOptions {
42
82
  readonly prompt: string;
43
83
  readonly images?: readonly string[];
@@ -49,6 +89,14 @@ export interface RunAgentOptions {
49
89
  readonly signal?: AbortSignal;
50
90
  readonly guardrails?: readonly GuardrailCheck[];
51
91
  readonly guardrailContext?: GuardrailContext;
92
+ /**
93
+ * Optional user-redirect channel. When provided, the loop drains
94
+ * pending messages BEFORE each iteration's query and appends them
95
+ * to context as new user turns. Lets the user redirect a streaming
96
+ * agent without losing context (Hermes Gap 2). See
97
+ * {@link InMemoryRedirectChannel} for the default implementation.
98
+ */
99
+ readonly redirectChannel?: RedirectChannel;
52
100
  readonly query: (o: WotannQueryOptions) => AsyncGenerator<StreamChunk>;
53
101
  readonly executeTool: (name: string, input: Record<string, unknown>) => Promise<string>;
54
102
  }
@@ -1,5 +1,31 @@
1
1
  import { evaluateGuardrails, } from "../guardrails/tripwire.js";
2
- import { IntelligenceAmplifier, updateDoomLoopState } from "../intelligence/amplifier.js";
2
+ import { IntelligenceAmplifier, updateDoomLoopState, } from "../intelligence/amplifier.js";
3
+ /**
4
+ * Default in-memory channel. Pushes are appended to a queue; drain
5
+ * empties the queue atomically. The queue ignores empty-string pushes
6
+ * so an accidental Ctrl+R on an empty composer doesn't inject a blank
7
+ * user turn into the model context.
8
+ */
9
+ export class InMemoryRedirectChannel {
10
+ queue = [];
11
+ push(message) {
12
+ const trimmed = message.trim();
13
+ if (trimmed.length === 0)
14
+ return;
15
+ this.queue.push(trimmed);
16
+ }
17
+ drain() {
18
+ if (this.queue.length === 0)
19
+ return [];
20
+ const out = this.queue;
21
+ this.queue = [];
22
+ return out;
23
+ }
24
+ /** Snapshot of pending count — for status-bar indicators. Read-only. */
25
+ get pending() {
26
+ return this.queue.length;
27
+ }
28
+ }
3
29
  export async function* runAgent(opts) {
4
30
  const maxIterations = opts.maxIterations ?? 8;
5
31
  const guardrails = opts.guardrails ?? [];
@@ -20,6 +46,22 @@ export async function* runAgent(opts) {
20
46
  while (iteration < maxIterations) {
21
47
  iteration++;
22
48
  yield { kind: "iteration_start", iteration };
49
+ // Hermes Gap 2 — drain user-side redirects BEFORE this iteration's
50
+ // query. Each pending message becomes a new user turn appended to
51
+ // context, so the model sees the directive on its next turn. No
52
+ // abort, no context loss. We emit a `redirect_received` control
53
+ // event so UI consumers can render a system breadcrumb.
54
+ if (opts.redirectChannel !== undefined) {
55
+ const pending = opts.redirectChannel.drain();
56
+ if (pending.length > 0) {
57
+ const next = [...context];
58
+ for (const msg of pending) {
59
+ next.push({ role: "user", content: msg });
60
+ }
61
+ context = next;
62
+ yield { kind: "redirect_received", count: pending.length, messages: pending };
63
+ }
64
+ }
23
65
  let fullContent = "";
24
66
  let model;
25
67
  let provider;
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
+ }
@@ -41,7 +41,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "#wotann-jsx/j
41
41
  */
42
42
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
43
43
  import { Box, Text, useInput } from "ink";
44
- import { runAgent } from "../../../core/runtime-agent-loop.js";
44
+ import { InMemoryRedirectChannel, runAgent } from "../../../core/runtime-agent-loop.js";
45
45
  import { buildAgentToolContext } from "../../../core/agent-tool-context.js";
46
46
  import { AGENT_TOOL_DEFINITIONS, executeAgentTool } from "../../../tools/agent-tools.js";
47
47
  import { ThemeProvider, useThemeTone } from "../../theme/context.js";
@@ -217,6 +217,13 @@ function AppV3Inner({ providers, initialModel, initialProvider, messages, isStre
217
217
  const [transcriptScrollOffset, setTranscriptScrollOffset] = useState(0);
218
218
  const activeRunRef = useRef(null);
219
219
  const sideActiveRunRef = useRef(null);
220
+ // Hermes Gap 2 — interrupt-and-redirect: persistent channel for the
221
+ // host composer's mid-stream redirects. Ctrl+R captures the current
222
+ // draft and pushes it here; runAgent's iteration loop drains pending
223
+ // redirects at the next iteration boundary and appends them to
224
+ // context as new user turns. One channel per AppV3Inner instance so
225
+ // pending redirects survive within a session.
226
+ const redirectChannelRef = useRef(new InMemoryRedirectChannel());
220
227
  const [sidePaneDraft, setSidePaneDraft] = useState("");
221
228
  const [nowMs, setNowMs] = useState(() => Date.now());
222
229
  useEffect(() => {
@@ -241,6 +248,16 @@ function AppV3Inner({ providers, initialModel, initialProvider, messages, isStre
241
248
  // composer is empty so we don't intercept the dot from a typed
242
249
  // slash-command argument or filename.
243
250
  useInput((input, key) => {
251
+ // Hermes Gap 2 — Ctrl+R: queue the composer draft as a mid-stream
252
+ // redirect. ONLY fires when the agent is currently streaming AND
253
+ // the draft has non-whitespace content. The channel's drain runs
254
+ // at the next iteration boundary, so the user sees a system
255
+ // "redirect queued" bubble within ~1 turn.
256
+ if (key.ctrl && input === "r" && isStreaming && draftValue.trim().length > 0) {
257
+ redirectChannelRef.current.push(draftValue);
258
+ setDraftValue("");
259
+ return;
260
+ }
244
261
  if (key.ctrl && (input === "k" || input === "p")) {
245
262
  overlay.open("palette");
246
263
  return;
@@ -342,6 +359,7 @@ function AppV3Inner({ providers, initialModel, initialProvider, messages, isStre
342
359
  provider: initialProvider || undefined,
343
360
  tools: AGENT_TOOL_DEFINITIONS,
344
361
  signal: abortController.signal,
362
+ redirectChannel: redirectChannelRef.current,
345
363
  query: (o) => runtime.query(o),
346
364
  executeTool: (name, input) => executeAgentTool(name, input, buildAgentToolContext(runtime, {
347
365
  workingDir: runtime.getWorkingDir(),
@@ -362,6 +380,20 @@ function AppV3Inner({ providers, initialModel, initialProvider, messages, isStre
362
380
  },
363
381
  ]);
364
382
  }
383
+ else if (ev.kind === "redirect_received") {
384
+ // Hermes Gap 2 — UI breadcrumb so user sees the directive
385
+ // landed. The loop already appended it to context; we
386
+ // ONLY render here (do NOT push to context again).
387
+ for (const message of ev.messages) {
388
+ setMessages((prev) => [
389
+ ...prev,
390
+ {
391
+ role: "system",
392
+ content: `↳ redirect queued: ${message}`,
393
+ },
394
+ ]);
395
+ }
396
+ }
365
397
  continue;
366
398
  }
367
399
  if (ev.type === "text" && ev.content.length > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wotann",
3
- "version": "0.5.85",
3
+ "version": "0.5.87",
4
4
  "description": "WOTANN — The All-Father of AI Agent Harnesses",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",