@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.
- package/AGENTS.md +28 -0
- package/CHANGELOG.md +25 -0
- package/README.md +22 -18
- package/ROADMAP.md +3 -3
- package/STRATEGY.md +54 -0
- package/bash-mode/editor-input.ts +3 -1
- package/bash-mode/editor.ts +44 -7
- package/bash-mode/forward.ts +64 -0
- package/bash-mode/pty-session.ts +356 -0
- package/bash-mode/ptyshell-managed.ts +145 -0
- package/bash-mode/session-factory.ts +64 -0
- package/bash-mode/transcript-v2.ts +77 -0
- package/bash-mode/transcript.ts +18 -0
- package/bash-mode/types.ts +27 -0
- package/docs/bash-mode.md +4 -0
- package/docs/configuration.md +45 -1
- package/docs/index.md +4 -1
- package/package.json +1 -1
- package/src/config/effective.ts +122 -0
- package/src/config/settings-registry.ts +2 -0
- package/src/config/structural-presets.ts +1 -1
- package/src/extension/commands/bash-mode-actions.ts +11 -10
- package/src/extension/commands/commands.ts +8 -0
- package/src/extension/core/state.ts +0 -1
- package/src/extension/core/types.ts +2 -2
- package/src/extension/session/session-lifecycle.ts +21 -0
- package/src/extension/skills/skill-templates.ts +12 -3
- package/src/extension/ui/custom-editor.ts +4 -0
- package/src/extension/ui/status-line-renderers.ts +6 -7
- package/src/extension/welcome/welcome-integration.ts +23 -1
- package/src/motion/frames.ts +19 -9
- package/src/render/layout.ts +95 -0
- package/src/render/motion-rail.ts +79 -0
- package/src/render/paint.ts +40 -0
- package/src/render/v2-adapter.ts +68 -0
- package/src/render/v2-entry.ts +132 -0
- package/src/segments/pipeline.ts +164 -0
- package/src/segments/types.ts +36 -0
- package/src/segments/usage.ts +42 -3
- package/src/studio/actions.ts +105 -0
- package/src/studio/advice-pane.ts +73 -0
- package/src/studio/advise/context.ts +89 -0
- package/src/studio/advise/engine.ts +117 -0
- package/src/studio/advise/prompts.ts +68 -0
- package/src/studio/component.ts +105 -0
- package/src/studio/deepwiki/cache.ts +74 -0
- package/src/studio/deepwiki/client.ts +73 -0
- package/src/studio/deepwiki/extract.ts +66 -0
- package/src/studio/inspect.ts +38 -0
- package/src/studio/list.ts +48 -0
- package/src/studio/open.ts +44 -0
- package/src/studio/state.ts +100 -0
- package/src/studio/types.ts +30 -0
- package/src/welcome/banner.ts +6 -0
- package/src/welcome/index.ts +7 -0
- package/src/welcome/overlay.ts +6 -0
- package/src/welcome/renderer.ts +6 -39
- package/src/welcome/types.ts +6 -0
- package/src/welcome/welcome-art.ts +148 -0
- package/src/working-vibes/frames.ts +25 -0
- package/src/working-vibes/index.ts +2 -0
- package/src/working-vibes/storage.ts +11 -0
- package/bash-mode/shell-session.ts +0 -294
- 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
|
+
}
|