tweaklocal 0.1.0 → 0.3.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/README.md +10 -0
- package/overlay/overlay.js +21 -4
- package/package.json +26 -5
- package/src/resolver.js +120 -19
- package/src/router.js +213 -28
- package/src/server.js +59 -7
package/README.md
CHANGED
|
@@ -29,6 +29,16 @@ npm i -D tweaklocal @tweaklocal/react
|
|
|
29
29
|
|
|
30
30
|
Works with Turbopack, webpack, and Vite — stamping happens in React's dev JSX runtime, not the bundler. Server components included. Production builds are untouched (the prod runtime is a passthrough).
|
|
31
31
|
|
|
32
|
+
## Telemetry
|
|
33
|
+
|
|
34
|
+
The daemon sends anonymous usage telemetry: package version, node version, OS platform, whether Tailwind was detected, and per-lane tweak counts. **Never** code, file paths, file names, prompts, or anything identifying. A disclosure prints the first time the daemon runs.
|
|
35
|
+
|
|
36
|
+
Opt out permanently:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
export TWEAKLOCAL_TELEMETRY=0 # or DO_NOT_TRACK=1 — both respected
|
|
40
|
+
```
|
|
41
|
+
|
|
32
42
|
## Options
|
|
33
43
|
|
|
34
44
|
- `npx tweaklocal --port 4101` (+ `<TweakLocalOverlay origin="http://localhost:4101" />`)
|
package/overlay/overlay.js
CHANGED
|
@@ -36,9 +36,11 @@
|
|
|
36
36
|
.twk-chip{font-size:10.5px !important;padding:2px 6px !important}
|
|
37
37
|
.twk-tray{position:fixed;right:14px;bottom:14px;display:flex;flex-direction:column;align-items:flex-end;gap:6px;pointer-events:auto}
|
|
38
38
|
.twk-total{background:#064e3b;color:#a7f3d0;border-radius:8px;padding:6px 11px;font-size:13px;box-shadow:0 4px 14px rgba(0,0,0,.3);white-space:nowrap;width:max-content;max-width:720px}
|
|
39
|
+
.twk-total a{color:#6ee7b7;margin-left:10px;text-decoration:underline;cursor:pointer}
|
|
40
|
+
.twk-total a:hover{color:#a7f3d0}
|
|
39
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}
|
|
40
42
|
.twk-dot{width:8px;height:8px;border-radius:50%;flex:none}
|
|
41
|
-
.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}
|
|
42
44
|
.twk-tweak button{background:none;border:none;color:#818cf8;cursor:pointer;font-size:12.5px;padding:0}
|
|
43
45
|
.twk-meta{color:#9ca3af}
|
|
44
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}
|
|
@@ -684,13 +686,19 @@
|
|
|
684
686
|
root.appendChild(tray);
|
|
685
687
|
const totalBar = el('div', 'twk-total');
|
|
686
688
|
totalBar.style.display = 'none';
|
|
689
|
+
const totalText = el('span');
|
|
690
|
+
const reportLink = el('a', null, 'monthly report →');
|
|
691
|
+
reportLink.href = 'https://tweaklocal.dev/report';
|
|
692
|
+
reportLink.target = '_blank';
|
|
693
|
+
reportLink.rel = 'noopener';
|
|
694
|
+
totalBar.append(totalText, reportLink);
|
|
687
695
|
tray.appendChild(totalBar);
|
|
688
696
|
const tweaks = new Map();
|
|
689
697
|
|
|
690
698
|
function showTotals(t) {
|
|
691
699
|
if (!t || !t.count) return;
|
|
692
700
|
totalBar.style.display = '';
|
|
693
|
-
|
|
701
|
+
totalText.textContent = `≈ saved $${t.usd.toFixed(2)} · ${Math.round(t.ms / 1000)}s across ${t.count} tweak${t.count === 1 ? '' : 's'} (vs unscoped agent)`;
|
|
694
702
|
}
|
|
695
703
|
|
|
696
704
|
function addTweak(t) {
|
|
@@ -700,6 +708,13 @@
|
|
|
700
708
|
row._dot = el('span', 'twk-dot');
|
|
701
709
|
row._label = el('span', null, '');
|
|
702
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
|
+
};
|
|
703
718
|
row._undo = el('button', null, 'undo');
|
|
704
719
|
row._undo.style.display = 'none';
|
|
705
720
|
row._undo.onclick = async () => {
|
|
@@ -708,7 +723,7 @@
|
|
|
708
723
|
setTimeout(reposition, 350);
|
|
709
724
|
} catch (e) { row._meta.textContent = e.message; }
|
|
710
725
|
};
|
|
711
|
-
row.append(row._dot, row._label, row._meta, row._undo);
|
|
726
|
+
row.append(row._dot, row._label, row._meta, row._cancel, row._undo);
|
|
712
727
|
tray.insertBefore(row, totalBar.nextSibling);
|
|
713
728
|
tweaks.set(String(t.id), row);
|
|
714
729
|
while (tray.children.length > 7) tray.lastChild.remove();
|
|
@@ -716,10 +731,12 @@
|
|
|
716
731
|
if (t.label) row._label.textContent = t.label;
|
|
717
732
|
if (t.status) {
|
|
718
733
|
row._dot.className = 'twk-dot ' + t.status;
|
|
734
|
+
const inFlight = t.status === 'queued' || t.status === 'running';
|
|
735
|
+
row._cancel.style.display = inFlight ? '' : 'none';
|
|
719
736
|
row._undo.style.display = t.status === 'done' && !String(t.id).startsWith('x') ? '' : 'none';
|
|
720
737
|
}
|
|
721
738
|
const bits = [];
|
|
722
|
-
if (t.model) bits.push(t.model);
|
|
739
|
+
if (t.model) bits.push(t.model.replace(/^claude-/, '') + (t.effort ? ` @ ${t.effort}` : ''));
|
|
723
740
|
if (t.tokens === 0) bits.push('0 tokens');
|
|
724
741
|
if (t.durationMs) bits.push((t.durationMs / 1000).toFixed(1) + 's');
|
|
725
742
|
if (t.costUSD != null) bits.push('$' + t.costUSD.toFixed(3));
|
package/package.json
CHANGED
|
@@ -1,14 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tweaklocal",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=18"
|
|
21
|
+
},
|
|
10
22
|
"dependencies": {
|
|
11
23
|
"@babel/parser": "^7.26.0"
|
|
12
24
|
},
|
|
13
|
-
"keywords": [
|
|
25
|
+
"keywords": [
|
|
26
|
+
"devtools",
|
|
27
|
+
"tailwind",
|
|
28
|
+
"visual-editor",
|
|
29
|
+
"claude",
|
|
30
|
+
"ai",
|
|
31
|
+
"nextjs",
|
|
32
|
+
"vite",
|
|
33
|
+
"react"
|
|
34
|
+
]
|
|
14
35
|
}
|
package/src/resolver.js
CHANGED
|
@@ -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,127 @@ 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
|
+
// If `element` is the sole rendered output of a NAMED component function,
|
|
205
|
+
// return that component's name. Returns null when the innermost enclosing
|
|
206
|
+
// function is an anonymous callback (e.g. a `.map()` list item) — those are
|
|
207
|
+
// data-driven and belong in the model lane, not a usage removal.
|
|
208
|
+
function soleReturnComponentName(ast, element) {
|
|
209
|
+
// innermost function whose span contains the element
|
|
210
|
+
let inner = null;
|
|
211
|
+
walk(ast, (n) => {
|
|
212
|
+
if (
|
|
213
|
+
n.type !== 'FunctionDeclaration' &&
|
|
214
|
+
n.type !== 'FunctionExpression' &&
|
|
215
|
+
n.type !== 'ArrowFunctionExpression'
|
|
216
|
+
) return;
|
|
217
|
+
if (n.start <= element.start && n.end >= element.end) {
|
|
218
|
+
if (!inner || (n.end - n.start) < (inner.end - inner.start)) inner = n;
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
if (!inner) return null;
|
|
222
|
+
|
|
223
|
+
// named function declaration: function Foo() {}
|
|
224
|
+
if (inner.id && inner.id.name) return inner.id.name;
|
|
225
|
+
|
|
226
|
+
// arrow/function bound to a name: const Foo = () => {} / const Foo = function(){}
|
|
227
|
+
let name = null;
|
|
228
|
+
walk(ast, (n) => {
|
|
229
|
+
if (
|
|
230
|
+
n.type === 'VariableDeclarator' &&
|
|
231
|
+
n.init && n.init.start === inner.start && n.init.end === inner.end &&
|
|
232
|
+
n.id && n.id.type === 'Identifier'
|
|
233
|
+
) name = n.id.name;
|
|
234
|
+
});
|
|
235
|
+
return name; // null if the innermost fn is an anonymous callback (map, etc.)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Walk the project for JSX usages of <Name ...>. Returns [{file, element}].
|
|
239
|
+
function findUsages(root, name) {
|
|
240
|
+
const usages = [];
|
|
241
|
+
const skip = new Set(['node_modules', '.next', '.git', 'dist', '.turbo']);
|
|
242
|
+
const stack = [root];
|
|
243
|
+
while (stack.length) {
|
|
244
|
+
const dir = stack.pop();
|
|
245
|
+
let entries;
|
|
246
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
|
|
247
|
+
for (const e of entries) {
|
|
248
|
+
const full = path.join(dir, e.name);
|
|
249
|
+
if (e.isDirectory()) { if (!skip.has(e.name)) stack.push(full); continue; }
|
|
250
|
+
if (!/\.(jsx|tsx)$/.test(e.name)) continue;
|
|
251
|
+
let content, ast;
|
|
252
|
+
try {
|
|
253
|
+
content = fs.readFileSync(full, 'utf8');
|
|
254
|
+
if (!content.includes('<' + name)) continue; // cheap prefilter
|
|
255
|
+
ast = parseSource(content, e.name);
|
|
256
|
+
} catch { continue; }
|
|
257
|
+
walk(ast, (n) => {
|
|
258
|
+
if (n.type !== 'JSXElement') return;
|
|
259
|
+
const openName = n.openingElement.name;
|
|
260
|
+
if (openName.type === 'JSXIdentifier' && openName.name === name) {
|
|
261
|
+
usages.push({ file: path.relative(root, full), abs: full, content, element: n });
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return usages;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Delete behavior:
|
|
271
|
+
* - Normal element (has siblings / non-sole child): remove it in place.
|
|
272
|
+
* - Sole return of a component: the element IS the component's whole output,
|
|
273
|
+
* so "delete this" means remove the component's rendered *usage*. If the
|
|
274
|
+
* component is used in exactly one place, remove that usage deterministically.
|
|
275
|
+
* Otherwise refuse with an actionable message (ambiguous → model lane).
|
|
276
|
+
*/
|
|
277
|
+
export function applyDeleteElement(root, loc, { dryRun = false } = {}) {
|
|
278
|
+
const { abs, content, element, file } = loadTarget(root, loc);
|
|
279
|
+
|
|
280
|
+
const inPlace = removeElementFromContent(content, element, file);
|
|
281
|
+
if (inPlace !== null) {
|
|
282
|
+
if (dryRun) return { abs, before: content, after: inPlace, wouldWrite: false };
|
|
283
|
+
return writeChecked(abs, content, inPlace);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Removing the element in place would break the file → it's the sole return.
|
|
287
|
+
const ast = parseSource(content, file);
|
|
288
|
+
const name = soleReturnComponentName(ast, element);
|
|
289
|
+
if (!name || !/^[A-Z]/.test(name)) {
|
|
290
|
+
// no resolvable component name (anonymous default, or a .map() list item)
|
|
210
291
|
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'
|
|
292
|
+
"can't delete this in place — it's the only thing rendered here (likely a list item or an unnamed component). Describe the change instead (e.g. \"remove this from the list\") and it'll route through the model."
|
|
214
293
|
);
|
|
215
294
|
}
|
|
216
|
-
|
|
217
|
-
|
|
295
|
+
|
|
296
|
+
const usages = findUsages(root, name).filter(
|
|
297
|
+
(u) => !(u.abs === abs && u.element.start === element.start && u.element.end === element.end)
|
|
298
|
+
);
|
|
299
|
+
if (usages.length === 0) {
|
|
300
|
+
throw new Error(
|
|
301
|
+
`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.`
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
if (usages.length > 1) {
|
|
305
|
+
throw new Error(
|
|
306
|
+
`<${name}> is used in ${usages.length} places — deleting one is ambiguous. Select the specific <${name}> instance you want removed, or describe the change.`
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const usage = usages[0];
|
|
311
|
+
const removed = removeElementFromContent(usage.content, usage.element, usage.file);
|
|
312
|
+
if (removed === null) {
|
|
313
|
+
throw new Error(
|
|
314
|
+
`removing the <${name}> usage would break ${usage.file}. Describe the change instead.`
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
if (dryRun) return { abs: usage.abs, before: usage.content, after: removed, wouldWrite: false, removedUsage: name };
|
|
318
|
+
return { ...writeChecked(usage.abs, usage.content, removed), removedUsage: name };
|
|
218
319
|
}
|
|
219
320
|
|
|
220
321
|
/** Returns null if the file parses, else the parse error message. */
|
package/src/router.js
CHANGED
|
@@ -1,24 +1,183 @@
|
|
|
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
|
+
const MISSION_CRITICAL =
|
|
36
|
+
/(\bauth\b|authentication|log ?in|log ?out|sign ?(in|up)|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;
|
|
37
|
+
|
|
38
|
+
const MULTI_SCOPE =
|
|
39
|
+
/(across (the|all)|all pages|every (page|component|section)|entire (site|app|page)|throughout|end.to.end|site.wide|app.wide)/i;
|
|
40
|
+
|
|
41
|
+
const BIG_REWORK = /(refactor|redesign|rebuild|overhaul|rework|migrate)/i;
|
|
42
|
+
|
|
43
|
+
// --- Layer 1: deterministic scoring ---------------------------------------
|
|
44
|
+
|
|
45
|
+
function complexityOf(instruction) {
|
|
46
|
+
let score = 0;
|
|
47
|
+
const words = instruction.trim().split(/\s+/).length;
|
|
48
|
+
if (words > 12) score++;
|
|
49
|
+
if (words > 30) score++;
|
|
50
|
+
const clauses =
|
|
51
|
+
(instruction.match(/\b(and|then|also|plus|as well as|after that)\b/gi) || []).length +
|
|
52
|
+
(instruction.match(/[;,]/g) || []).length;
|
|
53
|
+
if (clauses >= 2) score++;
|
|
54
|
+
if (clauses >= 4) score++;
|
|
55
|
+
if (MULTI_SCOPE.test(instruction) || BIG_REWORK.test(instruction)) score++;
|
|
56
|
+
return score >= 3 ? 'high' : score >= 1 ? 'medium' : 'low';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function heuristicTier(instruction) {
|
|
60
|
+
const critical = MISSION_CRITICAL.test(instruction);
|
|
61
|
+
const multi = MULTI_SCOPE.test(instruction);
|
|
62
|
+
const rework = BIG_REWORK.test(instruction);
|
|
63
|
+
const logic = NEW_LOGIC.test(instruction);
|
|
64
|
+
const surface = STYLE_COPY.test(instruction) || EXISTING_COMPONENT.test(instruction);
|
|
65
|
+
|
|
66
|
+
if (critical) return { tier: 3, critical: true, confident: true };
|
|
67
|
+
if ((multi || rework) && (logic || rework || complexityOf(instruction) === 'high'))
|
|
68
|
+
return { tier: 3, critical: false, confident: true };
|
|
69
|
+
if (logic) return { tier: 2, critical: false, confident: true };
|
|
70
|
+
if (surface) return { tier: 1, critical: false, confident: true };
|
|
71
|
+
// no signal at all — default to the cheap tier but flag for the LLM tiebreak
|
|
72
|
+
return { tier: 1, critical: false, confident: false };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// --- Layer 2: Haiku classifier for ambiguous requests ----------------------
|
|
76
|
+
|
|
77
|
+
const CLASSIFY_SCHEMA = JSON.stringify({
|
|
78
|
+
type: 'object',
|
|
79
|
+
properties: {
|
|
80
|
+
tier: { type: 'integer', enum: [1, 2, 3] },
|
|
81
|
+
complexity: { type: 'string', enum: ['low', 'medium', 'high'] },
|
|
82
|
+
},
|
|
83
|
+
required: ['tier', 'complexity'],
|
|
84
|
+
additionalProperties: false,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
function llmClassify(instruction, cwd) {
|
|
88
|
+
return new Promise((resolve) => {
|
|
89
|
+
const prompt = [
|
|
90
|
+
'Classify this UI change request for model routing. Reply with JSON only.',
|
|
91
|
+
'tier 1: visual tweaks, styles, copy edits, or rearranging existing components.',
|
|
92
|
+
'tier 2: new logic or functionality within one area (handlers, state, forms, data fetching).',
|
|
93
|
+
'tier 3: multi-feature or cross-cutting work, or anything touching auth/payments/data/security.',
|
|
94
|
+
'complexity: low = one small change; medium = a few steps; high = many steps or broad scope.',
|
|
95
|
+
`Request: "${instruction.slice(0, 400)}"`,
|
|
96
|
+
].join('\n');
|
|
97
|
+
const child = spawn(
|
|
98
|
+
'claude',
|
|
99
|
+
['-p', prompt, '--model', 'haiku', '--effort', 'low', '--json-schema', CLASSIFY_SCHEMA, '--output-format', 'json'],
|
|
100
|
+
{ cwd, env: { ...process.env, CLAUDE_CODE_ENTRYPOINT: undefined } }
|
|
101
|
+
);
|
|
102
|
+
let out = '';
|
|
103
|
+
const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, 15000);
|
|
104
|
+
timer.unref?.();
|
|
105
|
+
child.stdout.on('data', (d) => (out += d));
|
|
106
|
+
child.on('close', () => {
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
try {
|
|
109
|
+
const envelope = JSON.parse(out);
|
|
110
|
+
const parsed = typeof envelope.result === 'string' ? JSON.parse(envelope.result) : envelope.result;
|
|
111
|
+
if (parsed && [1, 2, 3].includes(parsed.tier)) return resolve(parsed);
|
|
112
|
+
} catch { /* fall through */ }
|
|
113
|
+
resolve(null);
|
|
114
|
+
});
|
|
115
|
+
child.on('error', () => { clearTimeout(timer); resolve(null); });
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// --- Route resolution -------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
const EFFORT = {
|
|
122
|
+
1: { low: 'low', medium: 'medium', high: 'high' },
|
|
123
|
+
2: { low: 'medium', medium: 'high', high: 'xhigh' },
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
function resolveRoute(tier, complexity, critical, source) {
|
|
127
|
+
if (tier === 3) {
|
|
128
|
+
const frontier = critical || complexity === 'high';
|
|
129
|
+
return {
|
|
130
|
+
tier,
|
|
131
|
+
complexity,
|
|
132
|
+
model: frontier ? MODELS.t3Critical : MODELS.t3,
|
|
133
|
+
// Fable's thinking is always on; effort still scales depth. Opus tier-3
|
|
134
|
+
// runs at xhigh — the setting recommended for the hardest agentic work.
|
|
135
|
+
effort: frontier ? 'high' : 'xhigh',
|
|
136
|
+
kind: critical ? 'mission-critical' : 'multi-feature',
|
|
137
|
+
source,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
14
140
|
return {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
141
|
+
tier,
|
|
142
|
+
complexity,
|
|
143
|
+
model: tier === 1 ? MODELS.t1 : MODELS.t2,
|
|
144
|
+
effort: EFFORT[tier][complexity],
|
|
145
|
+
kind: tier === 1 ? 'tweak' : 'feature',
|
|
146
|
+
source,
|
|
19
147
|
};
|
|
20
148
|
}
|
|
21
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Route an instruction to {tier, complexity, model, effort}. Async because
|
|
152
|
+
* ambiguous requests may consult the Haiku classifier (hybrid mode).
|
|
153
|
+
*/
|
|
154
|
+
export async function classify(instruction, { cwd } = {}) {
|
|
155
|
+
const mode = process.env.TWEAKLOCAL_ROUTER || 'hybrid';
|
|
156
|
+
const h = heuristicTier(instruction);
|
|
157
|
+
let tier = h.tier;
|
|
158
|
+
let critical = h.critical;
|
|
159
|
+
let complexity = complexityOf(instruction);
|
|
160
|
+
let source = 'heuristic';
|
|
161
|
+
|
|
162
|
+
const wantLLM = mode === 'llm' || (mode === 'hybrid' && !h.confident);
|
|
163
|
+
if (wantLLM) {
|
|
164
|
+
const llm = await llmClassify(instruction, cwd);
|
|
165
|
+
if (llm) {
|
|
166
|
+
tier = llm.tier;
|
|
167
|
+
complexity = llm.complexity;
|
|
168
|
+
critical = critical || (llm.tier === 3 && MISSION_CRITICAL.test(instruction));
|
|
169
|
+
source = 'llm';
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return resolveRoute(tier, complexity, critical, source);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Synchronous heuristic-only classification (used by tests and dry runs). */
|
|
176
|
+
export function classifySync(instruction) {
|
|
177
|
+
const h = heuristicTier(instruction);
|
|
178
|
+
return resolveRoute(h.tier, complexityOf(instruction), h.critical, 'heuristic');
|
|
179
|
+
}
|
|
180
|
+
|
|
22
181
|
export function buildPrompt({ file, target, instruction, tailwind = true }) {
|
|
23
182
|
const styleRule = tailwind
|
|
24
183
|
? 'use Tailwind classes for styling'
|
|
@@ -36,31 +195,57 @@ export function buildPrompt({ file, target, instruction, tailwind = true }) {
|
|
|
36
195
|
].join('\n');
|
|
37
196
|
}
|
|
38
197
|
|
|
39
|
-
|
|
198
|
+
/**
|
|
199
|
+
* Spawn a headless claude run. `onSpawn` receives the child process so the
|
|
200
|
+
* caller can register it for cancellation. If cancelled (child killed), the
|
|
201
|
+
* promise resolves with { ok: false, cancelled: true }.
|
|
202
|
+
*/
|
|
203
|
+
export function runClaude({ prompt, model, effort, cwd, onEvent, onSpawn }) {
|
|
40
204
|
return new Promise((resolve) => {
|
|
41
205
|
const started = Date.now();
|
|
206
|
+
const args = [
|
|
207
|
+
'-p',
|
|
208
|
+
prompt,
|
|
209
|
+
'--model',
|
|
210
|
+
model,
|
|
211
|
+
'--allowedTools',
|
|
212
|
+
'Read,Edit',
|
|
213
|
+
'--permission-mode',
|
|
214
|
+
'acceptEdits',
|
|
215
|
+
'--output-format',
|
|
216
|
+
'json',
|
|
217
|
+
];
|
|
218
|
+
if (effort) args.push('--effort', effort);
|
|
42
219
|
const child = spawn(
|
|
43
220
|
'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 } }
|
|
221
|
+
args,
|
|
222
|
+
// detached → the child leads its own process group, so cancelling can
|
|
223
|
+
// signal the whole group (claude + any tool subprocesses it spawns).
|
|
224
|
+
// Killing only the top process orphans children that keep stdio pipes
|
|
225
|
+
// open, which would stall the 'close' event and the restore that follows.
|
|
226
|
+
{ cwd, env: { ...process.env, CLAUDE_CODE_ENTRYPOINT: undefined }, detached: true }
|
|
57
227
|
);
|
|
228
|
+
let cancelled = false;
|
|
229
|
+
const signalGroup = (sig) => {
|
|
230
|
+
try { process.kill(-child.pid, sig); } // negative pid = process group
|
|
231
|
+
catch { try { child.kill(sig); } catch {} } // fall back to the single process
|
|
232
|
+
};
|
|
233
|
+
child.__cancel = () => {
|
|
234
|
+
cancelled = true;
|
|
235
|
+
signalGroup('SIGTERM');
|
|
236
|
+
setTimeout(() => { if (!child.killed) signalGroup('SIGKILL'); }, 1500).unref?.();
|
|
237
|
+
};
|
|
238
|
+
onSpawn?.(child);
|
|
58
239
|
let out = '';
|
|
59
240
|
let err = '';
|
|
60
241
|
child.stdout.on('data', (d) => (out += d));
|
|
61
242
|
child.stderr.on('data', (d) => (err += d));
|
|
62
243
|
child.on('close', (code) => {
|
|
63
244
|
const durationMs = Date.now() - started;
|
|
245
|
+
if (cancelled) {
|
|
246
|
+
resolve({ ok: false, cancelled: true, durationMs });
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
64
249
|
if (code !== 0) {
|
|
65
250
|
resolve({ ok: false, error: err.trim() || `claude exited ${code}`, durationMs });
|
|
66
251
|
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);
|
|
@@ -73,7 +74,7 @@ export function startServer({ root, port = 4100 }) {
|
|
|
73
74
|
}
|
|
74
75
|
|
|
75
76
|
if (req.method === 'GET' && url.pathname === '/api/health') {
|
|
76
|
-
return json(res, { ok: true, root, totals, tailwind });
|
|
77
|
+
return json(res, { ok: true, root, totals, tailwind, telemetry: !telemetry.disabled });
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
if (req.method === 'GET' && url.pathname === '/api/events') {
|
|
@@ -99,6 +100,7 @@ export function startServer({ root, port = 4100 }) {
|
|
|
99
100
|
const id = nextId++;
|
|
100
101
|
const write = applyTextEdit(root, body.loc, body.oldText, body.newText);
|
|
101
102
|
remember(id, write);
|
|
103
|
+
telemetry.record('copy');
|
|
102
104
|
broadcast({ type: 'tweak', id, kind: 'copy', status: 'done', tokens: 0, label: `copy: "${body.newText.slice(0, 40)}"`, ...recordSavings(0, 50) });
|
|
103
105
|
return json(res, { ok: true, id });
|
|
104
106
|
}
|
|
@@ -107,6 +109,7 @@ export function startServer({ root, port = 4100 }) {
|
|
|
107
109
|
const id = nextId++;
|
|
108
110
|
const write = applyClassEdit(root, body.loc, body.remove || [], body.add || []);
|
|
109
111
|
remember(id, write);
|
|
112
|
+
telemetry.record('style');
|
|
110
113
|
broadcast({ type: 'tweak', id, kind: 'style', status: 'done', tokens: 0, label: `style: ${(body.add || []).join(' ')}`, ...recordSavings(0, 50) });
|
|
111
114
|
return json(res, { ok: true, id });
|
|
112
115
|
}
|
|
@@ -118,6 +121,7 @@ export function startServer({ root, port = 4100 }) {
|
|
|
118
121
|
const desc = Object.entries(body.styles || {})
|
|
119
122
|
.map(([k, v]) => `${k}: ${v}`)
|
|
120
123
|
.join(', ');
|
|
124
|
+
telemetry.record('style');
|
|
121
125
|
broadcast({ type: 'tweak', id, kind: 'style', status: 'done', tokens: 0, label: `style: ${desc.slice(0, 50)}`, ...recordSavings(0, 50) });
|
|
122
126
|
return json(res, { ok: true, id });
|
|
123
127
|
}
|
|
@@ -131,7 +135,11 @@ export function startServer({ root, port = 4100 }) {
|
|
|
131
135
|
const target = describeTarget(root, body.loc);
|
|
132
136
|
const write = applyDeleteElement(root, body.loc);
|
|
133
137
|
remember(id, write);
|
|
134
|
-
|
|
138
|
+
telemetry.record('delete');
|
|
139
|
+
const label = write.removedUsage
|
|
140
|
+
? `removed <${write.removedUsage}> usage`
|
|
141
|
+
: `deleted <${target.tagName}>`;
|
|
142
|
+
broadcast({ type: 'tweak', id, kind: 'delete', status: 'done', tokens: 0, label, ...recordSavings(0, 50) });
|
|
135
143
|
return json(res, { ok: true, id });
|
|
136
144
|
}
|
|
137
145
|
|
|
@@ -144,23 +152,55 @@ export function startServer({ root, port = 4100 }) {
|
|
|
144
152
|
return json(res, { ok: true });
|
|
145
153
|
}
|
|
146
154
|
|
|
155
|
+
if (url.pathname === '/api/cancel') {
|
|
156
|
+
const child = running.get(String(body.id));
|
|
157
|
+
if (!child) return json(res, { ok: false, error: 'task not running' }, 404);
|
|
158
|
+
child.__cancel(); // kills the process; the nl handler restores the file
|
|
159
|
+
return json(res, { ok: true });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (url.pathname === '/api/classify') {
|
|
163
|
+
// dry-run the router: no file writes, no model edit run (the hybrid
|
|
164
|
+
// router may still consult the Haiku classifier on ambiguous input)
|
|
165
|
+
const route = await classify(body.instruction || '', { cwd: root });
|
|
166
|
+
return json(res, { ok: true, ...route });
|
|
167
|
+
}
|
|
168
|
+
|
|
147
169
|
if (url.pathname === '/api/nl') {
|
|
148
170
|
const id = nextId++;
|
|
149
171
|
const { file } = parseLoc(body.loc);
|
|
150
172
|
const target = describeTarget(root, body.loc);
|
|
151
|
-
const route = classify(body.instruction);
|
|
173
|
+
const route = await classify(body.instruction, { cwd: root });
|
|
152
174
|
const abs = path.resolve(root, file);
|
|
153
175
|
remember(id, { abs, before: fs.readFileSync(abs, 'utf8') });
|
|
154
|
-
|
|
155
|
-
|
|
176
|
+
telemetry.record('nl');
|
|
177
|
+
broadcast({ type: 'tweak', id, kind: route.kind, status: 'queued', model: route.model, effort: route.effort, tier: route.tier, label: body.instruction.slice(0, 60) });
|
|
178
|
+
json(res, { ok: true, id, model: route.model, effort: route.effort, tier: route.tier, kind: route.kind });
|
|
179
|
+
|
|
180
|
+
const restore = () => {
|
|
181
|
+
const entry = undoStack.get(String(id));
|
|
182
|
+
if (entry) fs.writeFileSync(entry.abs, entry.before);
|
|
183
|
+
};
|
|
156
184
|
|
|
157
185
|
const prompt = buildPrompt({ file, target, instruction: body.instruction, tailwind });
|
|
158
186
|
const result = await runClaude({
|
|
159
187
|
prompt,
|
|
160
188
|
model: route.model,
|
|
189
|
+
effort: route.effort,
|
|
161
190
|
cwd: root,
|
|
162
191
|
onEvent: (e) => broadcast({ type: 'tweak', id, ...e }),
|
|
192
|
+
onSpawn: (child) => running.set(String(id), child),
|
|
163
193
|
});
|
|
194
|
+
running.delete(String(id));
|
|
195
|
+
|
|
196
|
+
// Cancelled mid-run: undo any partial edit and stop — no retry, no
|
|
197
|
+
// savings credit.
|
|
198
|
+
if (result.cancelled) {
|
|
199
|
+
restore();
|
|
200
|
+
undoStack.delete(String(id));
|
|
201
|
+
broadcast({ type: 'tweak', id, status: 'cancelled', model: route.model });
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
164
204
|
|
|
165
205
|
// The model's edit must leave the file parseable: retry once with
|
|
166
206
|
// the parse error, then revert if it's still broken.
|
|
@@ -170,15 +210,23 @@ export function startServer({ root, port = 4100 }) {
|
|
|
170
210
|
const retry = await runClaude({
|
|
171
211
|
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.`,
|
|
172
212
|
model: route.model,
|
|
213
|
+
effort: route.effort,
|
|
173
214
|
cwd: root,
|
|
215
|
+
onSpawn: (child) => running.set(String(id), child),
|
|
174
216
|
});
|
|
217
|
+
running.delete(String(id));
|
|
218
|
+
if (retry.cancelled) {
|
|
219
|
+
restore();
|
|
220
|
+
undoStack.delete(String(id));
|
|
221
|
+
broadcast({ type: 'tweak', id, status: 'cancelled', model: route.model });
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
175
224
|
result.durationMs += retry.durationMs || 0;
|
|
176
225
|
if (retry.costUSD) result.costUSD = (result.costUSD || 0) + retry.costUSD;
|
|
177
226
|
parseErr = checkSyntax(root, file);
|
|
178
227
|
}
|
|
179
228
|
if (parseErr) {
|
|
180
|
-
|
|
181
|
-
fs.writeFileSync(entry.abs, entry.before);
|
|
229
|
+
restore();
|
|
182
230
|
result.ok = false;
|
|
183
231
|
result.error = `edit reverted — file was left unparseable: ${parseErr.slice(0, 120)}`;
|
|
184
232
|
}
|
|
@@ -188,6 +236,8 @@ export function startServer({ root, port = 4100 }) {
|
|
|
188
236
|
id,
|
|
189
237
|
status: result.ok ? 'done' : 'error',
|
|
190
238
|
model: route.model,
|
|
239
|
+
effort: route.effort,
|
|
240
|
+
tier: route.tier,
|
|
191
241
|
durationMs: result.durationMs,
|
|
192
242
|
costUSD: result.costUSD,
|
|
193
243
|
error: result.error,
|
|
@@ -217,6 +267,8 @@ export function startServer({ root, port = 4100 }) {
|
|
|
217
267
|
});
|
|
218
268
|
server.listen(port, () => {
|
|
219
269
|
console.log(`[tweaklocal] daemon on http://localhost:${port} (root: ${root})`);
|
|
270
|
+
console.log('[tweaklocal] docs & updates → https://tweaklocal.dev');
|
|
271
|
+
if (telemetry.firstRun && !telemetry.disabled) console.log(DISCLOSURE);
|
|
220
272
|
});
|
|
221
273
|
return server;
|
|
222
274
|
}
|