@groeponline/pi-wishcraft 1.4.0 → 1.6.0

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.
Files changed (64) hide show
  1. package/AGENTS.md +28 -0
  2. package/CHANGELOG.md +25 -0
  3. package/README.md +22 -18
  4. package/ROADMAP.md +3 -3
  5. package/STRATEGY.md +54 -0
  6. package/bash-mode/editor-input.ts +3 -1
  7. package/bash-mode/editor.ts +44 -7
  8. package/bash-mode/forward.ts +64 -0
  9. package/bash-mode/pty-session.ts +356 -0
  10. package/bash-mode/ptyshell-managed.ts +145 -0
  11. package/bash-mode/session-factory.ts +64 -0
  12. package/bash-mode/transcript-v2.ts +77 -0
  13. package/bash-mode/transcript.ts +18 -0
  14. package/bash-mode/types.ts +27 -0
  15. package/docs/bash-mode.md +4 -0
  16. package/docs/configuration.md +45 -1
  17. package/docs/index.md +4 -1
  18. package/package.json +1 -1
  19. package/src/config/effective.ts +122 -0
  20. package/src/config/settings-registry.ts +2 -0
  21. package/src/config/structural-presets.ts +1 -1
  22. package/src/extension/commands/bash-mode-actions.ts +11 -10
  23. package/src/extension/commands/commands.ts +8 -0
  24. package/src/extension/core/state.ts +0 -1
  25. package/src/extension/core/types.ts +2 -2
  26. package/src/extension/session/session-lifecycle.ts +21 -0
  27. package/src/extension/skills/skill-templates.ts +12 -3
  28. package/src/extension/ui/custom-editor.ts +4 -0
  29. package/src/extension/ui/status-line-renderers.ts +6 -7
  30. package/src/extension/welcome/welcome-integration.ts +23 -1
  31. package/src/motion/frames.ts +19 -9
  32. package/src/render/layout.ts +95 -0
  33. package/src/render/motion-rail.ts +79 -0
  34. package/src/render/paint.ts +40 -0
  35. package/src/render/v2-adapter.ts +68 -0
  36. package/src/render/v2-entry.ts +132 -0
  37. package/src/segments/pipeline.ts +164 -0
  38. package/src/segments/types.ts +36 -0
  39. package/src/segments/usage.ts +42 -3
  40. package/src/studio/actions.ts +105 -0
  41. package/src/studio/advice-pane.ts +73 -0
  42. package/src/studio/advise/context.ts +89 -0
  43. package/src/studio/advise/engine.ts +117 -0
  44. package/src/studio/advise/prompts.ts +68 -0
  45. package/src/studio/component.ts +105 -0
  46. package/src/studio/deepwiki/cache.ts +74 -0
  47. package/src/studio/deepwiki/client.ts +73 -0
  48. package/src/studio/deepwiki/extract.ts +66 -0
  49. package/src/studio/inspect.ts +38 -0
  50. package/src/studio/list.ts +48 -0
  51. package/src/studio/open.ts +44 -0
  52. package/src/studio/state.ts +100 -0
  53. package/src/studio/types.ts +30 -0
  54. package/src/welcome/banner.ts +6 -0
  55. package/src/welcome/index.ts +7 -0
  56. package/src/welcome/overlay.ts +6 -0
  57. package/src/welcome/renderer.ts +6 -39
  58. package/src/welcome/types.ts +6 -0
  59. package/src/welcome/welcome-art.ts +148 -0
  60. package/src/working-vibes/frames.ts +25 -0
  61. package/src/working-vibes/index.ts +2 -0
  62. package/src/working-vibes/storage.ts +11 -0
  63. package/bash-mode/shell-session.ts +0 -294
  64. package/src/signal/render.ts +0 -221
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Studio action executor (U7). Wires the existing skill backend
3
+ * (templates, doctor) into a single action runner the Studio action pane
4
+ * calls. Destructive paths (overwrite) route through the supplied
5
+ * `confirm` callback so the action pane can render the same
6
+ * confirm step it already shows for delete/overwrite elsewhere.
7
+ */
8
+
9
+ import { existsSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import {
12
+ SkillNameError,
13
+ writeSkillFromTemplate,
14
+ type SkillTemplateId,
15
+ } from "../extension/skills/skill-templates.ts";
16
+ import { collectSkillDoctorInputs, diagnoseSkills } from "../extension/skills/skill-doctor.ts";
17
+
18
+ export type StudioAction =
19
+ | {
20
+ type: "create";
21
+ name: string;
22
+ template: SkillTemplateId;
23
+ skillsRoot: string;
24
+ }
25
+ | {
26
+ type: "doctor";
27
+ cwd: string;
28
+ };
29
+
30
+ export interface ActionContext {
31
+ confirm: (message: string) => Promise<boolean>;
32
+ }
33
+
34
+ export type ActionResultKind = "ok" | "error" | "declined";
35
+
36
+ export interface ActionResult {
37
+ kind: ActionResultKind;
38
+ message: string;
39
+ filePath?: string;
40
+ }
41
+
42
+ export async function runStudioAction(
43
+ action: StudioAction,
44
+ ctx: ActionContext,
45
+ ): Promise<ActionResult> {
46
+ if (action.type === "create") {
47
+ return runCreate(action, ctx);
48
+ }
49
+ if (action.type === "doctor") {
50
+ return runDoctor(action);
51
+ }
52
+ return { kind: "error", message: `Unknown action` };
53
+ }
54
+
55
+ async function runCreate(
56
+ action: Extract<StudioAction, { type: "create" }>,
57
+ ctx: ActionContext,
58
+ ): Promise<ActionResult> {
59
+ const safe = action.name.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-");
60
+ if (!safe || safe === "-" || safe.includes("..")) {
61
+ return { kind: "error", message: `Invalid skill name: ${action.name}` };
62
+ }
63
+ const filePath = join(action.skillsRoot, safe, "SKILL.md");
64
+ if (existsSync(filePath)) {
65
+ const ok = await ctx.confirm(`Skill "${safe}" exists. Overwrite?`);
66
+ if (!ok) {
67
+ return { kind: "declined", message: `Cancelled: ${safe} unchanged`, filePath };
68
+ }
69
+ try {
70
+ // Atomic overwrite: the replacement is written to a temp file and
71
+ // renamed over SKILL.md only after it succeeds, so a failed write
72
+ // never destroys the existing skill.
73
+ writeSkillFromTemplate(safe, action.template, action.skillsRoot, {
74
+ overwrite: true,
75
+ });
76
+ return { kind: "ok", message: `Overwrote ${safe}`, filePath };
77
+ } catch (err) {
78
+ return { kind: "error", message: errorMessage(err) };
79
+ }
80
+ }
81
+ try {
82
+ writeSkillFromTemplate(safe, action.template, action.skillsRoot);
83
+ return { kind: "ok", message: `Created ${safe}`, filePath };
84
+ } catch (err) {
85
+ return { kind: "error", message: errorMessage(err) };
86
+ }
87
+ }
88
+
89
+ function runDoctor(action: Extract<StudioAction, { type: "doctor" }>): ActionResult {
90
+ const { entries, usage, contents } = collectSkillDoctorInputs(action.cwd);
91
+ const rows = diagnoseSkills(entries, usage, contents);
92
+ const ok = rows.filter((r) => r.status === "ok").length;
93
+ const warn = rows.filter((r) => r.status === "warn").length;
94
+ const fail = rows.filter((r) => r.status === "fail").length;
95
+ return {
96
+ kind: "ok",
97
+ message: `Doctor: ${rows.length} skills · ${ok} ok · ${warn} warn · ${fail} fail`,
98
+ };
99
+ }
100
+
101
+ function errorMessage(err: unknown): string {
102
+ if (err instanceof SkillNameError) return err.message;
103
+ if (err instanceof Error) return err.message;
104
+ return String(err);
105
+ }
@@ -0,0 +1,73 @@
1
+ import { runAdvice, type RunAdviceOptions, type AdviseStreamProvider } from "./advise/engine.ts";
2
+
3
+ export type AdvicePaneState = "idle" | "running" | "ok" | "unavailable";
4
+
5
+ export interface AdvicePane {
6
+ state: AdvicePaneState;
7
+ text: string;
8
+ error: string | null;
9
+ run(opts: Omit<RunAdviceOptions, "onChunk" | "provider"> & {
10
+ provider: AdviseStreamProvider | null;
11
+ callbacks?: AdvicePaneCallbacks;
12
+ }): Promise<void>;
13
+ reset(): void;
14
+ }
15
+
16
+ export interface AdvicePaneCallbacks {
17
+ onChunk?: (text: string) => void;
18
+ }
19
+
20
+ export function createAdvicePane(): AdvicePane {
21
+ const pane: AdvicePane = {
22
+ state: "idle",
23
+ text: "",
24
+ error: null,
25
+ async run(opts) {
26
+ pane.state = "running";
27
+ pane.text = "";
28
+ pane.error = null;
29
+ const result = await runAdvice({
30
+ mode: opts.mode,
31
+ skillName: opts.skillName,
32
+ body: opts.body,
33
+ references: opts.references,
34
+ wiki: opts.wiki,
35
+ maxChars: opts.maxChars,
36
+ provider: opts.provider,
37
+ model: opts.model,
38
+ signal: opts.signal,
39
+ onChunk: (delta) => {
40
+ pane.text += delta;
41
+ opts.callbacks?.onChunk?.(delta);
42
+ },
43
+ });
44
+ if (result.kind === "ok") {
45
+ pane.state = "ok";
46
+ pane.error = null;
47
+ } else {
48
+ pane.state = "unavailable";
49
+ pane.error = result.reason;
50
+ }
51
+ },
52
+ reset() {
53
+ pane.state = "idle";
54
+ pane.text = "";
55
+ pane.error = null;
56
+ },
57
+ };
58
+ return pane;
59
+ }
60
+
61
+ // ponytail: typed `unknown` session — Studio wires the real session at call
62
+ // time. Kept narrow to `appendUserMessage(string)` so we do not depend on
63
+ // the full AgentSession shape (TUI imports, model registry, etc.).
64
+ export interface AdviceSessionSink {
65
+ appendUserMessage(text: string): unknown;
66
+ }
67
+
68
+ export function advicePaneInsert(pane: AdvicePane, session: AdviceSessionSink): boolean {
69
+ if (pane.state !== "ok") return false;
70
+ if (pane.text.length === 0) return false;
71
+ session.appendUserMessage(pane.text);
72
+ return true;
73
+ }
@@ -0,0 +1,89 @@
1
+ // ponytail: char-based cap; full token-aware budgeting needs a tokenizer that
2
+ // we do not want to depend on. Document the ceiling and upgrade path.
3
+ export interface AdviseContextSection {
4
+ name: string;
5
+ content: string;
6
+ }
7
+
8
+ export interface AdviseContextInput {
9
+ body: string;
10
+ references: AdviseContextSection[];
11
+ wiki: AdviseContextSection[];
12
+ maxChars: number;
13
+ }
14
+
15
+ export interface AdviseContext {
16
+ body: string;
17
+ references: AdviseContextSection[];
18
+ wiki: AdviseContextSection[];
19
+ dropped: { wiki: number; references: number; body: boolean };
20
+ budget: { cap: number; used: number };
21
+ }
22
+
23
+ export function capContextByChars(text: string, cap: number): { content: string; truncated: boolean } {
24
+ if (text.length <= cap) {
25
+ return { content: text, truncated: false };
26
+ }
27
+ return { content: text.slice(0, cap), truncated: true };
28
+ }
29
+
30
+ export function buildAdviseContext(input: AdviseContextInput): AdviseContext {
31
+ const cap = Math.max(0, input.maxChars);
32
+ let used = 0;
33
+ const dropped = { wiki: 0, references: 0, body: false };
34
+
35
+ // Body is highest priority and never truncated: if the body itself exceeds
36
+ // the cap, we still keep it and mark body=true so the caller knows. Body
37
+ // shape (skill file) is what advice is grounded in.
38
+ const body = input.body;
39
+ used += body.length;
40
+
41
+ const references: AdviseContextSection[] = [];
42
+ for (const ref of input.references) {
43
+ if (used >= cap) {
44
+ dropped.references += 1;
45
+ continue;
46
+ }
47
+ const remaining = cap - used;
48
+ const capped = capContextByChars(ref.content, remaining);
49
+ references.push({ name: ref.name, content: capped.content });
50
+ used += capped.content.length;
51
+ if (capped.truncated) {
52
+ // We filled the rest of the budget with this reference; drop the rest.
53
+ dropped.references += input.references.length - references.length;
54
+ break;
55
+ }
56
+ }
57
+
58
+ // Wiki is lowest priority: drop from the back when full.
59
+ const wiki: AdviseContextSection[] = [];
60
+ for (const w of input.wiki) {
61
+ if (used + w.content.length > cap) {
62
+ // Try a partial if there is room at all.
63
+ const remaining = Math.max(0, cap - used);
64
+ if (remaining > 0) {
65
+ const capped = capContextByChars(w.content, remaining);
66
+ wiki.push({ name: w.name, content: capped.content });
67
+ used += capped.content.length;
68
+ dropped.wiki += 1;
69
+ } else {
70
+ dropped.wiki += 1;
71
+ }
72
+ continue;
73
+ }
74
+ wiki.push({ name: w.name, content: w.content });
75
+ used += w.content.length;
76
+ }
77
+
78
+ if (used > cap) {
79
+ dropped.body = true;
80
+ }
81
+
82
+ return {
83
+ body,
84
+ references,
85
+ wiki,
86
+ dropped,
87
+ budget: { cap, used: Math.min(used, cap + body.length) },
88
+ };
89
+ }
@@ -0,0 +1,117 @@
1
+ import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
2
+ import { buildAdviseContext, type AdviseContext, type AdviseContextSection } from "./context.ts";
3
+ import { buildPrompt, type AdviseMode, type AdvisePrompt } from "./prompts.ts";
4
+
5
+ /**
6
+ * Provider-agnostic surface the engine needs: any object exposing a
7
+ * `stream` with the pi-ai shape. The model is the engine's local
8
+ * {@link AdviseModel} contract — production adapters map it to a fully
9
+ * populated pi-ai `Model` at the boundary, so no incomplete object is ever
10
+ * cast into one here.
11
+ */
12
+ export interface AdviseStreamContext extends AdviseContext {
13
+ /** Built prompt (system + rendered user instructions). Providers that
14
+ * construct their own LLM context from the sections may ignore it. */
15
+ prompt?: AdvisePrompt;
16
+ }
17
+
18
+ export interface AdviseStreamProvider {
19
+ stream(
20
+ model: AdviseModel,
21
+ context: AdviseStreamContext,
22
+ options: { signal: AbortSignal; maxTokens?: number },
23
+ ): AsyncIterable<unknown>;
24
+ }
25
+
26
+ export interface AdviseModel {
27
+ id: string;
28
+ contextWindow: number;
29
+ maxTokens?: number;
30
+ }
31
+
32
+ export interface RunAdviceOptions {
33
+ mode: AdviseMode;
34
+ skillName: string;
35
+ body: string;
36
+ references: AdviseContextSection[];
37
+ wiki: AdviseContextSection[];
38
+ maxChars?: number;
39
+ provider: AdviseStreamProvider | null;
40
+ model?: AdviseModel;
41
+ signal: AbortSignal;
42
+ onChunk?: (text: string) => void;
43
+ }
44
+
45
+ export type RunAdviceResult =
46
+ | { kind: "ok"; text: string }
47
+ | { kind: "unavailable"; reason: "no-model" | "aborted" | "no-text" };
48
+
49
+ const DEFAULT_MAX_CHARS = 24_000;
50
+ const DEFAULT_MODEL: AdviseModel = {
51
+ id: "advisor-faux",
52
+ contextWindow: 32_000,
53
+ maxTokens: 1024,
54
+ };
55
+
56
+ export async function runAdvice(opts: RunAdviceOptions): Promise<RunAdviceResult> {
57
+ if (opts.signal.aborted) {
58
+ return { kind: "unavailable", reason: "aborted" };
59
+ }
60
+ if (!opts.provider) {
61
+ return { kind: "unavailable", reason: "no-model" };
62
+ }
63
+ const model = opts.model ?? DEFAULT_MODEL;
64
+ const maxChars = opts.maxChars ?? DEFAULT_MAX_CHARS;
65
+ const ctx: AdviseStreamContext = buildAdviseContext({
66
+ body: opts.body,
67
+ references: opts.references,
68
+ wiki: opts.wiki,
69
+ maxChars,
70
+ });
71
+ ctx.prompt = buildPrompt(opts.mode, {
72
+ skillName: opts.skillName,
73
+ body: ctx.body,
74
+ references: ctx.references,
75
+ wiki: ctx.wiki,
76
+ });
77
+
78
+ const stream = opts.provider.stream(model, ctx, {
79
+ signal: opts.signal,
80
+ maxTokens: opts.model?.maxTokens ?? DEFAULT_MODEL.maxTokens,
81
+ });
82
+
83
+ let text = "";
84
+ try {
85
+ for await (const ev of stream) {
86
+ if (opts.signal.aborted) {
87
+ return { kind: "unavailable", reason: "aborted" };
88
+ }
89
+ // Defensive text extraction: anything with a string .delta or .text
90
+ // counts. Avoids pinning the engine to pi-ai's AssistantMessageEvent.
91
+ const anyEv = ev as { delta?: unknown; text?: unknown };
92
+ const delta =
93
+ typeof anyEv.delta === "string"
94
+ ? anyEv.delta
95
+ : typeof anyEv.text === "string"
96
+ ? anyEv.text
97
+ : "";
98
+ if (delta) {
99
+ text += delta;
100
+ opts.onChunk?.(delta);
101
+ }
102
+ }
103
+ } catch (err) {
104
+ if (opts.signal.aborted) {
105
+ return { kind: "unavailable", reason: "aborted" };
106
+ }
107
+ throw err;
108
+ }
109
+ if (text.length === 0) {
110
+ return { kind: "unavailable", reason: "no-text" };
111
+ }
112
+ return { kind: "ok", text };
113
+ }
114
+
115
+ // Re-export the pi-ai factory so call sites that want to plug a real stream
116
+ // do not have to import pi-ai themselves.
117
+ export { createAssistantMessageEventStream };
@@ -0,0 +1,68 @@
1
+ import type { AdviseContextSection } from "./context.ts";
2
+
3
+ export type AdviseMode = "explain" | "integrate" | "examples" | "improve";
4
+
5
+ export interface AdvisePromptInput {
6
+ skillName: string;
7
+ body: string;
8
+ references: AdviseContextSection[];
9
+ wiki: AdviseContextSection[];
10
+ }
11
+
12
+ export interface AdvisePrompt {
13
+ system: string;
14
+ user: string;
15
+ }
16
+
17
+ const SYSTEM_BASE =
18
+ "You advise a chef who is building or auditing a skill. Reply in English. " +
19
+ "Stay grounded in the skill body and the provided references; do not invent " +
20
+ "APIs or behavior. Prefer concrete, copy-paste-ready guidance.";
21
+
22
+ const MODE_INSTRUCTIONS: Record<AdviseMode, string> = {
23
+ explain:
24
+ "Explain what this skill does, when to use it, and when to avoid it. " +
25
+ "Be specific. Reference the existing body rather than restating it.",
26
+ integrate:
27
+ "Show how to wire this skill into the existing repo. Identify the files, " +
28
+ "settings, and commands that already exist, and describe the smallest " +
29
+ "change that adds the skill to the user-facing flow.",
30
+ examples:
31
+ "Produce two or three example invocations or scenarios that demonstrate " +
32
+ "the skill's value. Each example must be runnable as written.",
33
+ improve:
34
+ "Find concrete weaknesses in the skill body: missing inputs, broken " +
35
+ "promises, unclear copy, missing tests. Suggest the smallest change for " +
36
+ "each finding.",
37
+ };
38
+
39
+ function renderSection(title: string, sections: AdviseContextSection[]): string {
40
+ if (sections.length === 0) return `## ${title}\n(none)`;
41
+ const lines = [`## ${title}`];
42
+ for (const s of sections) {
43
+ lines.push(`### ${s.name}`);
44
+ lines.push(s.content);
45
+ lines.push("");
46
+ }
47
+ return lines.join("\n");
48
+ }
49
+
50
+ export function buildPrompt(mode: AdviseMode, input: AdvisePromptInput): AdvisePrompt {
51
+ const lines: string[] = [];
52
+ lines.push(`# Skill: ${input.skillName}`);
53
+ lines.push("");
54
+ lines.push("## Body");
55
+ lines.push(input.body);
56
+ lines.push("");
57
+ lines.push(renderSection("References", input.references));
58
+ lines.push("");
59
+ lines.push(renderSection("Upstream wiki (context only)", input.wiki));
60
+ lines.push("");
61
+ lines.push("## Task");
62
+ lines.push(MODE_INSTRUCTIONS[mode]);
63
+
64
+ return {
65
+ system: SYSTEM_BASE,
66
+ user: lines.join("\n"),
67
+ };
68
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Fullscreen studio component (U5, KTD3). Non-overlay `ctx.ui.custom()` —
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
+ */
6
+
7
+ import { matchesKey } from "@earendil-works/pi-tui";
8
+ import type { Theme } from "@earendil-works/pi-coding-agent";
9
+ import {
10
+ createStudioState,
11
+ handleStudioKey,
12
+ STUDIO_PANES,
13
+ } from "./state.ts";
14
+ import type { StudioKeyEvent, StudioState } from "./types.ts";
15
+
16
+ const PANE_LABELS: Record<string, string> = {
17
+ list: "Skills",
18
+ detail: "Detail",
19
+ actions: "Actions",
20
+ advice: "AI Advice",
21
+ };
22
+
23
+ const HELP_LINES: readonly string[] = [
24
+ "Skill Studio — keys",
25
+ "",
26
+ " j / k or arrows Move selection",
27
+ " / Filter skills",
28
+ " Tab Cycle pane focus",
29
+ " ? Toggle this help",
30
+ " q / Esc Exit studio",
31
+ ];
32
+
33
+ export function mapRawInput(data: string): StudioKeyEvent {
34
+ if (matchesKey(data, "escape")) return { key: "escape" };
35
+ if (matchesKey(data, "return")) return { key: "return" };
36
+ if (matchesKey(data, "tab")) return { key: "tab" };
37
+ if (matchesKey(data, "up")) return { key: "up" };
38
+ if (matchesKey(data, "down")) return { key: "down" };
39
+ if (data === "\x7f" || data === "\b" || matchesKey(data, "backspace")) {
40
+ return { key: "backspace" };
41
+ }
42
+ if (data.length === 1 && data >= " " && data <= "~") {
43
+ return { key: "printable", char: data };
44
+ }
45
+ return { key: "other" };
46
+ }
47
+
48
+ export function renderStudioFrame(theme: Theme, width: number, state: StudioState): string[] {
49
+ if (state.mode === "help") {
50
+ const lines = [theme.fg("accent", HELP_LINES[0] ?? ""), ""];
51
+ for (const line of HELP_LINES.slice(2)) {
52
+ lines.push(theme.fg("muted", line));
53
+ }
54
+ lines.push("", theme.fg("dim", "Press q, Esc, or Enter to close help"));
55
+ return lines;
56
+ }
57
+
58
+ const focusMark = (pane: string): string =>
59
+ state.focus === pane ? theme.fg("accent", `[${PANE_LABELS[pane] ?? pane}]`) : theme.fg("dim", ` ${PANE_LABELS[pane] ?? pane} `);
60
+
61
+ const header = STUDIO_PANES.map((pane) => focusMark(pane)).join(" ");
62
+ const filterLine = state.mode === "filter"
63
+ ? theme.fg("accent", `filter: ${state.filterQuery}_`)
64
+ : state.filterQuery
65
+ ? theme.fg("muted", `filter: ${state.filterQuery}`)
66
+ : theme.fg("dim", "press / to filter, ? for help");
67
+
68
+ const lines: string[] = [
69
+ theme.fg("accent", "Skill Studio"),
70
+ header,
71
+ filterLine,
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"),
77
+ ];
78
+ return lines;
79
+ }
80
+
81
+ export function createStudioComponent(
82
+ theme: Theme,
83
+ done: (value: string | null) => void,
84
+ onStateChange?: (state: StudioState) => void,
85
+ ) {
86
+ let state = createStudioState();
87
+
88
+ return {
89
+ focused: true,
90
+ invalidate() {},
91
+ render(width: number) {
92
+ return renderStudioFrame(theme, width, state);
93
+ },
94
+ handleInput(data: string) {
95
+ const next = handleStudioKey(state, mapRawInput(data));
96
+ if (next !== state) {
97
+ state = next;
98
+ onStateChange?.(state);
99
+ }
100
+ if (state.exitRequested) {
101
+ done(null);
102
+ }
103
+ },
104
+ };
105
+ }
@@ -0,0 +1,74 @@
1
+ /** DeepWiki disk cache (U9). TTL + stale fallback, no LRU (ponytail: add when
2
+ * the cache directory count actually grows). */
3
+
4
+ import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { getAgentPath } from "../../paths/agent-dirs.ts";
7
+ import type { RepoRef } from "./extract.ts";
8
+
9
+ export function resolveCacheDir(override?: string): string {
10
+ return override ?? getAgentPath("wishcraft-cache", "deepwiki");
11
+ }
12
+
13
+ export interface CacheFile<T> {
14
+ savedAt: number;
15
+ data: T;
16
+ }
17
+
18
+ export interface CacheReadResult<T> {
19
+ status: "hit" | "miss";
20
+ stale: boolean;
21
+ entry: CacheFile<T> | null;
22
+ }
23
+
24
+ function fileFor(dir: string, repo: RepoRef): string {
25
+ return join(dir, repo.owner, `${repo.repo}.json`);
26
+ }
27
+
28
+ export async function writeCacheEntry<T>(
29
+ dir: string,
30
+ repo: RepoRef,
31
+ data: T,
32
+ options: { ttlMs: number; now?: number },
33
+ ): Promise<void> {
34
+ const now = options.now ?? Date.now();
35
+ const file = fileFor(dir, repo);
36
+ mkdirSync(join(dir, repo.owner), { recursive: true });
37
+ writeFileSync(file, JSON.stringify({ savedAt: now, data }), "utf8");
38
+ }
39
+
40
+ export async function readCacheEntry<T>(
41
+ dir: string,
42
+ repo: RepoRef,
43
+ options: { ttlMs: number; now?: number },
44
+ ): Promise<CacheReadResult<T>> {
45
+ const now = options.now ?? Date.now();
46
+ const file = fileFor(dir, repo);
47
+ if (!existsSync(file)) return { status: "miss", stale: false, entry: null };
48
+ try {
49
+ const raw = JSON.parse(readFileSync(file, "utf8")) as CacheFile<T>;
50
+ const fresh = now - raw.savedAt <= options.ttlMs;
51
+ return { status: fresh ? "hit" : "miss", stale: !fresh, entry: raw };
52
+ } catch {
53
+ return { status: "miss", stale: false, entry: null };
54
+ }
55
+ }
56
+
57
+ export async function withCache<T>(
58
+ dir: string,
59
+ repo: RepoRef,
60
+ options: { ttlMs: number; now?: number },
61
+ networkFetch: () => Promise<T>,
62
+ ): Promise<CacheReadResult<T>> {
63
+ const fresh = await readCacheEntry<T>(dir, repo, options);
64
+ if (fresh.status === "hit") return fresh;
65
+ try {
66
+ const data = await networkFetch();
67
+ await writeCacheEntry(dir, repo, data, options);
68
+ const read = await readCacheEntry<T>(dir, repo, { ...options, now: Date.now() });
69
+ return read;
70
+ } catch {
71
+ if (fresh.entry) return { status: "miss", stale: true, entry: fresh.entry };
72
+ return { status: "miss", stale: false, entry: null };
73
+ }
74
+ }