whale-igniter 1.1.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/LICENSE +21 -0
- package/README.md +275 -0
- package/dist/analyzer/imports.js +88 -0
- package/dist/analyzer/insights.js +276 -0
- package/dist/commands/add.js +36 -0
- package/dist/commands/adopt.js +180 -0
- package/dist/commands/adoptReview.js +267 -0
- package/dist/commands/component.js +93 -0
- package/dist/commands/createComponent.js +207 -0
- package/dist/commands/decision.js +98 -0
- package/dist/commands/docs.js +34 -0
- package/dist/commands/ignite.js +212 -0
- package/dist/commands/init.js +66 -0
- package/dist/commands/insights.js +123 -0
- package/dist/commands/mcp.js +106 -0
- package/dist/commands/refine.js +36 -0
- package/dist/commands/selene.js +516 -0
- package/dist/commands/sync.js +43 -0
- package/dist/commands/validate.js +48 -0
- package/dist/commands/watch.js +150 -0
- package/dist/commands/wiki.js +21 -0
- package/dist/generators/markdownGenerator.js +112 -0
- package/dist/generators/reportGenerator.js +50 -0
- package/dist/generators/wikiGenerator.js +365 -0
- package/dist/index.js +213 -0
- package/dist/mcp/server.js +404 -0
- package/dist/scanner/componentScanner.js +522 -0
- package/dist/scanner/foundationInferrer.js +174 -0
- package/dist/scanner/tailwindMapper.js +58 -0
- package/dist/scanner/tailwindScanner.js +186 -0
- package/dist/selene/apiClient.js +168 -0
- package/dist/selene/cache.js +68 -0
- package/dist/selene/clipboard.js +56 -0
- package/dist/selene/promptBuilder.js +229 -0
- package/dist/selene/providers.js +67 -0
- package/dist/selene/responseParser.js +149 -0
- package/dist/ui/atoms.js +30 -0
- package/dist/ui/blocks.js +208 -0
- package/dist/ui/capabilities.js +64 -0
- package/dist/ui/index.js +13 -0
- package/dist/ui/symbols.js +41 -0
- package/dist/ui/theme.js +78 -0
- package/dist/utils/components.js +40 -0
- package/dist/utils/config.js +31 -0
- package/dist/utils/decisions.js +32 -0
- package/dist/utils/paths.js +4 -0
- package/dist/utils/proposals.js +61 -0
- package/dist/utils/refinements.js +81 -0
- package/dist/utils/registry.js +45 -0
- package/dist/utils/writeJson.js +6 -0
- package/dist/validators/cssValidator.js +204 -0
- package/dist/version.js +1 -0
- package/docs/ROADMAP.md +206 -0
- package/package.json +76 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pixel → Tailwind class mapper.
|
|
3
|
+
*
|
|
4
|
+
* The scanner reads classes and produces pixel values; this module does
|
|
5
|
+
* the reverse, so the generator can write code that respects the
|
|
6
|
+
* project's foundations using the canonical Tailwind names instead of
|
|
7
|
+
* arbitrary-value escape hatches.
|
|
8
|
+
*
|
|
9
|
+
* When the exact px value doesn't have a Tailwind class (e.g. grid=5px,
|
|
10
|
+
* but Tailwind has no `p-1.25`), we fall back to the arbitrary value
|
|
11
|
+
* syntax (`p-[5px]`). That's worse than a token class but better than
|
|
12
|
+
* silently rounding.
|
|
13
|
+
*/
|
|
14
|
+
const SPACING_PX_TO_SUFFIX = {
|
|
15
|
+
0: "0", 1: "px", 2: "0.5", 4: "1", 6: "1.5", 8: "2", 10: "2.5", 12: "3",
|
|
16
|
+
14: "3.5", 16: "4", 20: "5", 24: "6", 28: "7", 32: "8", 36: "9", 40: "10",
|
|
17
|
+
44: "11", 48: "12", 56: "14", 64: "16", 80: "20", 96: "24", 112: "28",
|
|
18
|
+
128: "32", 144: "36", 160: "40", 176: "44", 192: "48", 208: "52", 224: "56",
|
|
19
|
+
240: "60", 256: "64", 288: "72", 320: "80", 384: "96"
|
|
20
|
+
};
|
|
21
|
+
const RADIUS_PX_TO_SUFFIX = {
|
|
22
|
+
0: "none",
|
|
23
|
+
2: "sm",
|
|
24
|
+
4: "", // bare `rounded`
|
|
25
|
+
6: "md",
|
|
26
|
+
8: "lg",
|
|
27
|
+
12: "xl",
|
|
28
|
+
16: "2xl",
|
|
29
|
+
24: "3xl",
|
|
30
|
+
9999: "full"
|
|
31
|
+
};
|
|
32
|
+
export function spacingClass(prefix, px) {
|
|
33
|
+
const suffix = SPACING_PX_TO_SUFFIX[px];
|
|
34
|
+
if (suffix !== undefined)
|
|
35
|
+
return `${prefix}-${suffix}`;
|
|
36
|
+
return `${prefix}-[${px}px]`;
|
|
37
|
+
}
|
|
38
|
+
export function radiusClass(prefix, px) {
|
|
39
|
+
const suffix = RADIUS_PX_TO_SUFFIX[px];
|
|
40
|
+
if (suffix !== undefined) {
|
|
41
|
+
return suffix === "" ? prefix : `${prefix}-${suffix}`;
|
|
42
|
+
}
|
|
43
|
+
return `${prefix}-[${px}px]`;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Pick the most common color name to use as the project accent. We
|
|
47
|
+
* fall back to "blue" because that's Tailwind's de facto convention
|
|
48
|
+
* and matches the default `accent` in whale's config.
|
|
49
|
+
*/
|
|
50
|
+
export function accentColorFamily(accent) {
|
|
51
|
+
if (!accent)
|
|
52
|
+
return "blue";
|
|
53
|
+
const known = ["slate", "gray", "zinc", "neutral", "stone", "red", "orange", "amber", "yellow", "lime", "green", "emerald", "teal", "cyan", "sky", "blue", "indigo", "violet", "purple", "fuchsia", "pink", "rose"];
|
|
54
|
+
const lower = accent.toLowerCase();
|
|
55
|
+
if (known.includes(lower))
|
|
56
|
+
return lower;
|
|
57
|
+
return "blue";
|
|
58
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import fs from "fs-extra";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
// Tailwind's default spacing scale, in pixels. We use this when no tailwind.config is found.
|
|
4
|
+
// Keys are the suffix used in classes (e.g. "4" in "p-4"), values are pixel sizes.
|
|
5
|
+
const DEFAULT_SPACING_SCALE = {
|
|
6
|
+
"0": 0, "px": 1, "0.5": 2, "1": 4, "1.5": 6, "2": 8, "2.5": 10, "3": 12,
|
|
7
|
+
"3.5": 14, "4": 16, "5": 20, "6": 24, "7": 28, "8": 32, "9": 36, "10": 40,
|
|
8
|
+
"11": 44, "12": 48, "14": 56, "16": 64, "20": 80, "24": 96, "28": 112,
|
|
9
|
+
"32": 128, "36": 144, "40": 160, "44": 176, "48": 192, "52": 208, "56": 224,
|
|
10
|
+
"60": 240, "64": 256, "72": 288, "80": 320, "96": 384
|
|
11
|
+
};
|
|
12
|
+
// Tailwind's default radius scale.
|
|
13
|
+
const DEFAULT_RADIUS_SCALE = {
|
|
14
|
+
none: 0, sm: 2, "": 4, md: 6, lg: 8, xl: 12, "2xl": 16, "3xl": 24, full: 9999
|
|
15
|
+
};
|
|
16
|
+
// Spacing-related class prefixes. The captured group is the value suffix.
|
|
17
|
+
// Order matters — longer prefixes first to avoid `m-` matching `mt-`.
|
|
18
|
+
const SPACING_PREFIXES = [
|
|
19
|
+
"px-", "py-", "pt-", "pb-", "pl-", "pr-", "ps-", "pe-", "p-",
|
|
20
|
+
"mx-", "my-", "mt-", "mb-", "ml-", "mr-", "ms-", "me-", "m-",
|
|
21
|
+
"gap-x-", "gap-y-", "gap-",
|
|
22
|
+
"space-x-", "space-y-"
|
|
23
|
+
];
|
|
24
|
+
// Radius prefixes — full first, then sided.
|
|
25
|
+
const RADIUS_PREFIXES = [
|
|
26
|
+
"rounded-t-", "rounded-b-", "rounded-l-", "rounded-r-",
|
|
27
|
+
"rounded-tl-", "rounded-tr-", "rounded-bl-", "rounded-br-",
|
|
28
|
+
"rounded-"
|
|
29
|
+
];
|
|
30
|
+
// Bare radius class (`rounded`) with no suffix.
|
|
31
|
+
const BARE_RADIUS = /^rounded$/;
|
|
32
|
+
const COLOR_PREFIXES = ["bg-", "text-", "border-", "ring-", "fill-", "stroke-", "from-", "to-", "via-"];
|
|
33
|
+
const BORDER_WIDTH = /^border(?:-[a-z]+)?(?:-(\d+))?$/;
|
|
34
|
+
const RING_WIDTH = /^ring(?:-(\d+))?$/;
|
|
35
|
+
const RING_OFFSET = /^ring-offset(?:-(\d+))?$/;
|
|
36
|
+
const FONT_SIZE = /^text-(xs|sm|base|lg|xl|2xl|3xl|4xl|5xl|6xl|7xl|8xl|9xl)$/;
|
|
37
|
+
const SHADOW = /^shadow(-[a-z0-9-]+)?$/;
|
|
38
|
+
function stripVariantPrefixes(cls) {
|
|
39
|
+
// Tailwind variants like `hover:`, `md:`, `dark:`, `focus-visible:`.
|
|
40
|
+
// Multiple variants can stack: `dark:md:hover:p-4`. Take everything
|
|
41
|
+
// after the last `:`.
|
|
42
|
+
const lastColon = cls.lastIndexOf(":");
|
|
43
|
+
return lastColon >= 0 ? cls.slice(lastColon + 1) : cls;
|
|
44
|
+
}
|
|
45
|
+
function isNegative(value) {
|
|
46
|
+
// Tailwind allows negative spacing: `-mt-4`. The negative goes in front of the prefix.
|
|
47
|
+
return value.startsWith("-");
|
|
48
|
+
}
|
|
49
|
+
function parseArbitraryPx(value) {
|
|
50
|
+
// value example: "[10px]" or "[1.5rem]". Returns pixels.
|
|
51
|
+
const m = value.match(/^\[([0-9.]+)(px|rem|em)?\]$/);
|
|
52
|
+
if (!m)
|
|
53
|
+
return null;
|
|
54
|
+
const n = parseFloat(m[1]);
|
|
55
|
+
const unit = m[2] ?? "px";
|
|
56
|
+
if (unit === "px")
|
|
57
|
+
return n;
|
|
58
|
+
if (unit === "rem" || unit === "em")
|
|
59
|
+
return n * 16; // assume default root = 16
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
function classifyClass(cls) {
|
|
63
|
+
const stripped = stripVariantPrefixes(cls);
|
|
64
|
+
const neg = isNegative(stripped);
|
|
65
|
+
const base = neg ? stripped.slice(1) : stripped;
|
|
66
|
+
// Spacing
|
|
67
|
+
for (const prefix of SPACING_PREFIXES) {
|
|
68
|
+
if (base.startsWith(prefix)) {
|
|
69
|
+
const suffix = base.slice(prefix.length);
|
|
70
|
+
if (suffix.startsWith("[") && suffix.endsWith("]")) {
|
|
71
|
+
const px = parseArbitraryPx(suffix);
|
|
72
|
+
return { raw: cls, kind: "spacing", pxValue: px === null ? null : neg ? -px : px, colorToken: null, isArbitrary: true };
|
|
73
|
+
}
|
|
74
|
+
const scaled = DEFAULT_SPACING_SCALE[suffix];
|
|
75
|
+
return {
|
|
76
|
+
raw: cls,
|
|
77
|
+
kind: "spacing",
|
|
78
|
+
pxValue: scaled === undefined ? null : neg ? -scaled : scaled,
|
|
79
|
+
colorToken: null,
|
|
80
|
+
isArbitrary: false
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// Radius
|
|
85
|
+
for (const prefix of RADIUS_PREFIXES) {
|
|
86
|
+
if (base.startsWith(prefix)) {
|
|
87
|
+
const suffix = base.slice(prefix.length);
|
|
88
|
+
if (suffix.startsWith("[") && suffix.endsWith("]")) {
|
|
89
|
+
return { raw: cls, kind: "radius", pxValue: parseArbitraryPx(suffix), colorToken: null, isArbitrary: true };
|
|
90
|
+
}
|
|
91
|
+
const scaled = DEFAULT_RADIUS_SCALE[suffix];
|
|
92
|
+
return {
|
|
93
|
+
raw: cls,
|
|
94
|
+
kind: "radius",
|
|
95
|
+
pxValue: scaled === undefined ? null : scaled,
|
|
96
|
+
colorToken: null,
|
|
97
|
+
isArbitrary: false
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (BARE_RADIUS.test(base)) {
|
|
102
|
+
return { raw: cls, kind: "radius", pxValue: DEFAULT_RADIUS_SCALE[""], colorToken: null, isArbitrary: false };
|
|
103
|
+
}
|
|
104
|
+
// Font size — must check before color because `text-sm` would also match the `text-` color prefix.
|
|
105
|
+
if (FONT_SIZE.test(base)) {
|
|
106
|
+
return { raw: cls, kind: "font-size", pxValue: null, colorToken: null, isArbitrary: false };
|
|
107
|
+
}
|
|
108
|
+
// Ring width / offset — match before color because `ring-` is in COLOR_PREFIXES too.
|
|
109
|
+
const ringMatch = base.match(RING_WIDTH);
|
|
110
|
+
if (ringMatch) {
|
|
111
|
+
const px = ringMatch[1] ? parseInt(ringMatch[1], 10) : 3; // tailwind default ring is 3px
|
|
112
|
+
return { raw: cls, kind: "border-width", pxValue: px, colorToken: null, isArbitrary: false };
|
|
113
|
+
}
|
|
114
|
+
const ringOffsetMatch = base.match(RING_OFFSET);
|
|
115
|
+
if (ringOffsetMatch) {
|
|
116
|
+
const px = ringOffsetMatch[1] ? parseInt(ringOffsetMatch[1], 10) : 0;
|
|
117
|
+
return { raw: cls, kind: "border-width", pxValue: px, colorToken: null, isArbitrary: false };
|
|
118
|
+
}
|
|
119
|
+
// Color
|
|
120
|
+
for (const prefix of COLOR_PREFIXES) {
|
|
121
|
+
if (base.startsWith(prefix)) {
|
|
122
|
+
const suffix = base.slice(prefix.length);
|
|
123
|
+
if (suffix.startsWith("[") && suffix.endsWith("]")) {
|
|
124
|
+
return { raw: cls, kind: "color", pxValue: null, colorToken: suffix, isArbitrary: true };
|
|
125
|
+
}
|
|
126
|
+
// bg-blue-500, bg-primary, bg-white, bg-transparent
|
|
127
|
+
return { raw: cls, kind: "color", pxValue: null, colorToken: suffix, isArbitrary: false };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// Border width
|
|
131
|
+
const borderMatch = base.match(BORDER_WIDTH);
|
|
132
|
+
if (borderMatch && !base.startsWith("border-t-") && !base.startsWith("border-b-")) {
|
|
133
|
+
const px = borderMatch[1] ? parseInt(borderMatch[1], 10) : 1;
|
|
134
|
+
return { raw: cls, kind: "border-width", pxValue: px, colorToken: null, isArbitrary: false };
|
|
135
|
+
}
|
|
136
|
+
// Shadow
|
|
137
|
+
if (SHADOW.test(base)) {
|
|
138
|
+
return { raw: cls, kind: "shadow", pxValue: null, colorToken: null, isArbitrary: false };
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Aggregate Tailwind observations from a list of raw className strings.
|
|
144
|
+
* Each className string can contain multiple space-separated classes;
|
|
145
|
+
* we tokenize and classify each, then count occurrences.
|
|
146
|
+
*/
|
|
147
|
+
export function aggregateTailwind(classNameStrings) {
|
|
148
|
+
const map = new Map();
|
|
149
|
+
for (const str of classNameStrings) {
|
|
150
|
+
if (!str)
|
|
151
|
+
continue;
|
|
152
|
+
const tokens = str.split(/\s+/).filter(Boolean);
|
|
153
|
+
for (const tok of tokens) {
|
|
154
|
+
const obs = classifyClass(tok);
|
|
155
|
+
if (!obs)
|
|
156
|
+
continue;
|
|
157
|
+
const key = obs.raw;
|
|
158
|
+
const existing = map.get(key);
|
|
159
|
+
if (existing) {
|
|
160
|
+
existing.count += 1;
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
map.set(key, { ...obs, count: 1 });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return Array.from(map.values()).sort((a, b) => b.count - a.count);
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Try to read tailwind.config.{js,ts,mjs,cjs} to override default scales.
|
|
171
|
+
* We use a conservative approach: don't execute the file (no eval, no
|
|
172
|
+
* dynamic import — security and reliability), just check existence and
|
|
173
|
+
* report it in the inference.
|
|
174
|
+
*
|
|
175
|
+
* Properly reading the resolved Tailwind config requires running the
|
|
176
|
+
* tailwindcss preset machinery, which is out of scope for v0.8. The
|
|
177
|
+
* default scales catch ~95% of usage in our fixtures.
|
|
178
|
+
*/
|
|
179
|
+
export async function detectTailwindConfig(target) {
|
|
180
|
+
const candidates = ["tailwind.config.js", "tailwind.config.ts", "tailwind.config.mjs", "tailwind.config.cjs"];
|
|
181
|
+
for (const c of candidates) {
|
|
182
|
+
if (await fs.pathExists(path.join(target, c)))
|
|
183
|
+
return c;
|
|
184
|
+
}
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Selene API clients.
|
|
3
|
+
*
|
|
4
|
+
* Two providers, one abstraction. Native fetch — no SDK dependencies.
|
|
5
|
+
* SDKs add weight, version-lock us to one API shape, and pull in
|
|
6
|
+
* tokenizers and types we don't need. Both APIs are extremely simple
|
|
7
|
+
* for the single "send a prompt, get text back" use case, so we hit
|
|
8
|
+
* them directly.
|
|
9
|
+
*
|
|
10
|
+
* Errors are mapped to a normalised shape so the command layer can
|
|
11
|
+
* handle them uniformly. We deliberately surface the provider's own
|
|
12
|
+
* error message verbatim — those are usually accurate ("invalid_api_key",
|
|
13
|
+
* "model_not_found", rate limit, etc.) and editing them obscures the
|
|
14
|
+
* real cause.
|
|
15
|
+
*/
|
|
16
|
+
export async function callProvider(resolved, params) {
|
|
17
|
+
if (resolved.provider === "anthropic")
|
|
18
|
+
return callAnthropic(resolved, params);
|
|
19
|
+
if (resolved.provider === "openai")
|
|
20
|
+
return callOpenAI(resolved, params);
|
|
21
|
+
// Compile-time exhaustiveness check — if we add a provider, this errors.
|
|
22
|
+
const _exhaustive = resolved.provider;
|
|
23
|
+
return { ok: false, error: `Unknown provider: ${String(_exhaustive)}`, retryable: false };
|
|
24
|
+
}
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Anthropic Messages API
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
async function callAnthropic(resolved, params) {
|
|
29
|
+
// We split the prompt: a stable "system" portion (project context +
|
|
30
|
+
// identity) would normally let Anthropic cache it, but we don't have
|
|
31
|
+
// a clean structural split here because the prompt builder produces
|
|
32
|
+
// a single Markdown blob. We send the whole thing as a single user
|
|
33
|
+
// message; that's correct, just not optimal for cache hits.
|
|
34
|
+
//
|
|
35
|
+
// Future: split promptBuilder output into {system, user} pieces and
|
|
36
|
+
// pass them separately to enable prompt caching. Out of scope here.
|
|
37
|
+
const body = {
|
|
38
|
+
model: resolved.model,
|
|
39
|
+
max_tokens: params.maxTokens,
|
|
40
|
+
temperature: params.temperature,
|
|
41
|
+
messages: [{ role: "user", content: params.prompt }]
|
|
42
|
+
};
|
|
43
|
+
try {
|
|
44
|
+
const resp = await fetch("https://api.anthropic.com/v1/messages", {
|
|
45
|
+
method: "POST",
|
|
46
|
+
headers: {
|
|
47
|
+
"content-type": "application/json",
|
|
48
|
+
"x-api-key": resolved.apiKey,
|
|
49
|
+
"anthropic-version": "2023-06-01"
|
|
50
|
+
},
|
|
51
|
+
body: JSON.stringify(body)
|
|
52
|
+
});
|
|
53
|
+
if (!resp.ok) {
|
|
54
|
+
const errText = await resp.text();
|
|
55
|
+
return {
|
|
56
|
+
ok: false,
|
|
57
|
+
status: resp.status,
|
|
58
|
+
error: `Anthropic ${resp.status}: ${errText.slice(0, 500)}`,
|
|
59
|
+
retryable: resp.status === 429 || resp.status >= 500
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const data = (await resp.json());
|
|
63
|
+
// Anthropic returns content blocks; we concatenate the text ones.
|
|
64
|
+
const text = (data.content ?? [])
|
|
65
|
+
.filter((b) => b.type === "text")
|
|
66
|
+
.map((b) => b.text ?? "")
|
|
67
|
+
.join("")
|
|
68
|
+
.trim();
|
|
69
|
+
return {
|
|
70
|
+
ok: true,
|
|
71
|
+
text,
|
|
72
|
+
inputTokens: data.usage?.input_tokens ?? null,
|
|
73
|
+
outputTokens: data.usage?.output_tokens ?? null
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
78
|
+
return { ok: false, error: `Network error: ${message}`, retryable: true };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// OpenAI Chat Completions API
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
async function callOpenAI(resolved, params) {
|
|
85
|
+
const body = {
|
|
86
|
+
model: resolved.model,
|
|
87
|
+
max_tokens: params.maxTokens,
|
|
88
|
+
temperature: params.temperature,
|
|
89
|
+
messages: [{ role: "user", content: params.prompt }]
|
|
90
|
+
};
|
|
91
|
+
try {
|
|
92
|
+
const resp = await fetch("https://api.openai.com/v1/chat/completions", {
|
|
93
|
+
method: "POST",
|
|
94
|
+
headers: {
|
|
95
|
+
"content-type": "application/json",
|
|
96
|
+
authorization: `Bearer ${resolved.apiKey}`
|
|
97
|
+
},
|
|
98
|
+
body: JSON.stringify(body)
|
|
99
|
+
});
|
|
100
|
+
if (!resp.ok) {
|
|
101
|
+
const errText = await resp.text();
|
|
102
|
+
return {
|
|
103
|
+
ok: false,
|
|
104
|
+
status: resp.status,
|
|
105
|
+
error: `OpenAI ${resp.status}: ${errText.slice(0, 500)}`,
|
|
106
|
+
retryable: resp.status === 429 || resp.status >= 500
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
const data = (await resp.json());
|
|
110
|
+
const text = (data.choices?.[0]?.message?.content ?? "").trim();
|
|
111
|
+
return {
|
|
112
|
+
ok: true,
|
|
113
|
+
text,
|
|
114
|
+
inputTokens: data.usage?.prompt_tokens ?? null,
|
|
115
|
+
outputTokens: data.usage?.completion_tokens ?? null
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
120
|
+
return { ok: false, error: `Network error: ${message}`, retryable: true };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// Cost estimation (very rough, off-by-design)
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
/**
|
|
127
|
+
* Rough token estimate from a prompt string. We assume ~4 chars/token,
|
|
128
|
+
* which is the rule of thumb for English Latin-script text. Code is
|
|
129
|
+
* denser (~3 chars/token); we err on the high side intentionally so
|
|
130
|
+
* cost estimates skew pessimistic.
|
|
131
|
+
*
|
|
132
|
+
* If you want real numbers, run `whale selene status --tokens` after
|
|
133
|
+
* calls and look at the actual usage; we record it.
|
|
134
|
+
*/
|
|
135
|
+
export function estimateTokens(text) {
|
|
136
|
+
return Math.ceil(text.length / 3.5);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Pricing (USD per 1M tokens) for the default models. We deliberately
|
|
140
|
+
* don't hardcode every model — these are approximations for confirmCost
|
|
141
|
+
* messages, not invoices. Users should check the provider's pricing
|
|
142
|
+
* page for accuracy.
|
|
143
|
+
*
|
|
144
|
+
* Source: vendor pricing pages, accessed early 2026. Update opportunistically.
|
|
145
|
+
*/
|
|
146
|
+
const APPROX_PRICING = {
|
|
147
|
+
// Anthropic
|
|
148
|
+
"claude-sonnet-4-6": { input: 3.0, output: 15.0 },
|
|
149
|
+
"claude-opus-4-7": { input: 15.0, output: 75.0 },
|
|
150
|
+
"claude-haiku-4-5": { input: 1.0, output: 5.0 },
|
|
151
|
+
// OpenAI
|
|
152
|
+
"gpt-4.1": { input: 2.5, output: 10.0 },
|
|
153
|
+
"gpt-4.1-mini": { input: 0.4, output: 1.6 }
|
|
154
|
+
};
|
|
155
|
+
export function estimateCostUsd(model, inputTokens, expectedOutputTokens) {
|
|
156
|
+
// Try exact model match, then prefix match (handles e.g. "claude-opus-4-7-20260101").
|
|
157
|
+
let pricing = APPROX_PRICING[model];
|
|
158
|
+
if (!pricing) {
|
|
159
|
+
const prefix = Object.keys(APPROX_PRICING).find((k) => model.startsWith(k));
|
|
160
|
+
if (prefix)
|
|
161
|
+
pricing = APPROX_PRICING[prefix];
|
|
162
|
+
}
|
|
163
|
+
if (!pricing)
|
|
164
|
+
return null;
|
|
165
|
+
const inputCost = (inputTokens / 1_000_000) * pricing.input;
|
|
166
|
+
const outputCost = (expectedOutputTokens / 1_000_000) * pricing.output;
|
|
167
|
+
return inputCost + outputCost;
|
|
168
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-disk cache for Selene API responses.
|
|
3
|
+
*
|
|
4
|
+
* Why: identical prompts run in quick succession (re-running a command,
|
|
5
|
+
* iterating on a workflow) shouldn't pay twice. The cache key is the
|
|
6
|
+
* sha256 of (model + prompt), so any meaningful change to either
|
|
7
|
+
* invalidates. TTL defaults to 7 days; old files are pruned lazily on
|
|
8
|
+
* read.
|
|
9
|
+
*
|
|
10
|
+
* Caveats. We cache the *response text*, not usage tokens or cost — those
|
|
11
|
+
* are zero on a cache hit. We don't cache failures. If the user wants
|
|
12
|
+
* a fresh call, they can pass --no-cache or delete the cache dir.
|
|
13
|
+
*/
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import fs from "fs-extra";
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
const CACHE_DIR_REL = ".whale/selene/cache";
|
|
18
|
+
const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
19
|
+
function cacheKey(model, prompt) {
|
|
20
|
+
return createHash("sha256").update(`${model}\n${prompt}`).digest("hex").slice(0, 32);
|
|
21
|
+
}
|
|
22
|
+
export async function readCache(target, model, prompt, ttlMs = DEFAULT_TTL_MS) {
|
|
23
|
+
const key = cacheKey(model, prompt);
|
|
24
|
+
const file = path.join(target, CACHE_DIR_REL, `${key}.json`);
|
|
25
|
+
if (!(await fs.pathExists(file)))
|
|
26
|
+
return null;
|
|
27
|
+
try {
|
|
28
|
+
const entry = (await fs.readJson(file));
|
|
29
|
+
const ageMs = Date.now() - new Date(entry.storedAt).getTime();
|
|
30
|
+
if (ageMs > ttlMs) {
|
|
31
|
+
// Expired — clean up lazily so the cache doesn't grow unbounded.
|
|
32
|
+
await fs.remove(file).catch(() => undefined);
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
return entry.text;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// Corrupt file — remove and treat as miss.
|
|
39
|
+
await fs.remove(file).catch(() => undefined);
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export async function writeCache(target, model, prompt, text) {
|
|
44
|
+
const key = cacheKey(model, prompt);
|
|
45
|
+
const dir = path.join(target, CACHE_DIR_REL);
|
|
46
|
+
await fs.ensureDir(dir);
|
|
47
|
+
const entry = {
|
|
48
|
+
key,
|
|
49
|
+
model,
|
|
50
|
+
text,
|
|
51
|
+
storedAt: new Date().toISOString()
|
|
52
|
+
};
|
|
53
|
+
await fs.writeJson(path.join(dir, `${key}.json`), entry, { spaces: 2 });
|
|
54
|
+
}
|
|
55
|
+
export async function clearCache(target) {
|
|
56
|
+
const dir = path.join(target, CACHE_DIR_REL);
|
|
57
|
+
if (!(await fs.pathExists(dir)))
|
|
58
|
+
return 0;
|
|
59
|
+
const files = await fs.readdir(dir);
|
|
60
|
+
let removed = 0;
|
|
61
|
+
for (const f of files) {
|
|
62
|
+
if (f.endsWith(".json")) {
|
|
63
|
+
await fs.remove(path.join(dir, f));
|
|
64
|
+
removed += 1;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return removed;
|
|
68
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copy text to the system clipboard without an npm dependency.
|
|
3
|
+
*
|
|
4
|
+
* We try in order: pbcopy (macOS), clip.exe (Windows / WSL), wl-copy
|
|
5
|
+
* (Wayland), xclip (X11), xsel (X11). If none works the function
|
|
6
|
+
* returns false; the caller should then print the text so the user can
|
|
7
|
+
* still copy it manually.
|
|
8
|
+
*
|
|
9
|
+
* Reasoning: every clipboard npm package adds a dependency and forces
|
|
10
|
+
* the user to think about install. For a CLI tool whose hot path is
|
|
11
|
+
* "generate a prompt the user pastes elsewhere", a 50-line shell-out
|
|
12
|
+
* approach is more robust and has zero install cost.
|
|
13
|
+
*/
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
function platformStrategies() {
|
|
16
|
+
const p = process.platform;
|
|
17
|
+
if (p === "darwin")
|
|
18
|
+
return [{ cmd: "pbcopy", args: [] }];
|
|
19
|
+
if (p === "win32")
|
|
20
|
+
return [{ cmd: "clip.exe", args: [] }];
|
|
21
|
+
// Linux / *nix: try Wayland first, then X11.
|
|
22
|
+
return [
|
|
23
|
+
{ cmd: "wl-copy", args: [] },
|
|
24
|
+
{ cmd: "xclip", args: ["-selection", "clipboard"] },
|
|
25
|
+
{ cmd: "xsel", args: ["--clipboard", "--input"] }
|
|
26
|
+
];
|
|
27
|
+
}
|
|
28
|
+
async function tryCopy(strategy, text) {
|
|
29
|
+
return new Promise((resolve) => {
|
|
30
|
+
let settled = false;
|
|
31
|
+
const finish = (ok) => {
|
|
32
|
+
if (settled)
|
|
33
|
+
return;
|
|
34
|
+
settled = true;
|
|
35
|
+
resolve(ok);
|
|
36
|
+
};
|
|
37
|
+
try {
|
|
38
|
+
const child = spawn(strategy.cmd, strategy.args, { stdio: ["pipe", "ignore", "ignore"] });
|
|
39
|
+
child.on("error", () => finish(false));
|
|
40
|
+
child.on("close", (code) => finish(code === 0));
|
|
41
|
+
child.stdin.write(text);
|
|
42
|
+
child.stdin.end();
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
finish(false);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
export async function copyToClipboard(text) {
|
|
50
|
+
for (const strat of platformStrategies()) {
|
|
51
|
+
const ok = await tryCopy(strat, text);
|
|
52
|
+
if (ok)
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
}
|