@groeponline/pi-wishcraft 1.4.11 → 1.4.13
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/AGENTS.md +309 -97
- package/CHANGELOG.md +4 -0
- package/package.json +2 -2
- package/src/extension/skills/global-registry.ts +63 -0
- package/src/extension/skills/skill-registry.ts +56 -4
- package/src/motion/index.ts +1 -0
- package/src/motion/policy.ts +28 -1
- package/src/render/motion-rail.ts +70 -16
- package/src/render/v2-entry.ts +1 -1
- package/src/signal/controller.ts +10 -10
- package/src/studio/component.ts +213 -37
- package/src/studio/deepwiki/cache.ts +80 -6
- package/src/studio/list.ts +11 -1
- package/src/studio/open.ts +207 -15
- package/src/theme/colors.ts +2 -6
- package/src/tools/patch.ts +0 -179
- package/src/tools/ripgrep.ts +0 -104
package/src/motion/policy.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { CADENCE_MS, CHANNEL_MATRIX, PREVIEW_INTERVAL_MS } from "./catalog.ts";
|
|
11
|
-
import type { MotionChannel, MotionEvent, MotionLevel, MotionPolicy, MotionToggles } from "./types.ts";
|
|
11
|
+
import type { MotionChannel, MotionDef, MotionEvent, MotionLevel, MotionPolicy, MotionToggles } from "./types.ts";
|
|
12
12
|
|
|
13
13
|
const CHANNEL_TOGGLE: Record<MotionChannel, keyof MotionToggles> = {
|
|
14
14
|
workingGlyph: "state",
|
|
@@ -53,6 +53,33 @@ export function effectiveLevel(policy: MotionPolicy): MotionLevel {
|
|
|
53
53
|
return policy.level;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Channels a motion is allowed to drive under the current policy.
|
|
58
|
+
*
|
|
59
|
+
* Same a11y filter as `allowedChannels`, but the source is the **motion's own
|
|
60
|
+
* declared channels** instead of the event table. This is what makes an
|
|
61
|
+
* explicit motion choice (preset signature or `appearance.motion` override)
|
|
62
|
+
* actually runnable: the event no longer vetoes a motion it wasn't paired
|
|
63
|
+
* with by the default matrix — the motion decides, the policy still filters.
|
|
64
|
+
*/
|
|
65
|
+
export function channelsForMotion(
|
|
66
|
+
motion: MotionDef,
|
|
67
|
+
policy: MotionPolicy,
|
|
68
|
+
): MotionChannel[] {
|
|
69
|
+
if (policy.screenReader || policy.level === "off") return [];
|
|
70
|
+
|
|
71
|
+
const effective = effectiveLevel(policy);
|
|
72
|
+
return motion.channels.filter((channel) => {
|
|
73
|
+
if (effective === "functional") {
|
|
74
|
+
return channel === "workingGlyph" || channel === "panelIndicator";
|
|
75
|
+
}
|
|
76
|
+
if (effective === "reduced") {
|
|
77
|
+
if (channel === "ambient" || channel === "signal") return false;
|
|
78
|
+
}
|
|
79
|
+
return policy.toggles[CHANNEL_TOGGLE[channel]];
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
56
83
|
export function cadenceFor(
|
|
57
84
|
channel: MotionChannel,
|
|
58
85
|
policy: MotionPolicy,
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* rail, streaming = travelling head + trail, compacting = inward heads.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import { defaultMotionFor, getMotion } from "../motion/catalog.ts";
|
|
10
|
+
import { frameAt, framesOf, sweepPosition, trailGlyph } from "../motion/frames.ts";
|
|
10
11
|
import type { SignalRuntime } from "../signal/controller.ts";
|
|
11
12
|
import type { SignalSpec } from "../config/types.ts";
|
|
12
13
|
import { ansi, colorEnabled, getFgAnsiCode } from "../theme/colors.ts";
|
|
@@ -16,6 +17,7 @@ export function renderActivity(
|
|
|
16
17
|
runtime: SignalRuntime,
|
|
17
18
|
spec: SignalSpec,
|
|
18
19
|
ascii = false,
|
|
20
|
+
width = 80,
|
|
19
21
|
): string {
|
|
20
22
|
const label = runtime.activity || "ready";
|
|
21
23
|
const open = spec.caps.leftOpen ?? "";
|
|
@@ -23,28 +25,70 @@ export function renderActivity(
|
|
|
23
25
|
const dim = getFgAnsiCode("sep");
|
|
24
26
|
const hot = getFgAnsiCode("accent");
|
|
25
27
|
const reset = colorEnabled() ? ansi.reset : "";
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
// muddy across three unrelated glyph families).
|
|
30
|
-
const RAIL_WIDTH = 12;
|
|
28
|
+
// Adaptive rail width: ~20% of terminal, clamped [16, 40]. Wider
|
|
29
|
+
// terminals get a longer sweep so the motion reads at a glance.
|
|
30
|
+
const RAIL_WIDTH = Math.max(16, Math.min(40, Math.round(width * 0.2)));
|
|
31
31
|
const track = ascii ? "-" : "─";
|
|
32
32
|
const head = ascii ? "o" : "●";
|
|
33
33
|
const railColor = runtime.active ? hot : dim;
|
|
34
|
+
const def = getMotion(runtime.motionId);
|
|
35
|
+
// The head glyph is the chosen motion's own frame — so ember-relay
|
|
36
|
+
// sweeps a ◇→◈→◆ sequence and hex-relay carries #-density, instead of
|
|
37
|
+
// every motion wearing the same generic comet. The trail reuses the
|
|
38
|
+
// motion's own past frames too, so each motion leaves its own wake.
|
|
39
|
+
// ASCII terminals fall back to the clean box-drawing comet — motion
|
|
40
|
+
// frames are a color-font feature.
|
|
41
|
+
const headGlyph = (tick: number, distance: number) => {
|
|
42
|
+
if (def && !ascii) return frameAt(def, Math.max(0, tick - distance), false);
|
|
43
|
+
return distance === 0 ? head : trailGlyph(distance, ascii);
|
|
44
|
+
};
|
|
45
|
+
const trailDepth = def?.generator?.trail ?? 4;
|
|
46
|
+
// Per-cell color gradient: hot head fading to dim through the palette.
|
|
47
|
+
// Distance 0 = accent, then model → path → sep so the wake cools off.
|
|
48
|
+
const cellColor = (distance: number): string => {
|
|
49
|
+
if (!colorEnabled()) return "";
|
|
50
|
+
if (distance <= 0) return hot;
|
|
51
|
+
if (distance <= 1) return getFgAnsiCode("model");
|
|
52
|
+
if (distance <= 2) return getFgAnsiCode("path");
|
|
53
|
+
return dim;
|
|
54
|
+
};
|
|
34
55
|
let railBlock: string;
|
|
35
56
|
if (!runtime.active) {
|
|
36
|
-
|
|
57
|
+
// Idle: a frozen breathing wave of the ambient motion (wisp) frames.
|
|
58
|
+
// No animation consumer at idle, but a sine-sampled wave reads as
|
|
59
|
+
// "resting, not dead" — calmer than a full sweep, warmer than a flat track.
|
|
60
|
+
const ambient = getMotion(defaultMotionFor("idle"));
|
|
61
|
+
const ambientFrames = ambient ? framesOf(ambient) : null;
|
|
62
|
+
if (ambientFrames && colorEnabled()) {
|
|
63
|
+
const phase = Date.now() % 4000 / 4000;
|
|
64
|
+
const built: string[] = [];
|
|
65
|
+
for (let i = 0; i < RAIL_WIDTH; i++) {
|
|
66
|
+
const wave = Math.sin((i / RAIL_WIDTH) * Math.PI * 2 + phase * Math.PI * 2);
|
|
67
|
+
const frameIdx = Math.floor(((wave + 1) / 2) * ambientFrames.length) % ambientFrames.length;
|
|
68
|
+
const color = wave > 0.3 ? hot : dim;
|
|
69
|
+
built.push(`${color}${ambientFrames[frameIdx]}${reset}`);
|
|
70
|
+
}
|
|
71
|
+
railBlock = built.join("");
|
|
72
|
+
} else {
|
|
73
|
+
railBlock = track.repeat(RAIL_WIDTH);
|
|
74
|
+
}
|
|
37
75
|
} else if (runtime.activity === "compacting") {
|
|
38
|
-
|
|
39
|
-
// visually distinct from the sweep so compaction reads at a glance.
|
|
40
|
-
railBlock = renderCompactRail(runtime.tick, RAIL_WIDTH, ascii);
|
|
76
|
+
railBlock = renderCompactRail(runtime.tick, RAIL_WIDTH, ascii, headGlyph, cellColor);
|
|
41
77
|
} else {
|
|
42
78
|
const pos = sweepPosition(runtime.tick, RAIL_WIDTH, true);
|
|
43
79
|
const built: string[] = [];
|
|
44
80
|
for (let i = 0; i < RAIL_WIDTH; i++) {
|
|
45
|
-
if (i === pos) built.push(
|
|
46
|
-
else if (i < pos)
|
|
47
|
-
|
|
81
|
+
if (i === pos) built.push(`${cellColor(0)}${headGlyph(runtime.tick, 0)}${reset}`);
|
|
82
|
+
else if (i < pos) {
|
|
83
|
+
const distance = pos - i;
|
|
84
|
+
if (distance <= trailDepth) {
|
|
85
|
+
built.push(`${cellColor(distance)}${headGlyph(runtime.tick, distance)}${reset}`);
|
|
86
|
+
} else {
|
|
87
|
+
built.push(track);
|
|
88
|
+
}
|
|
89
|
+
} else {
|
|
90
|
+
built.push(track);
|
|
91
|
+
}
|
|
48
92
|
}
|
|
49
93
|
railBlock = built.join("");
|
|
50
94
|
}
|
|
@@ -79,6 +123,8 @@ function renderCompactRail(
|
|
|
79
123
|
tick: number,
|
|
80
124
|
width: number,
|
|
81
125
|
ascii: boolean,
|
|
126
|
+
headGlyph: (tick: number, i: number) => string,
|
|
127
|
+
cellColor: (distance: number) => string,
|
|
82
128
|
): string {
|
|
83
129
|
const half = Math.floor(width / 2);
|
|
84
130
|
// Heads oscillate from the edges toward center and back.
|
|
@@ -87,13 +133,21 @@ function renderCompactRail(
|
|
|
87
133
|
const inward = phase < span ? phase : span * 2 - phase;
|
|
88
134
|
const leftPos = inward;
|
|
89
135
|
const rightPos = width - 1 - inward;
|
|
90
|
-
const head = ascii ? "*" : "●";
|
|
91
136
|
const core = ascii ? "=" : "━";
|
|
92
137
|
const track = ascii ? "-" : "─";
|
|
138
|
+
const reset = colorEnabled() ? ansi.reset : "";
|
|
139
|
+
// Color the core by distance from the nearest head — hottest at the
|
|
140
|
+
// heads, cooling toward center, so the compression reads thermally.
|
|
93
141
|
let built = "";
|
|
94
142
|
for (let i = 0; i < width; i++) {
|
|
95
|
-
if (i === leftPos || i === rightPos)
|
|
96
|
-
|
|
143
|
+
if (i === leftPos || i === rightPos) {
|
|
144
|
+
built += `${cellColor(0)}${headGlyph(tick, 0)}${reset}`;
|
|
145
|
+
} else if (i > leftPos && i < rightPos) {
|
|
146
|
+
const nearestHead = Math.min(Math.abs(i - leftPos), Math.abs(i - rightPos));
|
|
147
|
+
built += `${cellColor(nearestHead)}${core}${reset}`;
|
|
148
|
+
} else {
|
|
149
|
+
built += track;
|
|
150
|
+
}
|
|
97
151
|
}
|
|
98
152
|
return built;
|
|
99
153
|
}
|
package/src/render/v2-entry.ts
CHANGED
|
@@ -97,7 +97,7 @@ export function renderStatusLineV2(
|
|
|
97
97
|
|
|
98
98
|
merged.leftSegments.forEach((id, i) => pushSegment(id, 10_000 - i));
|
|
99
99
|
|
|
100
|
-
const rail = renderActivity(runtime, options.signal, options.ascii);
|
|
100
|
+
const rail = renderActivity(runtime, options.signal, options.ascii, width);
|
|
101
101
|
segments.push({ id: "signal", text: rail, priority: 5_000 });
|
|
102
102
|
primary.push("signal");
|
|
103
103
|
|
package/src/signal/controller.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
|
-
|
|
9
|
+
channelsForMotion,
|
|
10
10
|
defaultMotionFor,
|
|
11
11
|
getMotion,
|
|
12
12
|
type MotionEvent,
|
|
@@ -61,17 +61,17 @@ export function setSignalEvent(
|
|
|
61
61
|
runtime.tick = 0;
|
|
62
62
|
runtime.startedAt = Date.now();
|
|
63
63
|
runtime.activity = options.activity ?? activityForEvent(event);
|
|
64
|
-
runtime.active = event !== "idle";
|
|
65
|
-
|
|
66
|
-
if (
|
|
67
|
-
event === "idle" ||
|
|
68
|
-
!allowedChannels(event, policy).includes("signal")
|
|
69
|
-
) {
|
|
70
|
-
runtime.active = false;
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
64
|
|
|
74
65
|
const def = getMotion(runtime.motionId);
|
|
66
|
+
// The chosen motion's own channel declaration decides whether the rail
|
|
67
|
+
// runs — not the event table. `CHANNEL_MATRIX` still picks the default
|
|
68
|
+
// motion per event, but an explicit choice (preset signature or an
|
|
69
|
+
// `appearance.motion` override) is never vetoed by the matrix afterwards.
|
|
70
|
+
// The a11y policy (screen-reader, full→reduced, functional, toggles)
|
|
71
|
+
// filters both paths identically via `channelsForMotion`.
|
|
72
|
+
runtime.active =
|
|
73
|
+
def !== undefined && channelsForMotion(def, policy).includes("signal");
|
|
74
|
+
if (!runtime.active) return;
|
|
75
75
|
// Wrap subscribe so a throw doesn't leave runtime.active=true with
|
|
76
76
|
// release=null (a leaked state that would survive stopSignal).
|
|
77
77
|
let release: (() => void) | null = null;
|
package/src/studio/component.ts
CHANGED
|
@@ -1,19 +1,24 @@
|
|
|
1
|
-
/**
|
|
2
|
-
*
|
|
3
|
-
* the editor-replacing variant, unlike the Deck's centered overlay. Panes are
|
|
4
|
-
* placeholders here; U6-U10 fill list/detail/actions/advice content.
|
|
5
|
-
*/
|
|
1
|
+
/** Fullscreen Skill Studio workbench. The component owns presentation and input;
|
|
2
|
+
* the existing skills modules remain the only discovery and mutation backend. */
|
|
6
3
|
|
|
7
4
|
import { matchesKey } from "@earendil-works/pi-tui";
|
|
8
5
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import {
|
|
7
|
+
buildListRows,
|
|
8
|
+
filterListRows,
|
|
9
|
+
} from "./list.ts";
|
|
10
|
+
import { resolveReferences, type ResolvedReference } from "./inspect.ts";
|
|
11
|
+
import { readSkillBody, type SkillEntry, type SkillUsage } from "../extension/skills/skill-registry.ts";
|
|
12
|
+
import type { AdvicePane } from "./advice-pane.ts";
|
|
13
|
+
import type { AdviseMode } from "./advise/prompts.ts";
|
|
9
14
|
import {
|
|
10
15
|
createStudioState,
|
|
11
16
|
handleStudioKey,
|
|
12
17
|
STUDIO_PANES,
|
|
13
18
|
} from "./state.ts";
|
|
14
|
-
import type { StudioKeyEvent, StudioState } from "./types.ts";
|
|
19
|
+
import type { StudioKeyEvent, StudioPaneId, StudioState } from "./types.ts";
|
|
15
20
|
|
|
16
|
-
const PANE_LABELS: Record<
|
|
21
|
+
const PANE_LABELS: Record<StudioPaneId, string> = {
|
|
17
22
|
list: "Skills",
|
|
18
23
|
detail: "Detail",
|
|
19
24
|
actions: "Actions",
|
|
@@ -25,11 +30,31 @@ const HELP_LINES: readonly string[] = [
|
|
|
25
30
|
"",
|
|
26
31
|
" j / k or arrows Move selection",
|
|
27
32
|
" / Filter skills",
|
|
28
|
-
" Tab
|
|
33
|
+
" Tab or 1-4 Focus a pane",
|
|
34
|
+
" Enter Open the selected skill",
|
|
35
|
+
" n Create a skill",
|
|
36
|
+
" e Edit the selected skill",
|
|
37
|
+
" d Run doctor",
|
|
38
|
+
" a Focus AI advice",
|
|
39
|
+
" r Run advice · i insert answer",
|
|
29
40
|
" ? Toggle this help",
|
|
30
41
|
" q / Esc Exit studio",
|
|
31
42
|
];
|
|
32
43
|
|
|
44
|
+
export interface StudioComponentOptions {
|
|
45
|
+
entries?: readonly SkillEntry[];
|
|
46
|
+
usage?: ReadonlyMap<string, SkillUsage>;
|
|
47
|
+
advicePane?: AdvicePane;
|
|
48
|
+
adviceMode?: AdviseMode;
|
|
49
|
+
onRefresh?: () => readonly SkillEntry[];
|
|
50
|
+
onCreate?: () => Promise<void> | void;
|
|
51
|
+
onEdit?: (entry: SkillEntry) => Promise<void> | void;
|
|
52
|
+
onDoctor?: () => Promise<void> | void;
|
|
53
|
+
onAdvice?: (entry: SkillEntry, mode: AdviseMode, pane: AdvicePane) => Promise<void> | void;
|
|
54
|
+
onInsert?: (pane: AdvicePane) => Promise<void> | void;
|
|
55
|
+
onError?: (error: unknown) => void;
|
|
56
|
+
}
|
|
57
|
+
|
|
33
58
|
export function mapRawInput(data: string): StudioKeyEvent {
|
|
34
59
|
if (matchesKey(data, "escape")) return { key: "escape" };
|
|
35
60
|
if (matchesKey(data, "return")) return { key: "return" };
|
|
@@ -45,36 +70,132 @@ export function mapRawInput(data: string): StudioKeyEvent {
|
|
|
45
70
|
return { key: "other" };
|
|
46
71
|
}
|
|
47
72
|
|
|
48
|
-
|
|
49
|
-
if (
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
73
|
+
function fit(text: string, width: number): string {
|
|
74
|
+
if (width <= 0) return "";
|
|
75
|
+
return text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}…`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function safeReadReferences(entry: SkillEntry): ResolvedReference[] {
|
|
79
|
+
return resolveReferences(readSkillBody(entry.filePath), entry.baseDir);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function selectedEntry(
|
|
83
|
+
entries: readonly SkillEntry[],
|
|
84
|
+
query: string,
|
|
85
|
+
selectedIndex: number,
|
|
86
|
+
): SkillEntry | null {
|
|
87
|
+
const rows = filterListRows(buildListRows(entries), query);
|
|
88
|
+
const row = rows[selectedIndex] ?? rows[0];
|
|
89
|
+
return entries.find((entry) => entry.filePath === row?.filePath) ?? null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function renderHelp(theme: Theme): string[] {
|
|
93
|
+
return HELP_LINES.map((line, index) => {
|
|
94
|
+
if (index === 0) return theme.fg("accent", line);
|
|
95
|
+
return theme.fg(index === HELP_LINES.length - 1 ? "dim" : "muted", line);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function renderList(
|
|
100
|
+
theme: Theme,
|
|
101
|
+
width: number,
|
|
102
|
+
entries: readonly SkillEntry[],
|
|
103
|
+
state: StudioState,
|
|
104
|
+
): string[] {
|
|
105
|
+
const rows = filterListRows(buildListRows(entries), state.filterQuery);
|
|
106
|
+
const visible = rows.slice(Math.max(0, state.selectedIndex - 5), state.selectedIndex + 7);
|
|
107
|
+
const lines = [theme.fg("accent", `SKILLS · ${rows.length}/${entries.length}`)];
|
|
108
|
+
if (state.filterQuery) lines.push(theme.fg("muted", `filter: ${state.filterQuery}`));
|
|
109
|
+
if (rows.length === 0) {
|
|
110
|
+
lines.push(theme.fg("warning", "No skills match this filter"));
|
|
55
111
|
return lines;
|
|
56
112
|
}
|
|
113
|
+
for (const row of visible) {
|
|
114
|
+
const absolute = rows.indexOf(row);
|
|
115
|
+
const marker = absolute === state.selectedIndex ? "›" : " ";
|
|
116
|
+
const warning = row.warning ? theme.fg("warning", " ⚠") : "";
|
|
117
|
+
lines.push(
|
|
118
|
+
`${theme.fg(absolute === state.selectedIndex ? "accent" : "text", `${marker} ${fit(row.name, Math.max(8, width - 18))}`)} ${theme.fg("dim", `[${row.badge}]`)}${warning}`,
|
|
119
|
+
);
|
|
120
|
+
if (absolute === state.selectedIndex && row.description) {
|
|
121
|
+
lines.push(theme.fg("muted", ` ${fit(row.description, Math.max(8, width - 4))}`));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return lines;
|
|
125
|
+
}
|
|
57
126
|
|
|
58
|
-
|
|
59
|
-
|
|
127
|
+
function renderDetail(theme: Theme, width: number, entry: SkillEntry | null, usage: ReadonlyMap<string, SkillUsage>): string[] {
|
|
128
|
+
if (!entry) return [theme.fg("accent", "DETAIL"), theme.fg("dim", "Select a skill from the list")];
|
|
129
|
+
const body = readSkillBody(entry.filePath);
|
|
130
|
+
const refs = safeReadReferences(entry);
|
|
131
|
+
const use = usage.get(entry.name);
|
|
132
|
+
const health = entry.warning ? theme.fg("warning", "warn") : theme.fg("success", "ok");
|
|
133
|
+
const override = entry.category === "project" ? "project override" : entry.category === "global" ? "global default" : "standalone";
|
|
134
|
+
const lines = [
|
|
135
|
+
theme.fg("accent", `DETAIL · ${fit(entry.name, Math.max(8, width - 10))}`),
|
|
136
|
+
theme.fg("muted", fit(entry.description || "No description", width)),
|
|
137
|
+
theme.fg("dim", `health: ${health} · ${override} · used: ${use?.count ?? 0}×`),
|
|
138
|
+
theme.fg("dim", `file: ${fit(entry.filePath, Math.max(8, width - 6))}`),
|
|
139
|
+
theme.fg("dim", `frontmatter: ${entry.frontmatterKeys.join(", ") || "none"}`),
|
|
140
|
+
theme.fg("dim", `model invocation: ${entry.disableModelInvocation ? "off" : "on"}`),
|
|
141
|
+
theme.fg("accent", `REFERENCES · ${refs.length}`),
|
|
142
|
+
];
|
|
143
|
+
if (refs.length === 0) lines.push(theme.fg("dim", " none detected"));
|
|
144
|
+
for (const ref of refs.slice(0, 5)) {
|
|
145
|
+
lines.push(theme.fg(ref.exists ? "muted" : "warning", ` ${ref.exists ? "✓" : "✗"} ${fit(ref.href, Math.max(8, width - 6))}`));
|
|
146
|
+
}
|
|
147
|
+
return lines;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function adviceModeLabel(mode: AdviseMode): string {
|
|
151
|
+
return mode === "explain" ? "explain" : mode === "integrate" ? "integrate" : mode === "examples" ? "examples" : "improve";
|
|
152
|
+
}
|
|
60
153
|
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
:
|
|
65
|
-
|
|
66
|
-
|
|
154
|
+
function renderAdvice(theme: Theme, width: number, entry: SkillEntry | null, pane: AdvicePane, mode: AdviseMode): string[] {
|
|
155
|
+
const lines = [
|
|
156
|
+
theme.fg("accent", `AI ADVICE · ${adviceModeLabel(mode)}`),
|
|
157
|
+
entry ? theme.fg("muted", `skill: ${entry.name}`) : theme.fg("dim", "Select a skill first"),
|
|
158
|
+
theme.fg("dim", "r run · e explain · g integrate · x examples · m improve · i insert"),
|
|
159
|
+
];
|
|
160
|
+
if (pane.state === "running") lines.push(theme.fg("accent", "Thinking…"));
|
|
161
|
+
if (pane.state === "unavailable") lines.push(theme.fg("warning", `Advice unavailable: ${pane.error ?? "unknown error"}`));
|
|
162
|
+
const text = pane.text.trim();
|
|
163
|
+
if (text) {
|
|
164
|
+
lines.push(theme.fg("text", fit(text.replace(/\n/g, " "), Math.max(8, width - 2))));
|
|
165
|
+
} else if (pane.state === "idle") {
|
|
166
|
+
lines.push(theme.fg("dim", "Advice uses the selected skill and its local references."));
|
|
167
|
+
}
|
|
168
|
+
return lines;
|
|
169
|
+
}
|
|
67
170
|
|
|
68
|
-
|
|
171
|
+
export function renderStudioFrame(
|
|
172
|
+
theme: Theme,
|
|
173
|
+
width: number,
|
|
174
|
+
state: StudioState,
|
|
175
|
+
options: Pick<StudioComponentOptions, "entries" | "usage" | "advicePane" | "adviceMode"> = {},
|
|
176
|
+
): string[] {
|
|
177
|
+
if (state.mode === "help") return renderHelp(theme);
|
|
178
|
+
const entries = options.entries ?? [];
|
|
179
|
+
const usage = options.usage ?? new Map<string, SkillUsage>();
|
|
180
|
+
const pane = options.advicePane ?? { state: "idle", text: "", error: null } as AdvicePane;
|
|
181
|
+
const entry = selectedEntry(entries, state.filterQuery, state.selectedIndex);
|
|
182
|
+
const mode: AdviseMode = options.adviceMode ?? "explain";
|
|
183
|
+
const innerWidth = Math.max(20, width - 2);
|
|
184
|
+
const lines = [
|
|
69
185
|
theme.fg("accent", "Skill Studio"),
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
theme.fg("dim", "─".repeat(Math.max(1, Math.min(width - 1, 60)))),
|
|
73
|
-
theme.fg("muted", `selected: ${state.selectedIndex}`),
|
|
74
|
-
theme.fg("dim", "panes populate in upcoming units (browse, actions, advice)"),
|
|
75
|
-
theme.fg("dim", "─".repeat(Math.max(1, Math.min(width - 1, 60)))),
|
|
76
|
-
theme.fg("dim", "q/Esc exit · / filter · Tab focus · ? help"),
|
|
186
|
+
STUDIO_PANES.map((paneId) => state.focus === paneId ? theme.fg("accent", `[${PANE_LABELS[paneId]}]`) : theme.fg("dim", ` ${PANE_LABELS[paneId]} `)).join(" "),
|
|
187
|
+
theme.fg("dim", "─".repeat(Math.max(1, Math.min(innerWidth, 72)))),
|
|
77
188
|
];
|
|
189
|
+
lines.push(...renderList(theme, innerWidth, entries, state));
|
|
190
|
+
lines.push(theme.fg("dim", "─".repeat(Math.max(1, Math.min(innerWidth, 72)))));
|
|
191
|
+
lines.push(...renderDetail(theme, innerWidth, entry, usage));
|
|
192
|
+
lines.push(theme.fg("dim", "─".repeat(Math.max(1, Math.min(innerWidth, 72)))));
|
|
193
|
+
lines.push(theme.fg("accent", "ACTIONS"));
|
|
194
|
+
lines.push(theme.fg("muted", "n create · e edit · d doctor · Enter detail"));
|
|
195
|
+
lines.push(theme.fg("dim", "─".repeat(Math.max(1, Math.min(innerWidth, 72)))));
|
|
196
|
+
lines.push(...renderAdvice(theme, innerWidth, entry, pane, mode));
|
|
197
|
+
lines.push(theme.fg("dim", "─".repeat(Math.max(1, Math.min(innerWidth, 72)))));
|
|
198
|
+
lines.push(theme.fg("dim", "q/Esc exit · / filter · Tab focus · ? help"));
|
|
78
199
|
return lines;
|
|
79
200
|
}
|
|
80
201
|
|
|
@@ -82,22 +203,77 @@ export function createStudioComponent(
|
|
|
82
203
|
theme: Theme,
|
|
83
204
|
done: (value: string | null) => void,
|
|
84
205
|
onStateChange?: (state: StudioState) => void,
|
|
206
|
+
options: StudioComponentOptions = {},
|
|
85
207
|
) {
|
|
86
208
|
let state = createStudioState();
|
|
209
|
+
let entries = [...(options.entries ?? [])];
|
|
210
|
+
let adviceMode: AdviseMode = "explain";
|
|
211
|
+
let finished = false;
|
|
212
|
+
const pane = options.advicePane ?? {
|
|
213
|
+
state: "idle",
|
|
214
|
+
text: "",
|
|
215
|
+
error: null,
|
|
216
|
+
run: async () => {},
|
|
217
|
+
reset: () => {},
|
|
218
|
+
} as AdvicePane;
|
|
219
|
+
|
|
220
|
+
const refresh = () => {
|
|
221
|
+
const next = options.onRefresh?.();
|
|
222
|
+
if (next) entries = [...next];
|
|
223
|
+
};
|
|
224
|
+
const current = () => selectedEntry(entries, state.filterQuery, state.selectedIndex);
|
|
225
|
+
const notifyChange = (next: StudioState) => {
|
|
226
|
+
state = next;
|
|
227
|
+
onStateChange?.(state);
|
|
228
|
+
};
|
|
229
|
+
const invoke = (work: () => Promise<void> | void) => {
|
|
230
|
+
void Promise.resolve(work()).then(() => {
|
|
231
|
+
refresh();
|
|
232
|
+
}).catch((error: unknown) => options.onError?.(error));
|
|
233
|
+
};
|
|
234
|
+
const focus = (paneId: StudioPaneId) => notifyChange({ ...state, focus: paneId });
|
|
235
|
+
const runAdvice = () => {
|
|
236
|
+
const entry = current();
|
|
237
|
+
if (!entry || !options.onAdvice) return;
|
|
238
|
+
invoke(() => options.onAdvice!(entry, adviceMode, pane));
|
|
239
|
+
};
|
|
87
240
|
|
|
88
241
|
return {
|
|
89
242
|
focused: true,
|
|
90
243
|
invalidate() {},
|
|
91
244
|
render(width: number) {
|
|
92
|
-
return renderStudioFrame(theme, width, state);
|
|
245
|
+
return renderStudioFrame(theme, width, state, { entries, usage: options.usage, advicePane: pane, adviceMode });
|
|
93
246
|
},
|
|
94
247
|
handleInput(data: string) {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
248
|
+
if (state.mode === "normal") {
|
|
249
|
+
if (data >= "1" && data <= "4") {
|
|
250
|
+
focus(STUDIO_PANES[Number(data) - 1] ?? "list");
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (data === "\r" || matchesKey(data, "return")) {
|
|
254
|
+
if (state.focus === "list") focus("detail");
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (state.focus === "advice") {
|
|
258
|
+
if (data === "e") adviceMode = "explain";
|
|
259
|
+
else if (data === "g") adviceMode = "integrate";
|
|
260
|
+
else if (data === "x") adviceMode = "examples";
|
|
261
|
+
else if (data === "m") adviceMode = "improve";
|
|
262
|
+
else if (data === "r") runAdvice();
|
|
263
|
+
else if (data === "i" && options.onInsert) invoke(() => options.onInsert!(pane));
|
|
264
|
+
} else if (state.focus === "actions" || state.focus === "detail" || state.focus === "list") {
|
|
265
|
+
if (data === "n" && options.onCreate) invoke(() => options.onCreate!());
|
|
266
|
+
else if (data === "e" && current() && options.onEdit) invoke(() => options.onEdit!(current()!));
|
|
267
|
+
else if (data === "d" && options.onDoctor) invoke(() => options.onDoctor!());
|
|
268
|
+
else if (data === "a") focus("advice");
|
|
269
|
+
else if (data === "i" && pane.state === "ok" && options.onInsert) invoke(() => options.onInsert!(pane));
|
|
270
|
+
else if (state.focus === "detail" && data === "r") runAdvice();
|
|
271
|
+
}
|
|
99
272
|
}
|
|
100
|
-
|
|
273
|
+
const next = handleStudioKey(state, mapRawInput(data));
|
|
274
|
+
if (next !== state) notifyChange(next);
|
|
275
|
+
if (state.exitRequested && !finished) {
|
|
276
|
+
finished = true;
|
|
101
277
|
done(null);
|
|
102
278
|
}
|
|
103
279
|
},
|
|
@@ -1,11 +1,21 @@
|
|
|
1
|
-
/** DeepWiki disk cache (U9). TTL + stale fallback
|
|
2
|
-
* the cache directory count actually grows). */
|
|
1
|
+
/** DeepWiki disk cache (U9). TTL + stale fallback + bounded LRU eviction. */
|
|
3
2
|
|
|
4
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
statSync,
|
|
9
|
+
unlinkSync,
|
|
10
|
+
utimesSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
} from "node:fs";
|
|
5
13
|
import { join } from "node:path";
|
|
6
14
|
import { getAgentPath } from "../../paths/agent-dirs.ts";
|
|
7
15
|
import type { RepoRef } from "./extract.ts";
|
|
8
16
|
|
|
17
|
+
const DEFAULT_MAX_ENTRIES = 64;
|
|
18
|
+
|
|
9
19
|
export function resolveCacheDir(override?: string): string {
|
|
10
20
|
return override ?? getAgentPath("wishcraft-cache", "deepwiki");
|
|
11
21
|
}
|
|
@@ -21,32 +31,96 @@ export interface CacheReadResult<T> {
|
|
|
21
31
|
entry: CacheFile<T> | null;
|
|
22
32
|
}
|
|
23
33
|
|
|
34
|
+
export interface CacheOptions {
|
|
35
|
+
ttlMs: number;
|
|
36
|
+
now?: number;
|
|
37
|
+
maxEntries?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
24
40
|
function fileFor(dir: string, repo: RepoRef): string {
|
|
25
41
|
return join(dir, repo.owner, `${repo.repo}.json`);
|
|
26
42
|
}
|
|
27
43
|
|
|
44
|
+
function cacheFiles(dir: string): string[] {
|
|
45
|
+
const files: string[] = [];
|
|
46
|
+
let owners: string[];
|
|
47
|
+
try {
|
|
48
|
+
owners = readdirSync(dir);
|
|
49
|
+
} catch {
|
|
50
|
+
return files;
|
|
51
|
+
}
|
|
52
|
+
for (const owner of owners) {
|
|
53
|
+
const ownerDir = join(dir, owner);
|
|
54
|
+
let entries: string[];
|
|
55
|
+
try {
|
|
56
|
+
entries = readdirSync(ownerDir);
|
|
57
|
+
} catch {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
for (const entry of entries) {
|
|
61
|
+
if (entry.endsWith(".json")) files.push(join(ownerDir, entry));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return files;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Remove least-recently-used entries until the cache is within its cap. */
|
|
68
|
+
export function evictCacheEntries(dir: string, maxEntries = DEFAULT_MAX_ENTRIES): void {
|
|
69
|
+
const cap = Math.max(1, Math.floor(maxEntries));
|
|
70
|
+
const files = cacheFiles(dir);
|
|
71
|
+
if (files.length <= cap) return;
|
|
72
|
+
const ordered = files
|
|
73
|
+
.map((file) => {
|
|
74
|
+
try {
|
|
75
|
+
return { file, atimeMs: statSync(file).atimeMs, mtimeMs: statSync(file).mtimeMs };
|
|
76
|
+
} catch {
|
|
77
|
+
return { file, atimeMs: 0, mtimeMs: 0 };
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
.sort((a, b) => (a.atimeMs - b.atimeMs) || (a.mtimeMs - b.mtimeMs));
|
|
81
|
+
for (const item of ordered.slice(0, files.length - cap)) {
|
|
82
|
+
try {
|
|
83
|
+
unlinkSync(item.file);
|
|
84
|
+
} catch {
|
|
85
|
+
// A concurrent cleanup must not break advice or cache reads.
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function touch(file: string, now: number): void {
|
|
91
|
+
try {
|
|
92
|
+
const date = new Date(now);
|
|
93
|
+
utimesSync(file, date, date);
|
|
94
|
+
} catch {
|
|
95
|
+
// Cache recency is best-effort metadata.
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
28
99
|
export async function writeCacheEntry<T>(
|
|
29
100
|
dir: string,
|
|
30
101
|
repo: RepoRef,
|
|
31
102
|
data: T,
|
|
32
|
-
options:
|
|
103
|
+
options: CacheOptions,
|
|
33
104
|
): Promise<void> {
|
|
34
105
|
const now = options.now ?? Date.now();
|
|
35
106
|
const file = fileFor(dir, repo);
|
|
36
107
|
mkdirSync(join(dir, repo.owner), { recursive: true });
|
|
37
108
|
writeFileSync(file, JSON.stringify({ savedAt: now, data }), "utf8");
|
|
109
|
+
touch(file, now);
|
|
110
|
+
evictCacheEntries(dir, options.maxEntries);
|
|
38
111
|
}
|
|
39
112
|
|
|
40
113
|
export async function readCacheEntry<T>(
|
|
41
114
|
dir: string,
|
|
42
115
|
repo: RepoRef,
|
|
43
|
-
options:
|
|
116
|
+
options: CacheOptions,
|
|
44
117
|
): Promise<CacheReadResult<T>> {
|
|
45
118
|
const now = options.now ?? Date.now();
|
|
46
119
|
const file = fileFor(dir, repo);
|
|
47
120
|
if (!existsSync(file)) return { status: "miss", stale: false, entry: null };
|
|
48
121
|
try {
|
|
49
122
|
const raw = JSON.parse(readFileSync(file, "utf8")) as CacheFile<T>;
|
|
123
|
+
touch(file, now);
|
|
50
124
|
const fresh = now - raw.savedAt <= options.ttlMs;
|
|
51
125
|
return { status: fresh ? "hit" : "miss", stale: !fresh, entry: raw };
|
|
52
126
|
} catch {
|
|
@@ -57,7 +131,7 @@ export async function readCacheEntry<T>(
|
|
|
57
131
|
export async function withCache<T>(
|
|
58
132
|
dir: string,
|
|
59
133
|
repo: RepoRef,
|
|
60
|
-
options:
|
|
134
|
+
options: CacheOptions,
|
|
61
135
|
networkFetch: () => Promise<T>,
|
|
62
136
|
): Promise<CacheReadResult<T>> {
|
|
63
137
|
const fresh = await readCacheEntry<T>(dir, repo, options);
|