wotann 0.5.84 → 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.
- package/dist/cli/commands/skills-curator.d.ts +50 -0
- package/dist/cli/commands/skills-curator.js +162 -0
- package/dist/index.js +129 -33
- package/dist/learning/skill-curator.d.ts +156 -0
- package/dist/learning/skill-curator.js +402 -0
- package/dist/ui/mount-interactive-ink.d.ts +8 -0
- package/dist/ui/mount-interactive-ink.js +22 -2
- package/dist/ui/raw-mode-guard.d.ts +17 -2
- package/dist/ui/raw-mode-guard.js +27 -3
- package/package.json +1 -1
|
@@ -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
|
@@ -260,7 +260,9 @@ program
|
|
|
260
260
|
try {
|
|
261
261
|
const { createRuntime } = await import("./core/runtime.js");
|
|
262
262
|
const { runRuntimeQuery } = await import("./cli/runtime-query.js");
|
|
263
|
-
const jsonSchema = options.jsonSchema !== undefined
|
|
263
|
+
const jsonSchema = options.jsonSchema !== undefined
|
|
264
|
+
? parseJsonSchemaOption(options.jsonSchema)
|
|
265
|
+
: undefined;
|
|
264
266
|
const prContext = options.fromPr !== undefined ? loadPullRequestContext(options.fromPr) : undefined;
|
|
265
267
|
const runtime = await createRuntime(process.cwd(), undefined, {
|
|
266
268
|
...(options.bare
|
|
@@ -402,13 +404,22 @@ program
|
|
|
402
404
|
process.stderr.write("[wotann] Unknown renderer. Use --renderer ink or --renderer opentui.\n");
|
|
403
405
|
return;
|
|
404
406
|
}
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
const {
|
|
407
|
+
// Pre-load the heavy UI modules BEFORE entering alt-buffer so the
|
|
408
|
+
// window between alt-buffer entry and first Ink frame is near-zero.
|
|
409
|
+
// Otherwise the user stares at a pitch-black alt-buffer while
|
|
410
|
+
// AppV3 + Ink + react-reconciler hydrate, which is exactly the
|
|
411
|
+
// "npx wotann hangs and shows a black screen" symptom users have
|
|
412
|
+
// reported (2026-05-23 P0). Parallel imports cut the wait further.
|
|
413
|
+
const [{ AppV3 }, { mountInteractiveInk }, altBufferModule] = await Promise.all([
|
|
414
|
+
import("./ui/components/v3/index.js"),
|
|
415
|
+
import("./ui/mount-interactive-ink.js"),
|
|
416
|
+
import("./ui/alt-buffer.js"),
|
|
417
|
+
]);
|
|
418
|
+
const { isAltBufferRequested, enterAltBuffer, exitAltBuffer } = altBufferModule;
|
|
419
|
+
// Switch to the alternate screen buffer NOW that the heavy modules
|
|
420
|
+
// are loaded. Crash-safe — alt-buffer.ts wires SIGINT/SIGTERM/
|
|
421
|
+
// uncaughtException to always restore the main buffer. Default ON;
|
|
422
|
+
// disable with `--no-fullscreen` or `WOTANN_FULLSCREEN=0`.
|
|
412
423
|
if (isAltBufferRequested(options.fullscreen !== false)) {
|
|
413
424
|
enterAltBuffer();
|
|
414
425
|
}
|
|
@@ -419,17 +430,34 @@ program
|
|
|
419
430
|
// (process.stdin → /dev/tty → none), and refuse-cleanly-with-
|
|
420
431
|
// guidance instead of mounting Ink into the error-render-loop
|
|
421
432
|
// hang ("npx wotann just hangs"). See ui/mount-interactive-ink.ts.
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
433
|
+
//
|
|
434
|
+
// The try/catch + exitAltBuffer pair is the recovery contract:
|
|
435
|
+
// if Ink throws (e.g. setRawMode passes the guard probe but
|
|
436
|
+
// fails on Ink's first useInput commit on some terminal combos),
|
|
437
|
+
// we ALWAYS restore the main buffer first so the user sees the
|
|
438
|
+
// diagnostic instead of a frozen black screen.
|
|
439
|
+
let mountResult;
|
|
440
|
+
try {
|
|
441
|
+
mountResult = await mountInteractiveInk(React.createElement(AppV3, {
|
|
442
|
+
version: VERSION,
|
|
443
|
+
providers: interactive.providers,
|
|
444
|
+
initialModel: interactive.initialModel,
|
|
445
|
+
initialProvider: interactive.initialProvider,
|
|
446
|
+
runtime: interactive.runtime,
|
|
447
|
+
}));
|
|
448
|
+
}
|
|
449
|
+
catch (error) {
|
|
450
|
+
exitAltBuffer();
|
|
451
|
+
process.stderr.write(`[wotann] Interactive TUI failed to mount: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
if (mountResult.refused) {
|
|
455
|
+
// mountInteractiveInk already wrote actionable guidance to
|
|
456
|
+
// stderr. Restore main buffer so guidance is visible instead
|
|
457
|
+
// of trapped behind an alt-buffer Ink never painted into.
|
|
458
|
+
exitAltBuffer();
|
|
432
459
|
return;
|
|
460
|
+
}
|
|
433
461
|
});
|
|
434
462
|
// ── wotann cli-generate (CLI-Anything 7-phase generator) ─────────
|
|
435
463
|
program
|
|
@@ -1908,10 +1936,16 @@ program
|
|
|
1908
1936
|
console.log(chalk.green(" Session context restored. Continue where you left off.\n"));
|
|
1909
1937
|
const ReactModule = await import("react");
|
|
1910
1938
|
const React = ReactModule.default;
|
|
1911
|
-
//
|
|
1912
|
-
// (
|
|
1913
|
-
// `
|
|
1914
|
-
|
|
1939
|
+
// Pre-load heavy UI modules in parallel BEFORE entering alt-buffer
|
|
1940
|
+
// (npx pitch-black fix 2026-05-23). Same fullscreen-mode hook as
|
|
1941
|
+
// `wotann start` — env var only here (no CLI flag on `wotann
|
|
1942
|
+
// resume`). Default ON; disable via `WOTANN_FULLSCREEN=0`.
|
|
1943
|
+
const [{ AppV3 }, { mountInteractiveInk }, altBufferModule] = await Promise.all([
|
|
1944
|
+
import("./ui/components/v3/index.js"),
|
|
1945
|
+
import("./ui/mount-interactive-ink.js"),
|
|
1946
|
+
import("./ui/alt-buffer.js"),
|
|
1947
|
+
]);
|
|
1948
|
+
const { isAltBufferRequested, enterAltBuffer, exitAltBuffer } = altBufferModule;
|
|
1915
1949
|
if (isAltBufferRequested(true)) {
|
|
1916
1950
|
enterAltBuffer();
|
|
1917
1951
|
}
|
|
@@ -1919,18 +1953,28 @@ program
|
|
|
1919
1953
|
// resumed session continues where it left off (AppV3 wires it via
|
|
1920
1954
|
// useState([...initialMessages])). Same guarded gate as the start
|
|
1921
1955
|
// path: viewport repair + raw-mode stdin + refuse-instead-of-hang.
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1956
|
+
// try/catch + exitAltBuffer pair ensures the user always sees the
|
|
1957
|
+
// diagnostic on Ink-mount failure instead of a frozen black screen.
|
|
1958
|
+
let mountResult;
|
|
1959
|
+
try {
|
|
1960
|
+
mountResult = await mountInteractiveInk(React.createElement(AppV3, {
|
|
1961
|
+
version: VERSION,
|
|
1962
|
+
providers: interactive.providers,
|
|
1963
|
+
initialModel: session.model,
|
|
1964
|
+
initialProvider: session.provider,
|
|
1965
|
+
initialMessages: session.messages,
|
|
1966
|
+
runtime: interactive.runtime,
|
|
1967
|
+
}));
|
|
1968
|
+
}
|
|
1969
|
+
catch (error) {
|
|
1970
|
+
exitAltBuffer();
|
|
1971
|
+
process.stderr.write(`[wotann] Interactive TUI failed to mount: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
1933
1972
|
return;
|
|
1973
|
+
}
|
|
1974
|
+
if (mountResult.refused) {
|
|
1975
|
+
exitAltBuffer();
|
|
1976
|
+
return;
|
|
1977
|
+
}
|
|
1934
1978
|
});
|
|
1935
1979
|
// ── wotann rewind ───────────────────────────────────────────
|
|
1936
1980
|
//
|
|
@@ -3534,6 +3578,58 @@ skillsCmd
|
|
|
3534
3578
|
if (out.exitCode !== 0)
|
|
3535
3579
|
process.exit(out.exitCode);
|
|
3536
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
|
+
});
|
|
3537
3633
|
// ── wotann cost ──────────────────────────────────────────────
|
|
3538
3634
|
//
|
|
3539
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
|
+
}
|
|
@@ -56,6 +56,14 @@ export interface MountInteractiveResult {
|
|
|
56
56
|
* degraded-terminal case — it returns `{ instance: null, refused: true }`
|
|
57
57
|
* after writing actionable guidance, so the caller exits cleanly
|
|
58
58
|
* instead of hanging.
|
|
59
|
+
*
|
|
60
|
+
* Ink-render-throws backstop (added 2026-05-23, npx pitch-black fix):
|
|
61
|
+
* even with the raw-mode-guard probe in front, Ink's reconciler can
|
|
62
|
+
* still throw during its first `useInput` commit on terminal combos
|
|
63
|
+
* where `setRawMode` exists, passes the probe, then fails on the
|
|
64
|
+
* *specific* `(true)` call Ink makes. Catching here and returning
|
|
65
|
+
* `refused: true` lets the caller exit the alt-buffer + show the
|
|
66
|
+
* user the actual failure instead of a frozen black screen.
|
|
59
67
|
*/
|
|
60
68
|
export declare function mountInteractiveInk(element: ReactElement, opts?: MountInteractiveOptions): Promise<MountInteractiveResult>;
|
|
61
69
|
export {};
|
|
@@ -33,6 +33,14 @@ export const DEFAULT_REFUSAL_MESSAGE = "[wotann] No raw-mode-capable terminal av
|
|
|
33
33
|
* degraded-terminal case — it returns `{ instance: null, refused: true }`
|
|
34
34
|
* after writing actionable guidance, so the caller exits cleanly
|
|
35
35
|
* instead of hanging.
|
|
36
|
+
*
|
|
37
|
+
* Ink-render-throws backstop (added 2026-05-23, npx pitch-black fix):
|
|
38
|
+
* even with the raw-mode-guard probe in front, Ink's reconciler can
|
|
39
|
+
* still throw during its first `useInput` commit on terminal combos
|
|
40
|
+
* where `setRawMode` exists, passes the probe, then fails on the
|
|
41
|
+
* *specific* `(true)` call Ink makes. Catching here and returning
|
|
42
|
+
* `refused: true` lets the caller exit the alt-buffer + show the
|
|
43
|
+
* user the actual failure instead of a frozen black screen.
|
|
36
44
|
*/
|
|
37
45
|
export async function mountInteractiveInk(element, opts = {}) {
|
|
38
46
|
const ensureViewport = opts.ensureViewport ?? ensureRenderableViewport;
|
|
@@ -47,6 +55,18 @@ export async function mountInteractiveInk(element, opts = {}) {
|
|
|
47
55
|
return { instance: null, refused: true };
|
|
48
56
|
}
|
|
49
57
|
const inkRender = opts.inkRender ?? (await import("ink")).render;
|
|
50
|
-
|
|
51
|
-
|
|
58
|
+
try {
|
|
59
|
+
const instance = inkRender(element, { stdin: inputStdin });
|
|
60
|
+
return { instance, refused: false };
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
const sink = opts.stderr ?? process.stderr;
|
|
64
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
65
|
+
sink.write(`[wotann] Interactive TUI failed to mount: ${message}\n` +
|
|
66
|
+
" This usually means the terminal accepted setRawMode but Ink's\n" +
|
|
67
|
+
" first input commit threw under this launcher. Options:\n" +
|
|
68
|
+
" • `npm i -g wotann` then run `wotann` in a fresh terminal\n" +
|
|
69
|
+
' • non-interactive: `wotann -p "your prompt"`\n');
|
|
70
|
+
return { instance: null, refused: true };
|
|
71
|
+
}
|
|
52
72
|
}
|
|
@@ -18,11 +18,26 @@
|
|
|
18
18
|
*/
|
|
19
19
|
export interface RawModeStream {
|
|
20
20
|
readonly isTTY?: unknown;
|
|
21
|
+
readonly isRaw?: unknown;
|
|
21
22
|
setRawMode?: unknown;
|
|
22
23
|
}
|
|
23
24
|
/**
|
|
24
|
-
* True iff `stream` is a TTY that exposes a `setRawMode` function
|
|
25
|
-
* exactly Ink's precondition
|
|
25
|
+
* True iff `stream` is a TTY that exposes a `setRawMode` function AND
|
|
26
|
+
* that function works when invoked — exactly Ink's runtime precondition
|
|
27
|
+
* for `useInput()`. Never throws.
|
|
28
|
+
*
|
|
29
|
+
* The shape check (`isTTY === true` + `typeof setRawMode === "function"`)
|
|
30
|
+
* caught one silent-failure class — degraded stdin — but missed a
|
|
31
|
+
* second one: some `npx`/terminal combinations expose `setRawMode` as
|
|
32
|
+
* a function that THROWS the moment Ink invokes it during the first
|
|
33
|
+
* `useInput` hook. The property-only check passed, Ink mounted into
|
|
34
|
+
* an alt-buffer, Ink's reconciler error-looped on the rejected raw
|
|
35
|
+
* mode call, and the user saw the reported "pitch-black screen, TUI
|
|
36
|
+
* never paints" symptom. The probe below catches that case: call
|
|
37
|
+
* `setRawMode` with the CURRENT raw-state (idempotent — terminal
|
|
38
|
+
* state is preserved when it works) inside a try/catch. If it throws
|
|
39
|
+
* here, the resolver falls through to `/dev/tty` or refuses cleanly
|
|
40
|
+
* instead of mounting Ink into the silent loop.
|
|
26
41
|
*/
|
|
27
42
|
export declare function isRawModeCapable(stream: unknown): boolean;
|
|
28
43
|
export interface ResolveStdinOptions {
|
|
@@ -19,14 +19,38 @@
|
|
|
19
19
|
import { openSync } from "node:fs";
|
|
20
20
|
import { ReadStream } from "node:tty";
|
|
21
21
|
/**
|
|
22
|
-
* True iff `stream` is a TTY that exposes a `setRawMode` function
|
|
23
|
-
* exactly Ink's precondition
|
|
22
|
+
* True iff `stream` is a TTY that exposes a `setRawMode` function AND
|
|
23
|
+
* that function works when invoked — exactly Ink's runtime precondition
|
|
24
|
+
* for `useInput()`. Never throws.
|
|
25
|
+
*
|
|
26
|
+
* The shape check (`isTTY === true` + `typeof setRawMode === "function"`)
|
|
27
|
+
* caught one silent-failure class — degraded stdin — but missed a
|
|
28
|
+
* second one: some `npx`/terminal combinations expose `setRawMode` as
|
|
29
|
+
* a function that THROWS the moment Ink invokes it during the first
|
|
30
|
+
* `useInput` hook. The property-only check passed, Ink mounted into
|
|
31
|
+
* an alt-buffer, Ink's reconciler error-looped on the rejected raw
|
|
32
|
+
* mode call, and the user saw the reported "pitch-black screen, TUI
|
|
33
|
+
* never paints" symptom. The probe below catches that case: call
|
|
34
|
+
* `setRawMode` with the CURRENT raw-state (idempotent — terminal
|
|
35
|
+
* state is preserved when it works) inside a try/catch. If it throws
|
|
36
|
+
* here, the resolver falls through to `/dev/tty` or refuses cleanly
|
|
37
|
+
* instead of mounting Ink into the silent loop.
|
|
24
38
|
*/
|
|
25
39
|
export function isRawModeCapable(stream) {
|
|
26
40
|
if (stream === null || typeof stream !== "object")
|
|
27
41
|
return false;
|
|
28
42
|
const s = stream;
|
|
29
|
-
|
|
43
|
+
if (s.isTTY !== true || typeof s.setRawMode !== "function")
|
|
44
|
+
return false;
|
|
45
|
+
try {
|
|
46
|
+
const setRawMode = s.setRawMode;
|
|
47
|
+
const currentIsRaw = s.isRaw === true;
|
|
48
|
+
setRawMode(currentIsRaw);
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
30
54
|
}
|
|
31
55
|
/**
|
|
32
56
|
* Open the controlling terminal as a raw-capable input stream, or
|