tweaklocal 0.1.1 → 0.3.1
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/README.md +14 -1
- package/overlay/overlay.js +12 -3
- package/package.json +23 -5
- package/src/resolver.js +177 -20
- package/src/router.js +218 -28
- package/src/server.js +53 -6
package/README.md
CHANGED
|
@@ -4,7 +4,20 @@ Tweak your UI live in the browser — the changes are written straight to your s
|
|
|
4
4
|
|
|
5
5
|
- **Copy**: click any text, edit in place, Enter. Written to the JSX literal. 0 tokens.
|
|
6
6
|
- **Style**: padding/margin per side, font sizes and colors from *your* design system tokens. Deterministic Tailwind class edits. 0 tokens.
|
|
7
|
-
- **Anything else**: describe it —
|
|
7
|
+
- **Anything else**: describe it — a layered router picks a model *and* an effort level sized to the request, then runs headless `claude -p` scoped to the exact file your selection maps to.
|
|
8
|
+
|
|
9
|
+
## Model routing
|
|
10
|
+
|
|
11
|
+
| Tier | What it covers | Model | Effort (by complexity) |
|
|
12
|
+
|---|---|---|---|
|
|
13
|
+
| 1 | Styles, copy, component tweaks, existing components | `claude-sonnet-5` | low / medium / high |
|
|
14
|
+
| 2 | New logic & functionality (handlers, state, forms, data) | `claude-opus-4-8` | medium / high / xhigh |
|
|
15
|
+
| 3 | Multi-feature or cross-cutting work | `claude-opus-4-8` | xhigh |
|
|
16
|
+
| 3+ | Mission-critical (auth, payments, data, security) or very high complexity | `claude-fable-5` | high |
|
|
17
|
+
|
|
18
|
+
Routing is layered (RouteLLM-style): deterministic lexicon + structure scoring first (<1ms, free); requests with no clear signal fall through to a Haiku classifier with structured output (~1–3s, ~$0.001). Inspect any routing decision without running it: `POST /api/classify {"instruction": "..."}`.
|
|
19
|
+
|
|
20
|
+
Overrides: `TWEAKLOCAL_T1_MODEL`, `TWEAKLOCAL_T2_MODEL`, `TWEAKLOCAL_T3_MODEL`, `TWEAKLOCAL_T3_CRITICAL_MODEL`, and `TWEAKLOCAL_ROUTER=heuristic|hybrid|llm` (default `hybrid`).
|
|
8
21
|
|
|
9
22
|
## Quickstart (Next.js)
|
|
10
23
|
|
package/overlay/overlay.js
CHANGED
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
.twk-total a:hover{color:#a7f3d0}
|
|
41
41
|
.twk-tweak{background:#111827;color:#e5e7eb;border-radius:8px;padding:7px 11px;font-size:13px;display:flex;gap:8px;align-items:center;box-shadow:0 4px 14px rgba(0,0,0,.3);white-space:nowrap;width:max-content;max-width:720px;overflow:hidden;text-overflow:ellipsis}
|
|
42
42
|
.twk-dot{width:8px;height:8px;border-radius:50%;flex:none}
|
|
43
|
-
.twk-dot.done{background:#10b981}.twk-dot.queued,.twk-dot.running{background:#f59e0b;animation:twk-pulse 1s infinite}.twk-dot.error{background:#ef4444}.twk-dot.reverted{background:#6b7280}
|
|
43
|
+
.twk-dot.done{background:#10b981}.twk-dot.queued,.twk-dot.running{background:#f59e0b;animation:twk-pulse 1s infinite}.twk-dot.error{background:#ef4444}.twk-dot.reverted,.twk-dot.cancelled{background:#6b7280}
|
|
44
44
|
.twk-tweak button{background:none;border:none;color:#818cf8;cursor:pointer;font-size:12.5px;padding:0}
|
|
45
45
|
.twk-meta{color:#9ca3af}
|
|
46
46
|
.twk-hint{position:fixed;left:14px;bottom:14px;background:#111827;color:#9ca3af;font-size:12.5px;padding:6px 11px;border-radius:6px;pointer-events:none}
|
|
@@ -708,6 +708,13 @@
|
|
|
708
708
|
row._dot = el('span', 'twk-dot');
|
|
709
709
|
row._label = el('span', null, '');
|
|
710
710
|
row._meta = el('span', 'twk-meta', '');
|
|
711
|
+
row._cancel = el('button', null, 'cancel');
|
|
712
|
+
row._cancel.style.display = 'none';
|
|
713
|
+
row._cancel.onclick = async () => {
|
|
714
|
+
try {
|
|
715
|
+
await api('cancel', { id: t.id });
|
|
716
|
+
} catch (e) { row._meta.textContent = e.message; }
|
|
717
|
+
};
|
|
711
718
|
row._undo = el('button', null, 'undo');
|
|
712
719
|
row._undo.style.display = 'none';
|
|
713
720
|
row._undo.onclick = async () => {
|
|
@@ -716,7 +723,7 @@
|
|
|
716
723
|
setTimeout(reposition, 350);
|
|
717
724
|
} catch (e) { row._meta.textContent = e.message; }
|
|
718
725
|
};
|
|
719
|
-
row.append(row._dot, row._label, row._meta, row._undo);
|
|
726
|
+
row.append(row._dot, row._label, row._meta, row._cancel, row._undo);
|
|
720
727
|
tray.insertBefore(row, totalBar.nextSibling);
|
|
721
728
|
tweaks.set(String(t.id), row);
|
|
722
729
|
while (tray.children.length > 7) tray.lastChild.remove();
|
|
@@ -724,10 +731,12 @@
|
|
|
724
731
|
if (t.label) row._label.textContent = t.label;
|
|
725
732
|
if (t.status) {
|
|
726
733
|
row._dot.className = 'twk-dot ' + t.status;
|
|
734
|
+
const inFlight = t.status === 'queued' || t.status === 'running';
|
|
735
|
+
row._cancel.style.display = inFlight ? '' : 'none';
|
|
727
736
|
row._undo.style.display = t.status === 'done' && !String(t.id).startsWith('x') ? '' : 'none';
|
|
728
737
|
}
|
|
729
738
|
const bits = [];
|
|
730
|
-
if (t.model) bits.push(t.model);
|
|
739
|
+
if (t.model) bits.push(t.model.replace(/^claude-/, '') + (t.effort ? ` @ ${t.effort}` : ''));
|
|
731
740
|
if (t.tokens === 0) bits.push('0 tokens');
|
|
732
741
|
if (t.durationMs) bits.push((t.durationMs / 1000).toFixed(1) + 's');
|
|
733
742
|
if (t.costUSD != null) bits.push('$' + t.costUSD.toFixed(3));
|
package/package.json
CHANGED
|
@@ -1,17 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tweaklocal",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Tweak your UI live in the browser and write the changes straight to source. Copy and Tailwind edits cost zero tokens; everything else routes to a right-sized model via headless claude.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
|
-
"bin": {
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
"bin": {
|
|
8
|
+
"tweaklocal": "bin/tweaklocal.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"start": "node bin/tweaklocal.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"bin",
|
|
15
|
+
"src",
|
|
16
|
+
"overlay",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
10
19
|
"engines": {
|
|
11
20
|
"node": ">=18"
|
|
12
21
|
},
|
|
13
22
|
"dependencies": {
|
|
14
23
|
"@babel/parser": "^7.26.0"
|
|
15
24
|
},
|
|
16
|
-
"keywords": [
|
|
25
|
+
"keywords": [
|
|
26
|
+
"devtools",
|
|
27
|
+
"tailwind",
|
|
28
|
+
"visual-editor",
|
|
29
|
+
"claude",
|
|
30
|
+
"ai",
|
|
31
|
+
"nextjs",
|
|
32
|
+
"vite",
|
|
33
|
+
"react"
|
|
34
|
+
]
|
|
17
35
|
}
|
package/src/resolver.js
CHANGED
|
@@ -49,7 +49,7 @@ export function loadTarget(root, loc) {
|
|
|
49
49
|
});
|
|
50
50
|
element = element || nearMiss;
|
|
51
51
|
if (!element) throw new Error(`no JSX element at ${loc}`);
|
|
52
|
-
return { file, abs, content, element };
|
|
52
|
+
return { file, abs, content, element, ast };
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
export function describeTarget(root, loc) {
|
|
@@ -181,13 +181,9 @@ export function applyStyleEdit(root, loc, styles) {
|
|
|
181
181
|
);
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
* unparseable — e.g. deleting a component's only root element.
|
|
188
|
-
*/
|
|
189
|
-
export function applyDeleteElement(root, loc, { dryRun = false } = {}) {
|
|
190
|
-
const { abs, content, element, file } = loadTarget(root, loc);
|
|
184
|
+
// Remove an element from its source string, consuming its whole line(s) when
|
|
185
|
+
// it sits alone on them. Returns the new content, or null if unparseable.
|
|
186
|
+
function removeElementFromContent(content, element, file) {
|
|
191
187
|
let start = element.start;
|
|
192
188
|
let end = element.end;
|
|
193
189
|
const lineStart = content.lastIndexOf('\n', start - 1) + 1;
|
|
@@ -199,22 +195,183 @@ export function applyDeleteElement(root, loc, { dryRun = false } = {}) {
|
|
|
199
195
|
const next = content.slice(0, start) + content.slice(end);
|
|
200
196
|
try {
|
|
201
197
|
parseSource(next, file);
|
|
198
|
+
return next;
|
|
202
199
|
} catch {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Ancestor chain from `element` up to the Program node (element first).
|
|
205
|
+
function ancestorChain(ast, element) {
|
|
206
|
+
const chain = [];
|
|
207
|
+
(function descend(node, trail) {
|
|
208
|
+
if (!node || typeof node.type !== 'string') return false;
|
|
209
|
+
if (node === element) {
|
|
210
|
+
chain.push(element, ...trail);
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
if (node.start > element.start || node.end < element.end) return false;
|
|
214
|
+
for (const key of Object.keys(node)) {
|
|
215
|
+
if (key === 'loc') continue;
|
|
216
|
+
const v = node[key];
|
|
217
|
+
const kids = Array.isArray(v) ? v : [v];
|
|
218
|
+
for (const kid of kids) {
|
|
219
|
+
if (kid && typeof kid.type === 'string' && descend(kid, [node, ...trail])) return true;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return false;
|
|
223
|
+
})(ast, []);
|
|
224
|
+
return chain;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const FN_TYPES = new Set(['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression']);
|
|
228
|
+
|
|
229
|
+
// If the chain shows `element` is the RETURNED VALUE of the innermost enclosing
|
|
230
|
+
// function (only Return/Block between them), return that function node.
|
|
231
|
+
// This gate matters: without it, a parse-breaking delete anywhere inside a
|
|
232
|
+
// named component would wrongly escalate to removing the component's usage.
|
|
233
|
+
function returningFunction(chain) {
|
|
234
|
+
for (let i = 1; i < chain.length; i++) {
|
|
235
|
+
const t = chain[i].type;
|
|
236
|
+
if (t === 'ReturnStatement' || t === 'BlockStatement') continue;
|
|
237
|
+
return FN_TYPES.has(t) ? chain[i] : null;
|
|
238
|
+
}
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function nameOfFunction(ast, fn) {
|
|
243
|
+
if (!fn) return null;
|
|
244
|
+
if (fn.id && fn.id.name) return fn.id.name;
|
|
245
|
+
let name = null;
|
|
246
|
+
walk(ast, (n) => {
|
|
247
|
+
if (
|
|
248
|
+
n.type === 'VariableDeclarator' &&
|
|
249
|
+
n.init && n.init.start === fn.start && n.init.end === fn.end &&
|
|
250
|
+
n.id && n.id.type === 'Identifier'
|
|
251
|
+
) name = n.id.name;
|
|
252
|
+
});
|
|
253
|
+
return name;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Walk up from the element looking for a `{...}` JSX expression whose whole
|
|
257
|
+
// removal parses — covers `{cond && <el/>}` (conditional) and `{arr.map(...)}`
|
|
258
|
+
// (list template: removing it removes the rendered list). Never crosses a
|
|
259
|
+
// function boundary except an inline callback (a function that is itself a
|
|
260
|
+
// call argument, e.g. the .map render callback).
|
|
261
|
+
function removableExpressionContainer(chain) {
|
|
262
|
+
for (let i = 1; i < chain.length; i++) {
|
|
263
|
+
const node = chain[i];
|
|
264
|
+
if (node.type === 'JSXExpressionContainer') {
|
|
265
|
+
const inner = chain[i - 1];
|
|
266
|
+
const isList = FN_TYPES.has(inner?.type) || inner?.type === 'CallExpression';
|
|
267
|
+
return { container: node, kind: isList ? 'list' : 'conditional block' };
|
|
268
|
+
}
|
|
269
|
+
if (FN_TYPES.has(node.type)) {
|
|
270
|
+
// crossing a function is only allowed for inline callbacks (map etc.)
|
|
271
|
+
const parent = chain[i + 1];
|
|
272
|
+
if (!parent || parent.type !== 'CallExpression') return null;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Walk the project for JSX usages of <Name ...>. Returns [{file, element}].
|
|
279
|
+
function findUsages(root, name) {
|
|
280
|
+
const usages = [];
|
|
281
|
+
const skip = new Set(['node_modules', '.next', '.git', 'dist', '.turbo']);
|
|
282
|
+
const stack = [root];
|
|
283
|
+
while (stack.length) {
|
|
284
|
+
const dir = stack.pop();
|
|
285
|
+
let entries;
|
|
286
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
|
|
287
|
+
for (const e of entries) {
|
|
288
|
+
const full = path.join(dir, e.name);
|
|
289
|
+
if (e.isDirectory()) { if (!skip.has(e.name)) stack.push(full); continue; }
|
|
290
|
+
if (!/\.(jsx|tsx)$/.test(e.name)) continue;
|
|
291
|
+
let content, ast;
|
|
292
|
+
try {
|
|
293
|
+
content = fs.readFileSync(full, 'utf8');
|
|
294
|
+
if (!content.includes('<' + name)) continue; // cheap prefilter
|
|
295
|
+
ast = parseSource(content, e.name);
|
|
296
|
+
} catch { continue; }
|
|
297
|
+
walk(ast, (n) => {
|
|
298
|
+
if (n.type !== 'JSXElement') return;
|
|
299
|
+
const openName = n.openingElement.name;
|
|
300
|
+
if (openName.type === 'JSXIdentifier' && openName.name === name) {
|
|
301
|
+
usages.push({ file: path.relative(root, full), abs: full, content, element: n });
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return usages;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Delete behavior, in order:
|
|
311
|
+
* 1. Normal element (has siblings): remove it in place.
|
|
312
|
+
* 2. Element inside a `{...}` JSX expression whose removal parses — a list
|
|
313
|
+
* template (`{arr.map(...)}`) or a conditional (`{cond && <el/>}`):
|
|
314
|
+
* remove the whole expression. Deleting a list template removes the
|
|
315
|
+
* rendered list — that's what the on-screen "container" is.
|
|
316
|
+
* 3. Sole return of a NAMED component used in exactly one place: remove that
|
|
317
|
+
* usage at its call site.
|
|
318
|
+
* Otherwise refuse with an actionable message (ambiguous → model lane).
|
|
319
|
+
*/
|
|
320
|
+
export function applyDeleteElement(root, loc, { dryRun = false } = {}) {
|
|
321
|
+
// reuse loadTarget's AST — element identity must hold for the ancestor walk
|
|
322
|
+
const { abs, content, element, file, ast } = loadTarget(root, loc);
|
|
323
|
+
|
|
324
|
+
const inPlace = removeElementFromContent(content, element, file);
|
|
325
|
+
if (inPlace !== null) {
|
|
326
|
+
if (dryRun) return { abs, before: content, after: inPlace, wouldWrite: false };
|
|
327
|
+
return writeChecked(abs, content, inPlace);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// In-place removal breaks the file — walk up for a deletable wrapper.
|
|
331
|
+
const chain = ancestorChain(ast, element);
|
|
332
|
+
|
|
333
|
+
const wrapper = removableExpressionContainer(chain);
|
|
334
|
+
if (wrapper) {
|
|
335
|
+
const next = removeElementFromContent(content, wrapper.container, file);
|
|
336
|
+
if (next !== null) {
|
|
337
|
+
if (dryRun) return { abs, before: content, after: next, wouldWrite: false, removedBlock: wrapper.kind };
|
|
338
|
+
return { ...writeChecked(abs, content, next), removedBlock: wrapper.kind };
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Not a removable wrapper — usage removal, but ONLY when the element really
|
|
343
|
+
// is the returned output of a named component.
|
|
344
|
+
const fn = returningFunction(chain);
|
|
345
|
+
const name = nameOfFunction(ast, fn);
|
|
346
|
+
if (!name || !/^[A-Z]/.test(name)) {
|
|
210
347
|
throw new Error(
|
|
211
|
-
|
|
212
|
-
? "can't delete — it's the only thing this component (or list item) renders; deleting it would leave nothing to return. Describe the change instead (e.g. \"remove this from the list\") and it'll route through the model."
|
|
213
|
-
: 'deleting this element would break the file — describe the change instead'
|
|
348
|
+
"can't delete this in place — it's the only thing rendered here and there's no removable wrapper. Describe the change instead (e.g. \"remove this from the list\") and it'll route through the model."
|
|
214
349
|
);
|
|
215
350
|
}
|
|
216
|
-
|
|
217
|
-
|
|
351
|
+
|
|
352
|
+
const usages = findUsages(root, name).filter(
|
|
353
|
+
(u) => !(u.abs === abs && u.element.start === element.start && u.element.end === element.end)
|
|
354
|
+
);
|
|
355
|
+
if (usages.length === 0) {
|
|
356
|
+
throw new Error(
|
|
357
|
+
`can't find where <${name}> is used, so there's no usage to remove. Describe the change instead and it'll route through the model.`
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
if (usages.length > 1) {
|
|
361
|
+
throw new Error(
|
|
362
|
+
`<${name}> is used in ${usages.length} places — deleting one is ambiguous. Select the specific <${name}> instance you want removed, or describe the change.`
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const usage = usages[0];
|
|
367
|
+
const removed = removeElementFromContent(usage.content, usage.element, usage.file);
|
|
368
|
+
if (removed === null) {
|
|
369
|
+
throw new Error(
|
|
370
|
+
`removing the <${name}> usage would break ${usage.file}. Describe the change instead.`
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
if (dryRun) return { abs: usage.abs, before: usage.content, after: removed, wouldWrite: false, removedUsage: name };
|
|
374
|
+
return { ...writeChecked(usage.abs, usage.content, removed), removedUsage: name };
|
|
218
375
|
}
|
|
219
376
|
|
|
220
377
|
/** Returns null if the file parses, else the parse error message. */
|
package/src/router.js
CHANGED
|
@@ -1,24 +1,188 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
|
|
3
|
-
//
|
|
4
|
-
// (
|
|
5
|
-
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Model routing — layered router (deterministic first, LLM tiebreak second).
|
|
5
|
+
//
|
|
6
|
+
// Tiers (see README for the routing table):
|
|
7
|
+
// 1 component tweaks / styles / copy / existing components → Sonnet-tier
|
|
8
|
+
// 2 new logic & functionality, low error rate → Opus-tier
|
|
9
|
+
// 3 multi-feature / cross-cutting / mission-critical → Opus xhigh → Fable
|
|
10
|
+
//
|
|
11
|
+
// "Speed" within a tier is the claude CLI's --effort flag (low..max), scaled
|
|
12
|
+
// by a complexity score. Layer 1 is lexicon+structure heuristics (<1ms, free);
|
|
13
|
+
// when its signals are ambiguous, Layer 2 asks Haiku to classify via
|
|
14
|
+
// --json-schema (~1-3s, ~$0.001). TWEAKLOCAL_ROUTER=heuristic|hybrid|llm.
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
6
16
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
17
|
+
const MODELS = {
|
|
18
|
+
t1: process.env.TWEAKLOCAL_T1_MODEL || process.env.TWEAKLOCAL_FAST_MODEL || 'claude-sonnet-5',
|
|
19
|
+
t2: process.env.TWEAKLOCAL_T2_MODEL || process.env.TWEAKLOCAL_SMART_MODEL || 'claude-opus-4-8',
|
|
20
|
+
t3: process.env.TWEAKLOCAL_T3_MODEL || 'claude-opus-4-8',
|
|
21
|
+
t3Critical: process.env.TWEAKLOCAL_T3_CRITICAL_MODEL || 'claude-fable-5',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// --- Layer 1 lexicons ------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
const STYLE_COPY =
|
|
27
|
+
/(colou?r|\b(red|blue|green|yellow|orange|purple|pink|white|black|gr[ae]y|teal|indigo|emerald|amber|slate|navy|cream)\b|padding|margin|spacing|font|bigger|smaller|larger|tighter|wider|bold|italic|underline|round(ed)?|corner|radius|shadow|glow|gradient|background|border|align|cent(er|re)|width|height|gap|opacity|hover|focus|responsive|mobile|breakpoint|dark mode|reword|rewrite|rephrase|shorten|copy|headline|heading|title|caption|label|tone|punchier|catch(y|ier)|wording|typo|spelling)/i;
|
|
28
|
+
|
|
29
|
+
const EXISTING_COMPONENT =
|
|
30
|
+
/(add another|duplicate|a copy of|move (the|this|it)|reorder|swap|reuse|use the existing|another instance|same as the|like the one)/i;
|
|
31
|
+
|
|
32
|
+
const NEW_LOGIC =
|
|
33
|
+
/(implement|build|create|add a (new )?(feature|form|modal|dropdown|toggle|filter|search|carousel|slider|tab|accordion|tooltip|menu|pagination|stepper|wizard)|when (clicked|submitted|hovered|selected|scrolled)|on (click|submit|change|load|scroll)|fetch|\bapi\b|endpoint|request|\bstate\b|store|track|count(er|down)?|validat(e|ion)|debounce|localstorage|session storage|cookie|websocket|subscribe|drag|sortable|upload|download|timer|interval|keyboard shortcut)/i;
|
|
34
|
+
|
|
35
|
+
// "signup form" is a UI task (tier 2); "sign in with google" is auth (tier 3) —
|
|
36
|
+
// the sign in/up pattern only counts when followed by an auth-flow preposition.
|
|
37
|
+
const MISSION_CRITICAL =
|
|
38
|
+
/(\bauth\b|authentication|oauth|\bsso\b|log ?in|log ?out|sign ?(in|up) (with|via|using|flow)|password|payment|checkout|billing|stripe|subscription|credit card|security|permission|\brole\b|admin|database|migration|schema|production|deploy|delete (all|account|user)|gdpr|\bpii\b|encrypt)/i;
|
|
39
|
+
|
|
40
|
+
const MULTI_SCOPE =
|
|
41
|
+
/(across (the|all)|all pages|every (page|component|section)|entire (site|app|page)|throughout|end.to.end|site.wide|app.wide)/i;
|
|
42
|
+
|
|
43
|
+
const BIG_REWORK = /(refactor|redesign|rebuild|overhaul|rework|migrate)/i;
|
|
44
|
+
|
|
45
|
+
// --- Layer 1: deterministic scoring ---------------------------------------
|
|
46
|
+
|
|
47
|
+
function complexityOf(instruction) {
|
|
48
|
+
let score = 0;
|
|
49
|
+
const words = instruction.trim().split(/\s+/).length;
|
|
50
|
+
if (words > 12) score++;
|
|
51
|
+
if (words > 30) score++;
|
|
52
|
+
const clauses =
|
|
53
|
+
(instruction.match(/\b(and|then|also|plus|as well as|after that)\b/gi) || []).length +
|
|
54
|
+
(instruction.match(/[;,]/g) || []).length;
|
|
55
|
+
if (clauses >= 2) score++;
|
|
56
|
+
if (clauses >= 4) score++;
|
|
57
|
+
if (MULTI_SCOPE.test(instruction) || BIG_REWORK.test(instruction)) score++;
|
|
58
|
+
return score >= 3 ? 'high' : score >= 1 ? 'medium' : 'low';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function heuristicTier(instruction) {
|
|
62
|
+
const critical = MISSION_CRITICAL.test(instruction);
|
|
63
|
+
const multi = MULTI_SCOPE.test(instruction);
|
|
64
|
+
const rework = BIG_REWORK.test(instruction);
|
|
65
|
+
const logic = NEW_LOGIC.test(instruction);
|
|
66
|
+
const surface = STYLE_COPY.test(instruction) || EXISTING_COMPONENT.test(instruction);
|
|
67
|
+
|
|
68
|
+
if (critical) return { tier: 3, critical: true, confident: true };
|
|
69
|
+
if ((multi || rework) && (logic || rework || complexityOf(instruction) === 'high'))
|
|
70
|
+
return { tier: 3, critical: false, confident: true };
|
|
71
|
+
if (logic) return { tier: 2, critical: false, confident: true };
|
|
72
|
+
if (surface) return { tier: 1, critical: false, confident: true };
|
|
73
|
+
// no signal at all — default to the cheap tier but flag for the LLM tiebreak
|
|
74
|
+
return { tier: 1, critical: false, confident: false };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// --- Layer 2: Haiku classifier for ambiguous requests ----------------------
|
|
78
|
+
|
|
79
|
+
const CLASSIFY_SCHEMA = JSON.stringify({
|
|
80
|
+
type: 'object',
|
|
81
|
+
properties: {
|
|
82
|
+
tier: { type: 'integer', enum: [1, 2, 3] },
|
|
83
|
+
complexity: { type: 'string', enum: ['low', 'medium', 'high'] },
|
|
84
|
+
},
|
|
85
|
+
required: ['tier', 'complexity'],
|
|
86
|
+
additionalProperties: false,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
function llmClassify(instruction, cwd) {
|
|
90
|
+
return new Promise((resolve) => {
|
|
91
|
+
const prompt = [
|
|
92
|
+
'Classify this UI change request for model routing. Reply with JSON only.',
|
|
93
|
+
'tier 1: visual tweaks, styles, copy edits, or rearranging existing components.',
|
|
94
|
+
'tier 2: new logic or functionality within one area (handlers, state, forms, data fetching).',
|
|
95
|
+
'tier 3: multi-feature or cross-cutting work, or anything touching auth/payments/data/security.',
|
|
96
|
+
'complexity: low = one small change; medium = a few steps; high = many steps or broad scope.',
|
|
97
|
+
`Request: "${instruction.slice(0, 400)}"`,
|
|
98
|
+
].join('\n');
|
|
99
|
+
const child = spawn(
|
|
100
|
+
'claude',
|
|
101
|
+
['-p', prompt, '--model', 'haiku', '--effort', 'low', '--json-schema', CLASSIFY_SCHEMA, '--output-format', 'json'],
|
|
102
|
+
{ cwd, env: { ...process.env, CLAUDE_CODE_ENTRYPOINT: undefined } }
|
|
103
|
+
);
|
|
104
|
+
let out = '';
|
|
105
|
+
const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, 15000);
|
|
106
|
+
timer.unref?.();
|
|
107
|
+
child.stdout.on('data', (d) => (out += d));
|
|
108
|
+
child.on('close', () => {
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
try {
|
|
111
|
+
const envelope = JSON.parse(out);
|
|
112
|
+
// --json-schema puts the validated object in structured_output
|
|
113
|
+
const parsed =
|
|
114
|
+
envelope.structured_output ??
|
|
115
|
+
(typeof envelope.result === 'string' && envelope.result ? JSON.parse(envelope.result) : envelope.result);
|
|
116
|
+
if (parsed && [1, 2, 3].includes(parsed.tier)) return resolve(parsed);
|
|
117
|
+
} catch { /* fall through */ }
|
|
118
|
+
resolve(null);
|
|
119
|
+
});
|
|
120
|
+
child.on('error', () => { clearTimeout(timer); resolve(null); });
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// --- Route resolution -------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
const EFFORT = {
|
|
127
|
+
1: { low: 'low', medium: 'medium', high: 'high' },
|
|
128
|
+
2: { low: 'medium', medium: 'high', high: 'xhigh' },
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
function resolveRoute(tier, complexity, critical, source) {
|
|
132
|
+
if (tier === 3) {
|
|
133
|
+
const frontier = critical || complexity === 'high';
|
|
134
|
+
return {
|
|
135
|
+
tier,
|
|
136
|
+
complexity,
|
|
137
|
+
model: frontier ? MODELS.t3Critical : MODELS.t3,
|
|
138
|
+
// Fable's thinking is always on; effort still scales depth. Opus tier-3
|
|
139
|
+
// runs at xhigh — the setting recommended for the hardest agentic work.
|
|
140
|
+
effort: frontier ? 'high' : 'xhigh',
|
|
141
|
+
kind: critical ? 'mission-critical' : 'multi-feature',
|
|
142
|
+
source,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
14
145
|
return {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
146
|
+
tier,
|
|
147
|
+
complexity,
|
|
148
|
+
model: tier === 1 ? MODELS.t1 : MODELS.t2,
|
|
149
|
+
effort: EFFORT[tier][complexity],
|
|
150
|
+
kind: tier === 1 ? 'tweak' : 'feature',
|
|
151
|
+
source,
|
|
19
152
|
};
|
|
20
153
|
}
|
|
21
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Route an instruction to {tier, complexity, model, effort}. Async because
|
|
157
|
+
* ambiguous requests may consult the Haiku classifier (hybrid mode).
|
|
158
|
+
*/
|
|
159
|
+
export async function classify(instruction, { cwd } = {}) {
|
|
160
|
+
const mode = process.env.TWEAKLOCAL_ROUTER || 'hybrid';
|
|
161
|
+
const h = heuristicTier(instruction);
|
|
162
|
+
let tier = h.tier;
|
|
163
|
+
let critical = h.critical;
|
|
164
|
+
let complexity = complexityOf(instruction);
|
|
165
|
+
let source = 'heuristic';
|
|
166
|
+
|
|
167
|
+
const wantLLM = mode === 'llm' || (mode === 'hybrid' && !h.confident);
|
|
168
|
+
if (wantLLM) {
|
|
169
|
+
const llm = await llmClassify(instruction, cwd);
|
|
170
|
+
if (llm) {
|
|
171
|
+
tier = llm.tier;
|
|
172
|
+
complexity = llm.complexity;
|
|
173
|
+
critical = critical || (llm.tier === 3 && MISSION_CRITICAL.test(instruction));
|
|
174
|
+
source = 'llm';
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return resolveRoute(tier, complexity, critical, source);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Synchronous heuristic-only classification (used by tests and dry runs). */
|
|
181
|
+
export function classifySync(instruction) {
|
|
182
|
+
const h = heuristicTier(instruction);
|
|
183
|
+
return resolveRoute(h.tier, complexityOf(instruction), h.critical, 'heuristic');
|
|
184
|
+
}
|
|
185
|
+
|
|
22
186
|
export function buildPrompt({ file, target, instruction, tailwind = true }) {
|
|
23
187
|
const styleRule = tailwind
|
|
24
188
|
? 'use Tailwind classes for styling'
|
|
@@ -36,31 +200,57 @@ export function buildPrompt({ file, target, instruction, tailwind = true }) {
|
|
|
36
200
|
].join('\n');
|
|
37
201
|
}
|
|
38
202
|
|
|
39
|
-
|
|
203
|
+
/**
|
|
204
|
+
* Spawn a headless claude run. `onSpawn` receives the child process so the
|
|
205
|
+
* caller can register it for cancellation. If cancelled (child killed), the
|
|
206
|
+
* promise resolves with { ok: false, cancelled: true }.
|
|
207
|
+
*/
|
|
208
|
+
export function runClaude({ prompt, model, effort, cwd, onEvent, onSpawn }) {
|
|
40
209
|
return new Promise((resolve) => {
|
|
41
210
|
const started = Date.now();
|
|
211
|
+
const args = [
|
|
212
|
+
'-p',
|
|
213
|
+
prompt,
|
|
214
|
+
'--model',
|
|
215
|
+
model,
|
|
216
|
+
'--allowedTools',
|
|
217
|
+
'Read,Edit',
|
|
218
|
+
'--permission-mode',
|
|
219
|
+
'acceptEdits',
|
|
220
|
+
'--output-format',
|
|
221
|
+
'json',
|
|
222
|
+
];
|
|
223
|
+
if (effort) args.push('--effort', effort);
|
|
42
224
|
const child = spawn(
|
|
43
225
|
'claude',
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
'Read,Edit',
|
|
51
|
-
'--permission-mode',
|
|
52
|
-
'acceptEdits',
|
|
53
|
-
'--output-format',
|
|
54
|
-
'json',
|
|
55
|
-
],
|
|
56
|
-
{ cwd, env: { ...process.env, CLAUDE_CODE_ENTRYPOINT: undefined } }
|
|
226
|
+
args,
|
|
227
|
+
// detached → the child leads its own process group, so cancelling can
|
|
228
|
+
// signal the whole group (claude + any tool subprocesses it spawns).
|
|
229
|
+
// Killing only the top process orphans children that keep stdio pipes
|
|
230
|
+
// open, which would stall the 'close' event and the restore that follows.
|
|
231
|
+
{ cwd, env: { ...process.env, CLAUDE_CODE_ENTRYPOINT: undefined }, detached: true }
|
|
57
232
|
);
|
|
233
|
+
let cancelled = false;
|
|
234
|
+
const signalGroup = (sig) => {
|
|
235
|
+
try { process.kill(-child.pid, sig); } // negative pid = process group
|
|
236
|
+
catch { try { child.kill(sig); } catch {} } // fall back to the single process
|
|
237
|
+
};
|
|
238
|
+
child.__cancel = () => {
|
|
239
|
+
cancelled = true;
|
|
240
|
+
signalGroup('SIGTERM');
|
|
241
|
+
setTimeout(() => { if (!child.killed) signalGroup('SIGKILL'); }, 1500).unref?.();
|
|
242
|
+
};
|
|
243
|
+
onSpawn?.(child);
|
|
58
244
|
let out = '';
|
|
59
245
|
let err = '';
|
|
60
246
|
child.stdout.on('data', (d) => (out += d));
|
|
61
247
|
child.stderr.on('data', (d) => (err += d));
|
|
62
248
|
child.on('close', (code) => {
|
|
63
249
|
const durationMs = Date.now() - started;
|
|
250
|
+
if (cancelled) {
|
|
251
|
+
resolve({ ok: false, cancelled: true, durationMs });
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
64
254
|
if (code !== 0) {
|
|
65
255
|
resolve({ ok: false, error: err.trim() || `claude exited ${code}`, durationMs });
|
|
66
256
|
return;
|
package/src/server.js
CHANGED
|
@@ -30,6 +30,7 @@ function detectTailwind(root) {
|
|
|
30
30
|
|
|
31
31
|
export function startServer({ root, port = 4100 }) {
|
|
32
32
|
const undoStack = new Map(); // id -> { abs, before }
|
|
33
|
+
const running = new Map(); // id -> child process (for cancellation)
|
|
33
34
|
const sseClients = new Set();
|
|
34
35
|
let nextId = 1;
|
|
35
36
|
const tailwind = detectTailwind(root);
|
|
@@ -135,7 +136,12 @@ export function startServer({ root, port = 4100 }) {
|
|
|
135
136
|
const write = applyDeleteElement(root, body.loc);
|
|
136
137
|
remember(id, write);
|
|
137
138
|
telemetry.record('delete');
|
|
138
|
-
|
|
139
|
+
const label = write.removedUsage
|
|
140
|
+
? `removed <${write.removedUsage}> usage`
|
|
141
|
+
: write.removedBlock
|
|
142
|
+
? `deleted ${write.removedBlock}`
|
|
143
|
+
: `deleted <${target.tagName}>`;
|
|
144
|
+
broadcast({ type: 'tweak', id, kind: 'delete', status: 'done', tokens: 0, label, ...recordSavings(0, 50) });
|
|
139
145
|
return json(res, { ok: true, id });
|
|
140
146
|
}
|
|
141
147
|
|
|
@@ -148,24 +154,55 @@ export function startServer({ root, port = 4100 }) {
|
|
|
148
154
|
return json(res, { ok: true });
|
|
149
155
|
}
|
|
150
156
|
|
|
157
|
+
if (url.pathname === '/api/cancel') {
|
|
158
|
+
const child = running.get(String(body.id));
|
|
159
|
+
if (!child) return json(res, { ok: false, error: 'task not running' }, 404);
|
|
160
|
+
child.__cancel(); // kills the process; the nl handler restores the file
|
|
161
|
+
return json(res, { ok: true });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (url.pathname === '/api/classify') {
|
|
165
|
+
// dry-run the router: no file writes, no model edit run (the hybrid
|
|
166
|
+
// router may still consult the Haiku classifier on ambiguous input)
|
|
167
|
+
const route = await classify(body.instruction || '', { cwd: root });
|
|
168
|
+
return json(res, { ok: true, ...route });
|
|
169
|
+
}
|
|
170
|
+
|
|
151
171
|
if (url.pathname === '/api/nl') {
|
|
152
172
|
const id = nextId++;
|
|
153
173
|
const { file } = parseLoc(body.loc);
|
|
154
174
|
const target = describeTarget(root, body.loc);
|
|
155
|
-
const route = classify(body.instruction);
|
|
175
|
+
const route = await classify(body.instruction, { cwd: root });
|
|
156
176
|
const abs = path.resolve(root, file);
|
|
157
177
|
remember(id, { abs, before: fs.readFileSync(abs, 'utf8') });
|
|
158
178
|
telemetry.record('nl');
|
|
159
|
-
broadcast({ type: 'tweak', id, kind: route.kind, status: 'queued', model: route.model, label: body.instruction.slice(0, 60) });
|
|
160
|
-
json(res, { ok: true, id, model: route.model, kind: route.kind });
|
|
179
|
+
broadcast({ type: 'tweak', id, kind: route.kind, status: 'queued', model: route.model, effort: route.effort, tier: route.tier, label: body.instruction.slice(0, 60) });
|
|
180
|
+
json(res, { ok: true, id, model: route.model, effort: route.effort, tier: route.tier, kind: route.kind });
|
|
181
|
+
|
|
182
|
+
const restore = () => {
|
|
183
|
+
const entry = undoStack.get(String(id));
|
|
184
|
+
if (entry) fs.writeFileSync(entry.abs, entry.before);
|
|
185
|
+
};
|
|
161
186
|
|
|
162
187
|
const prompt = buildPrompt({ file, target, instruction: body.instruction, tailwind });
|
|
163
188
|
const result = await runClaude({
|
|
164
189
|
prompt,
|
|
165
190
|
model: route.model,
|
|
191
|
+
effort: route.effort,
|
|
166
192
|
cwd: root,
|
|
167
193
|
onEvent: (e) => broadcast({ type: 'tweak', id, ...e }),
|
|
194
|
+
onSpawn: (child) => running.set(String(id), child),
|
|
168
195
|
});
|
|
196
|
+
running.delete(String(id));
|
|
197
|
+
|
|
198
|
+
// Cancelled mid-run: undo any partial edit and stop — no retry, no
|
|
199
|
+
// savings credit.
|
|
200
|
+
if (result.cancelled) {
|
|
201
|
+
restore();
|
|
202
|
+
undoStack.delete(String(id));
|
|
203
|
+
broadcast({ type: 'tweak', id, status: 'cancelled', model: route.model });
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
169
206
|
|
|
170
207
|
// The model's edit must leave the file parseable: retry once with
|
|
171
208
|
// the parse error, then revert if it's still broken.
|
|
@@ -175,15 +212,23 @@ export function startServer({ root, port = 4100 }) {
|
|
|
175
212
|
const retry = await runClaude({
|
|
176
213
|
prompt: `Your previous edit to ${file} left it with a JSX/JS syntax error:\n${parseErr}\n\nFix ${file} so it parses cleanly while preserving the intended change: ${body.instruction}\nEdit ONLY that file.`,
|
|
177
214
|
model: route.model,
|
|
215
|
+
effort: route.effort,
|
|
178
216
|
cwd: root,
|
|
217
|
+
onSpawn: (child) => running.set(String(id), child),
|
|
179
218
|
});
|
|
219
|
+
running.delete(String(id));
|
|
220
|
+
if (retry.cancelled) {
|
|
221
|
+
restore();
|
|
222
|
+
undoStack.delete(String(id));
|
|
223
|
+
broadcast({ type: 'tweak', id, status: 'cancelled', model: route.model });
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
180
226
|
result.durationMs += retry.durationMs || 0;
|
|
181
227
|
if (retry.costUSD) result.costUSD = (result.costUSD || 0) + retry.costUSD;
|
|
182
228
|
parseErr = checkSyntax(root, file);
|
|
183
229
|
}
|
|
184
230
|
if (parseErr) {
|
|
185
|
-
|
|
186
|
-
fs.writeFileSync(entry.abs, entry.before);
|
|
231
|
+
restore();
|
|
187
232
|
result.ok = false;
|
|
188
233
|
result.error = `edit reverted — file was left unparseable: ${parseErr.slice(0, 120)}`;
|
|
189
234
|
}
|
|
@@ -193,6 +238,8 @@ export function startServer({ root, port = 4100 }) {
|
|
|
193
238
|
id,
|
|
194
239
|
status: result.ok ? 'done' : 'error',
|
|
195
240
|
model: route.model,
|
|
241
|
+
effort: route.effort,
|
|
242
|
+
tier: route.tier,
|
|
196
243
|
durationMs: result.durationMs,
|
|
197
244
|
costUSD: result.costUSD,
|
|
198
245
|
error: result.error,
|