@taj-special/dravix-code 1.1.5 → 1.1.6
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/dist/cli/repl.js +453 -57
- package/dist/services/ai.js +18 -6
- package/dist/services/executor.js +112 -37
- package/package.json +1 -1
package/dist/cli/repl.js
CHANGED
|
@@ -2,7 +2,7 @@ import * as fs from 'fs';
|
|
|
2
2
|
import * as path from 'path';
|
|
3
3
|
import * as os from 'os';
|
|
4
4
|
import chalk from 'chalk';
|
|
5
|
-
import { streamChat, fetchSystemPrompt } from '../services/ai.js';
|
|
5
|
+
import { streamChat, fetchSystemPrompt, FLASH_MODEL, PRO_MODEL } from '../services/ai.js';
|
|
6
6
|
import { buildContext } from '../services/context.js';
|
|
7
7
|
import { parseOps, executeSingleOp, computeDiff } from '../services/executor.js';
|
|
8
8
|
import { handleCommand } from './commands.js';
|
|
@@ -12,6 +12,73 @@ import { getToken, getSavedUser } from '../services/auth.js';
|
|
|
12
12
|
import { checkUsage, reportUsage, estimateTokens, usageBar, fmtNum, formatResetTime } from '../services/usage.js';
|
|
13
13
|
// System prompt is fetched from server at runtime — not stored in this package
|
|
14
14
|
const BASE_PROMPT = ``;
|
|
15
|
+
// Critical behavioral rules — always injected into system prompt for every session
|
|
16
|
+
const BEHAVIORAL_RULES = `
|
|
17
|
+
|
|
18
|
+
## LANGUAGE RULE — HIGHEST PRIORITY, NO EXCEPTIONS:
|
|
19
|
+
Identify the language of the user's message. Reply 100% in that same language — every single word of your response.
|
|
20
|
+
- User wrote Tajik (contains: нест, кун, илова, файл, тағир...) → respond ONLY in Tajik
|
|
21
|
+
- User wrote Russian (contains: удали, добавь, измени, файл...) → respond ONLY in Russian
|
|
22
|
+
- User wrote English → respond ONLY in English
|
|
23
|
+
This rule overrides everything. Even when editing files, your commentary is in the user's language.
|
|
24
|
+
|
|
25
|
+
## EXECUTE IMMEDIATELY — never ask what to do, never explain before acting.
|
|
26
|
+
|
|
27
|
+
## FILE OPERATIONS — use these exact tag formats:
|
|
28
|
+
|
|
29
|
+
### READ a file (when content not yet provided):
|
|
30
|
+
<read_file path="filename"/>
|
|
31
|
+
|
|
32
|
+
### CHANGE something:
|
|
33
|
+
<edit_file path="file.js">
|
|
34
|
+
<find>
|
|
35
|
+
exact old text copied from file
|
|
36
|
+
</find>
|
|
37
|
+
<replace>
|
|
38
|
+
new text goes here
|
|
39
|
+
</replace>
|
|
40
|
+
</edit_file>
|
|
41
|
+
|
|
42
|
+
### DELETE something — <replace> tag is COMPLETELY EMPTY, no spaces, no newlines inside:
|
|
43
|
+
<edit_file path="file.js">
|
|
44
|
+
<find>
|
|
45
|
+
exact text to remove copied from file
|
|
46
|
+
</find>
|
|
47
|
+
<replace></replace>
|
|
48
|
+
</edit_file>
|
|
49
|
+
|
|
50
|
+
### CREATE or fully rewrite a file:
|
|
51
|
+
<write_file path="file.js">
|
|
52
|
+
full file content here
|
|
53
|
+
</write_file>
|
|
54
|
+
|
|
55
|
+
### RUN a command:
|
|
56
|
+
<run_command>command here</run_command>
|
|
57
|
+
|
|
58
|
+
## RULES:
|
|
59
|
+
- File content labeled [File: name — FULL] is already provided — use it, do NOT read again.
|
|
60
|
+
- Copy <find> text CHARACTER BY CHARACTER from the actual file — never write from memory.
|
|
61
|
+
- Include enough surrounding lines so <find> matches exactly ONE location.
|
|
62
|
+
- DELETE = <replace></replace> with nothing inside — if you put anything inside, it is NOT a delete.
|
|
63
|
+
- "Found N identical occurrences" → add more lines to <find> until it is unique.
|
|
64
|
+
|
|
65
|
+
## PERSONALITY & COMMUNICATION:
|
|
66
|
+
- Be warm, friendly, and professional — like a skilled teammate who enjoys the work.
|
|
67
|
+
- Before acting: one short sentence saying what you are about to do — write it in the SAME language the user used.
|
|
68
|
+
- After acting: confirm what was done in a natural, friendly tone — in the SAME language the user used.
|
|
69
|
+
- If the task is interesting or you notice something worth mentioning (a side effect, a tip, a related thing), say it briefly.
|
|
70
|
+
- CRITICAL: Detect the user's language from their message and use that EXACT language for your entire response. Tajik → Tajik. Russian → Russian. English → English. This applies to ALL messages including when doing file operations.`;
|
|
71
|
+
// Full prompt used when server returns empty
|
|
72
|
+
const FALLBACK_PROMPT = `You are Dravix Code — a hyper-professional AI coding assistant running in the terminal.
|
|
73
|
+
You are an elite software engineer who takes immediate, precise action on every request.
|
|
74
|
+
|
|
75
|
+
Core identity:
|
|
76
|
+
- You write production-quality code with no unnecessary comments or fluff
|
|
77
|
+
- You take action first, explain only when absolutely necessary
|
|
78
|
+
- You handle any language: TypeScript, JavaScript, Python, Go, Rust, HTML/CSS, SQL, and more
|
|
79
|
+
- You work on any project type: web apps, mobile apps, CLIs, APIs, databases, scripts
|
|
80
|
+
|
|
81
|
+
${BEHAVIORAL_RULES}`;
|
|
15
82
|
// prompts removed
|
|
16
83
|
// Removed — fetched from server
|
|
17
84
|
const CREATOR_EXTRA = ``;
|
|
@@ -484,6 +551,61 @@ class MarkdownRenderer {
|
|
|
484
551
|
return out;
|
|
485
552
|
}
|
|
486
553
|
}
|
|
554
|
+
// ── Targeted section finder — finds proper HTML container boundaries ──
|
|
555
|
+
function findSectionBlocks(content, keywords) {
|
|
556
|
+
const lines = content.split('\n');
|
|
557
|
+
const seen = new Set();
|
|
558
|
+
const out = [];
|
|
559
|
+
for (const kw of keywords) {
|
|
560
|
+
if (kw.length < 3)
|
|
561
|
+
continue;
|
|
562
|
+
const kwL = kw.toLowerCase();
|
|
563
|
+
for (let i = 0; i < lines.length; i++) {
|
|
564
|
+
if (seen.has(i))
|
|
565
|
+
continue;
|
|
566
|
+
if (!lines[i].toLowerCase().includes(kwL))
|
|
567
|
+
continue;
|
|
568
|
+
// ── Go UP: find enclosing container opening tag ──────────────
|
|
569
|
+
let startIdx = Math.max(0, i - 3);
|
|
570
|
+
const kwIndent = (lines[i].match(/^(\s*)/)?.[1] ?? '').length;
|
|
571
|
+
for (let j = i - 1; j >= Math.max(0, i - 60); j--) {
|
|
572
|
+
const trimmed = lines[j].trim();
|
|
573
|
+
const lineIndent = (lines[j].match(/^(\s*)/)?.[1] ?? '').length;
|
|
574
|
+
// Opening block tag at same or lower indent level
|
|
575
|
+
if (lineIndent <= kwIndent && /<(section|div|article|main|header|footer|ul|ol)\b[^>]*>/.test(trimmed)) {
|
|
576
|
+
startIdx = j;
|
|
577
|
+
break;
|
|
578
|
+
}
|
|
579
|
+
// CSS section comment /* ═══ ... ═══ */
|
|
580
|
+
if (lineIndent <= kwIndent && /\/\*.*[═=─\-]{2,}/.test(trimmed)) {
|
|
581
|
+
startIdx = Math.max(0, j);
|
|
582
|
+
break;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
// ── Go DOWN: find matching closing tag ───────────────────────
|
|
586
|
+
const openMatch = lines[startIdx].match(/<(section|div|article|main|header|footer|ul|ol)\b/);
|
|
587
|
+
let endIdx = Math.min(lines.length - 1, i + 180);
|
|
588
|
+
if (openMatch) {
|
|
589
|
+
const tag = openMatch[1];
|
|
590
|
+
let depth = 0;
|
|
591
|
+
for (let j = startIdx; j <= Math.min(lines.length - 1, i + 300); j++) {
|
|
592
|
+
depth += (lines[j].match(new RegExp(`<${tag}[\\s>]`, 'g')) ?? []).length;
|
|
593
|
+
depth -= (lines[j].match(new RegExp(`</${tag}>`, 'g')) ?? []).length;
|
|
594
|
+
if (depth <= 0 && j > startIdx) {
|
|
595
|
+
endIdx = j;
|
|
596
|
+
break;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
const block = lines.slice(startIdx, endIdx + 1).join('\n');
|
|
601
|
+
out.push({ keyword: kw, lineNo: i + 1, block, startLine: startIdx + 1, endLine: endIdx + 1 });
|
|
602
|
+
for (let j = startIdx; j <= endIdx; j++)
|
|
603
|
+
seen.add(j);
|
|
604
|
+
break;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
return out;
|
|
608
|
+
}
|
|
487
609
|
function normalizeResponse(text) {
|
|
488
610
|
return text.replace(/```[\w]*\n?(<(?:write_file|edit_file|delete_file|create_folder|run_command|read_file|read_folder|search_code)[\s\S]*?(?:<\/(?:write_file|edit_file|run_command)>|<(?:delete_file|create_folder|read_file|read_folder|search_code)[^>]*\/>))\n?```/g, '$1');
|
|
489
611
|
}
|
|
@@ -526,7 +648,148 @@ function clearMenu(lines) {
|
|
|
526
648
|
process.stdout.write('\x1b[1A\x1b[2K');
|
|
527
649
|
}
|
|
528
650
|
}
|
|
529
|
-
|
|
651
|
+
// Returns number of terminal lines printed (for clearing after acceptance)
|
|
652
|
+
function printDiffPreview(op, cwd) {
|
|
653
|
+
let printed = 0;
|
|
654
|
+
try {
|
|
655
|
+
const cols = process.stdout.columns ?? 80;
|
|
656
|
+
const maxW = Math.min(cols - 12, 120);
|
|
657
|
+
const clip = (t) => t.length > maxW ? t.slice(0, maxW - 1) + '…' : t;
|
|
658
|
+
let diff = null;
|
|
659
|
+
if (op.type === 'write' && op.path && op.content !== undefined) {
|
|
660
|
+
const fp = path.resolve(cwd, op.path);
|
|
661
|
+
const old = fs.existsSync(fp) ? fs.readFileSync(fp, 'utf-8') : '';
|
|
662
|
+
diff = computeDiff(old, op.content);
|
|
663
|
+
}
|
|
664
|
+
else if (op.type === 'edit' && op.path && op.find !== undefined && op.replace !== undefined) {
|
|
665
|
+
// Find real line offset in file so line numbers match actual file
|
|
666
|
+
const rawDiff = computeDiff(op.find, op.replace);
|
|
667
|
+
try {
|
|
668
|
+
const fp = path.resolve(cwd, op.path);
|
|
669
|
+
if (fs.existsSync(fp)) {
|
|
670
|
+
const fileContent = fs.readFileSync(fp, 'utf-8');
|
|
671
|
+
const idx = fileContent.indexOf(op.find.trim());
|
|
672
|
+
const offset = idx >= 0 ? fileContent.slice(0, idx).split('\n').length - 1 : 0;
|
|
673
|
+
diff = rawDiff.map(d => ({ ...d, lineNo: d.lineNo + offset }));
|
|
674
|
+
}
|
|
675
|
+
else {
|
|
676
|
+
diff = rawDiff;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
catch {
|
|
680
|
+
diff = rawDiff;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
if (!diff)
|
|
684
|
+
return 0;
|
|
685
|
+
const adds = diff.filter(d => d.kind === 'add').length;
|
|
686
|
+
const removes = diff.filter(d => d.kind === 'remove').length;
|
|
687
|
+
if (adds === 0 && removes === 0)
|
|
688
|
+
return 0;
|
|
689
|
+
const statStr = [
|
|
690
|
+
removes > 0 ? chalk.hex('#f87171').bold(`-${removes}`) : '',
|
|
691
|
+
adds > 0 ? chalk.hex('#34d399').bold(`+${adds}`) : '',
|
|
692
|
+
].filter(Boolean).join(' ');
|
|
693
|
+
const writeLine = (s) => { process.stdout.write(s + '\n'); printed++; };
|
|
694
|
+
process.stdout.write('\n');
|
|
695
|
+
printed++;
|
|
696
|
+
writeLine(chalk.hex('#fbbf24')(' ~') +
|
|
697
|
+
chalk.hex('#94a3b8')(' ') +
|
|
698
|
+
chalk.hex('#e2e8f0')(op.path ?? '') +
|
|
699
|
+
(statStr ? ' ' + statStr : ''));
|
|
700
|
+
const CONTEXT = 2;
|
|
701
|
+
const show = new Uint8Array(diff.length);
|
|
702
|
+
for (let i = 0; i < diff.length; i++) {
|
|
703
|
+
if (diff[i].kind !== 'context') {
|
|
704
|
+
for (let c = Math.max(0, i - CONTEXT); c <= Math.min(diff.length - 1, i + CONTEXT); c++)
|
|
705
|
+
show[c] = 1;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
const renderLine = (line) => {
|
|
709
|
+
const num = String(line.lineNo).padStart(5, ' ');
|
|
710
|
+
if (line.kind === 'remove') {
|
|
711
|
+
const content = ` ${num} ${chalk.hex('#f87171')('-')} ${clip(line.text)}`;
|
|
712
|
+
writeLine(' ' + chalk.hex('#7f1d1d')('▌') + chalk.bgHex('#1c0a0a')(content.padEnd(cols - 3)));
|
|
713
|
+
}
|
|
714
|
+
else if (line.kind === 'add') {
|
|
715
|
+
const content = ` ${num} ${chalk.hex('#34d399')('+')} ${clip(line.text)}`;
|
|
716
|
+
writeLine(' ' + chalk.hex('#065f46')('▌') + chalk.bgHex('#021a0e')(content.padEnd(cols - 3)));
|
|
717
|
+
}
|
|
718
|
+
else {
|
|
719
|
+
writeLine(chalk.hex('#374151')(` ${num} ${clip(line.text)}`));
|
|
720
|
+
}
|
|
721
|
+
};
|
|
722
|
+
const hunks = [];
|
|
723
|
+
let curHunk = [];
|
|
724
|
+
let lastIdx = -1;
|
|
725
|
+
for (let i = 0; i < diff.length; i++) {
|
|
726
|
+
if (!show[i])
|
|
727
|
+
continue;
|
|
728
|
+
if (lastIdx >= 0 && i > lastIdx + 1) {
|
|
729
|
+
hunks.push(curHunk);
|
|
730
|
+
curHunk = [];
|
|
731
|
+
}
|
|
732
|
+
lastIdx = i;
|
|
733
|
+
curHunk.push(diff[i]);
|
|
734
|
+
}
|
|
735
|
+
if (curHunk.length > 0)
|
|
736
|
+
hunks.push(curHunk);
|
|
737
|
+
let shown = 0;
|
|
738
|
+
const MAX_LINES = 60;
|
|
739
|
+
for (let h = 0; h < hunks.length; h++) {
|
|
740
|
+
if (shown >= MAX_LINES)
|
|
741
|
+
break;
|
|
742
|
+
if (h > 0) {
|
|
743
|
+
writeLine(chalk.hex('#374151')(' ╌╌╌'));
|
|
744
|
+
}
|
|
745
|
+
const hunk = hunks[h];
|
|
746
|
+
const before = [];
|
|
747
|
+
const hRem = [];
|
|
748
|
+
const hAdd = [];
|
|
749
|
+
let pendingCtx = [];
|
|
750
|
+
let seenChange = false;
|
|
751
|
+
for (const line of hunk) {
|
|
752
|
+
if (line.kind === 'context') {
|
|
753
|
+
if (!seenChange)
|
|
754
|
+
before.push(line);
|
|
755
|
+
else
|
|
756
|
+
pendingCtx.push(line);
|
|
757
|
+
}
|
|
758
|
+
else {
|
|
759
|
+
seenChange = true;
|
|
760
|
+
pendingCtx = [];
|
|
761
|
+
if (line.kind === 'remove')
|
|
762
|
+
hRem.push(line);
|
|
763
|
+
else
|
|
764
|
+
hAdd.push(line);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
for (const l of before)
|
|
768
|
+
renderLine(l);
|
|
769
|
+
for (const l of hRem) {
|
|
770
|
+
if (shown >= MAX_LINES)
|
|
771
|
+
break;
|
|
772
|
+
renderLine(l);
|
|
773
|
+
shown++;
|
|
774
|
+
}
|
|
775
|
+
for (const l of hAdd) {
|
|
776
|
+
if (shown >= MAX_LINES)
|
|
777
|
+
break;
|
|
778
|
+
renderLine(l);
|
|
779
|
+
shown++;
|
|
780
|
+
}
|
|
781
|
+
for (const l of pendingCtx)
|
|
782
|
+
renderLine(l);
|
|
783
|
+
}
|
|
784
|
+
const total = diff.filter(d => d.kind !== 'context').length;
|
|
785
|
+
if (total > MAX_LINES) {
|
|
786
|
+
writeLine(chalk.hex('#4b5563')(` … ${total - MAX_LINES} more lines`));
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
catch { /* ignore */ }
|
|
790
|
+
return printed;
|
|
791
|
+
}
|
|
792
|
+
async function askPermission(label, key, alwaysAllowed, noAlways, diffShown, previewLineCount = 0) {
|
|
530
793
|
if (!noAlways && alwaysAllowed.has(key))
|
|
531
794
|
return true;
|
|
532
795
|
return new Promise((resolve) => {
|
|
@@ -538,37 +801,48 @@ async function askPermission(label, key, alwaysAllowed, noAlways) {
|
|
|
538
801
|
const { icon, paint } = opStyle(label);
|
|
539
802
|
const optLabels = noAlways ? OPT_LABELS_RUN : OPT_LABELS_DEFAULT;
|
|
540
803
|
const optCount = optLabels.length;
|
|
541
|
-
//
|
|
542
|
-
|
|
543
|
-
const menuLines = (noAlways ? 3 : 4) + 1 + optCount;
|
|
804
|
+
// when diff already shown: just blank + options (no box)
|
|
805
|
+
const menuLines = diffShown ? (1 + optCount) : (noAlways ? 3 : 4) + 1 + optCount;
|
|
544
806
|
function printMenu() {
|
|
545
807
|
if (drawn)
|
|
546
808
|
clearMenu(menuLines);
|
|
547
809
|
drawn = true;
|
|
548
|
-
let
|
|
549
|
-
if (
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
810
|
+
let lines;
|
|
811
|
+
if (diffShown) {
|
|
812
|
+
// Diff already shown above — just show options, no redundant box
|
|
813
|
+
lines = [
|
|
814
|
+
'',
|
|
815
|
+
...optLabels.map((lbl, i) => i === sel
|
|
816
|
+
? ` ${colors.primary('›')} ${colors.primary.bold(lbl)}`
|
|
817
|
+
: ` ${colors.muted(lbl)}`),
|
|
555
818
|
];
|
|
556
819
|
}
|
|
557
820
|
else {
|
|
558
|
-
boxLines
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
821
|
+
let boxLines;
|
|
822
|
+
if (noAlways) {
|
|
823
|
+
const dashes = SEP.length - 2 - 'run command'.length - 1;
|
|
824
|
+
boxLines = [
|
|
825
|
+
colors.dim(' ╭─ ') + colors.primary('run command') + colors.dim(' ' + '─'.repeat(Math.max(dashes, 1))),
|
|
826
|
+
` │ ${chalk.hex('#fbbf24')('$')} ${chalk.white(opValue)}`,
|
|
827
|
+
colors.dim(' ╰' + SEP),
|
|
828
|
+
];
|
|
829
|
+
}
|
|
830
|
+
else {
|
|
831
|
+
boxLines = [
|
|
832
|
+
colors.dim(' ╭' + SEP),
|
|
833
|
+
` │ ${paint(icon + ' ' + opType)}`,
|
|
834
|
+
` │ ${chalk.white(opValue)}`,
|
|
835
|
+
colors.dim(' ╰' + SEP),
|
|
836
|
+
];
|
|
837
|
+
}
|
|
838
|
+
lines = [
|
|
839
|
+
...boxLines,
|
|
840
|
+
'',
|
|
841
|
+
...optLabels.map((lbl, i) => i === sel
|
|
842
|
+
? ` ${colors.primary('›')} ${colors.primary.bold(lbl)}`
|
|
843
|
+
: ` ${colors.muted(lbl)}`),
|
|
563
844
|
];
|
|
564
845
|
}
|
|
565
|
-
const lines = [
|
|
566
|
-
...boxLines,
|
|
567
|
-
'',
|
|
568
|
-
...optLabels.map((lbl, i) => i === sel
|
|
569
|
-
? ` ${colors.primary('›')} ${colors.primary.bold(lbl)}`
|
|
570
|
-
: ` ${colors.muted(lbl)}`),
|
|
571
|
-
];
|
|
572
846
|
process.stdout.write(lines.join('\n') + '\n');
|
|
573
847
|
}
|
|
574
848
|
printMenu();
|
|
@@ -578,6 +852,10 @@ async function askPermission(label, key, alwaysAllowed, noAlways) {
|
|
|
578
852
|
function confirm(idx) {
|
|
579
853
|
cleanup();
|
|
580
854
|
clearMenu(menuLines);
|
|
855
|
+
// Clear the diff preview lines (all except the first blank separator)
|
|
856
|
+
for (let i = 0; i < previewLineCount - 1; i++) {
|
|
857
|
+
process.stdout.write('\x1b[1A\x1b[2K');
|
|
858
|
+
}
|
|
581
859
|
const skipIdx = noAlways ? 1 : 2;
|
|
582
860
|
if (idx === skipIdx) {
|
|
583
861
|
process.stdout.write(` ${colors.muted('○')} ${colors.muted('Skipped: ' + label)}\n`);
|
|
@@ -1930,9 +2208,15 @@ export async function startRepl(cwd) {
|
|
|
1930
2208
|
let SYSTEM_PROMPT = '';
|
|
1931
2209
|
if (token) {
|
|
1932
2210
|
const { prompt, webDesignerSkill } = await fetchSystemPrompt(token);
|
|
1933
|
-
|
|
2211
|
+
// Always inject behavioral rules — append to server prompt so critical rules are never missing
|
|
2212
|
+
SYSTEM_PROMPT = prompt
|
|
2213
|
+
? prompt + '\n' + BEHAVIORAL_RULES
|
|
2214
|
+
: FALLBACK_PROMPT;
|
|
1934
2215
|
_serverWebDesignerSkill = webDesignerSkill;
|
|
1935
2216
|
}
|
|
2217
|
+
else {
|
|
2218
|
+
SYSTEM_PROMPT = FALLBACK_PROMPT;
|
|
2219
|
+
}
|
|
1936
2220
|
// activeCwd can change when /resume loads a conversation from a different directory
|
|
1937
2221
|
let activeCwd = cwd;
|
|
1938
2222
|
const buildSystemMsg = (dir) => ({
|
|
@@ -1953,6 +2237,7 @@ export async function startRepl(cwd) {
|
|
|
1953
2237
|
let pendingOutputTokens = 0;
|
|
1954
2238
|
let pendingUserTokens = 0;
|
|
1955
2239
|
let hasPendingReport = false;
|
|
2240
|
+
let useProModel = false; // switched to PRO_MODEL after file op errors
|
|
1956
2241
|
process.stdin.once('end', () => {
|
|
1957
2242
|
console.log(colors.muted('\n Goodbye!\n'));
|
|
1958
2243
|
process.exit(0);
|
|
@@ -2002,24 +2287,40 @@ export async function startRepl(cwd) {
|
|
|
2002
2287
|
if (readFileContinue) {
|
|
2003
2288
|
readFileContinue = false;
|
|
2004
2289
|
readFileTurnCount++;
|
|
2005
|
-
|
|
2290
|
+
// Remind AI of the original user request — show first + last line so pasted code
|
|
2291
|
+
// doesn't eclipse the actual instruction written at the end.
|
|
2292
|
+
const rawUserMsg = lastUserLine.trim();
|
|
2293
|
+
const msgLines = rawUserMsg.split('\n').map(l => l.trim()).filter(Boolean);
|
|
2294
|
+
let userReminder = '';
|
|
2295
|
+
if (msgLines.length > 0) {
|
|
2296
|
+
const first = msgLines[0].slice(0, 100);
|
|
2297
|
+
const last = msgLines[msgLines.length - 1].slice(0, 150);
|
|
2298
|
+
const display = msgLines.length <= 2 ? msgLines.join(' / ').slice(0, 200) : `${first} [...] ${last}`;
|
|
2299
|
+
userReminder = `The user's request: "${display}". `;
|
|
2300
|
+
}
|
|
2301
|
+
const taskInstruction = userReminder
|
|
2302
|
+
? `The user's exact request was: "${rawUserMsg.slice(0, 300)}"\nNow execute THIS request and ONLY this request — nothing else. Do not invent, add, or change anything the user did not ask for. Use the file content above to find the exact text and apply the change.`
|
|
2303
|
+
: `Execute the user's request using the file content above.`;
|
|
2304
|
+
// Keep using FLASH_MODEL after file reads
|
|
2305
|
+
if (readFileTurnCount > 5 && !forcedEditMode) {
|
|
2006
2306
|
forcedEditMode = true;
|
|
2007
2307
|
history.push({
|
|
2008
2308
|
role: 'system',
|
|
2009
|
-
content: `[
|
|
2309
|
+
content: `[CONTEXT READY] ${taskInstruction}\nDo not read any more files — use what is already in context.`,
|
|
2010
2310
|
});
|
|
2011
2311
|
process.stdout.write('\n' + colors.muted(' ⚠ Too many read operations — forced edit mode') + '\n');
|
|
2012
2312
|
}
|
|
2013
|
-
else if (readFileTurnCount >=
|
|
2014
|
-
// Escalating signal: AI has read enough, must act now
|
|
2313
|
+
else if (readFileTurnCount >= 3) {
|
|
2015
2314
|
history.push({
|
|
2016
2315
|
role: 'system',
|
|
2017
|
-
content: `[
|
|
2316
|
+
content: `[CONTEXT READY] ${taskInstruction}`,
|
|
2018
2317
|
});
|
|
2019
2318
|
}
|
|
2020
2319
|
else {
|
|
2021
|
-
|
|
2022
|
-
|
|
2320
|
+
history.push({
|
|
2321
|
+
role: 'system',
|
|
2322
|
+
content: `[FILE CONTENT READY] ${taskInstruction}`,
|
|
2323
|
+
});
|
|
2023
2324
|
}
|
|
2024
2325
|
skipInput = true;
|
|
2025
2326
|
result = { text: '', lines: [] };
|
|
@@ -2089,11 +2390,19 @@ export async function startRepl(cwd) {
|
|
|
2089
2390
|
if (!skipInput) {
|
|
2090
2391
|
readFileTurnCount = 0; // reset loop counter for each new user message
|
|
2091
2392
|
forcedEditMode = false; // reset forced edit mode for each new user message
|
|
2393
|
+
useProModel = false; // reset model selection for each new user message
|
|
2092
2394
|
// ── Refresh project context (files may have changed since last turn) ──
|
|
2093
2395
|
history[0] = buildSystemMsg(activeCwd);
|
|
2094
2396
|
// ── Clear stale operational messages left from previous auto-continue turns ──
|
|
2095
2397
|
// They pollute future user messages when prepareMessages merges consecutive roles.
|
|
2096
|
-
const STALE_PREFIXES = [
|
|
2398
|
+
const STALE_PREFIXES = [
|
|
2399
|
+
'[File updated]', '[SYSTEM LIMIT]', '[BLOCKED]', 'Please proceed with the task',
|
|
2400
|
+
'[FORCED EDIT MODE]', '[ACTION REQUIRED]', '[Auto-reading',
|
|
2401
|
+
'[Web Designer mode', 'You have done ', 'You now have the file content',
|
|
2402
|
+
'You described the steps but', 'Your previous response was cut off',
|
|
2403
|
+
'[CONTEXT READY]', '[TARGET SECTION', '[FILE CONTENT READY]',
|
|
2404
|
+
'The user\'s exact request was:',
|
|
2405
|
+
];
|
|
2097
2406
|
for (let i = history.length - 1; i > 0; i--) {
|
|
2098
2407
|
if (history[i].role === 'system' &&
|
|
2099
2408
|
STALE_PREFIXES.some(p => history[i].content.startsWith(p))) {
|
|
@@ -2106,7 +2415,6 @@ export async function startRepl(cwd) {
|
|
|
2106
2415
|
}
|
|
2107
2416
|
// ── Auto-inject mentioned file contents ──────────────────────
|
|
2108
2417
|
// Files ≤ 3000 lines: full raw content injected — AI has exact text for <find>, no reads needed.
|
|
2109
|
-
// Files > 3000 lines: first 300 lines preview + AI does ONE <read_file> to get target section.
|
|
2110
2418
|
const FILE_PATTERN = /[\w\-.\/\\]+\.\w{2,5}/g;
|
|
2111
2419
|
const mentioned = [...new Set(line.match(FILE_PATTERN) ?? [])];
|
|
2112
2420
|
let fileContext = '';
|
|
@@ -2116,20 +2424,14 @@ export async function startRepl(cwd) {
|
|
|
2116
2424
|
if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) {
|
|
2117
2425
|
try {
|
|
2118
2426
|
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
2119
|
-
const
|
|
2120
|
-
const lineCount =
|
|
2427
|
+
const fileLines = content.split('\n');
|
|
2428
|
+
const lineCount = fileLines.length;
|
|
2121
2429
|
if (lineCount <= FULL_INJECT_LINES) {
|
|
2122
|
-
fileContext += `\n\n[File: ${fname} —
|
|
2123
|
-
`⚠ Complete file is here — do NOT use <read_file> for this file. Use <edit_file> directly.\n\n` +
|
|
2124
|
-
content;
|
|
2430
|
+
fileContext += `\n\n[File: ${fname} — ${lineCount} lines — FULL]\n` + content;
|
|
2125
2431
|
}
|
|
2126
2432
|
else {
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
fileContext += `\n\n[File: ${fname} — ${lineCount} lines — first 300 lines shown]\n` +
|
|
2130
|
-
`⚠ Use <read_file path="${fname}"/> (no lines=) to get full content, then edit.\n\n` +
|
|
2131
|
-
preview +
|
|
2132
|
-
`\n\n... (${lineCount - 300} more lines)`;
|
|
2433
|
+
fileContext += `\n\n[File: ${fname} — ${lineCount} lines]\n` +
|
|
2434
|
+
`Use: <search_code pattern="TERM" path="${fname}"/> → <read_file path="${fname}"/> → <edit_file>\n`;
|
|
2133
2435
|
}
|
|
2134
2436
|
}
|
|
2135
2437
|
catch { /* skip unreadable */ }
|
|
@@ -2237,7 +2539,8 @@ export async function startRepl(cwd) {
|
|
|
2237
2539
|
executedInlineOps.add(inlineOpFingerprint(op));
|
|
2238
2540
|
}
|
|
2239
2541
|
else {
|
|
2240
|
-
const
|
|
2542
|
+
const previewLines = (op.type === 'write' || op.type === 'edit') ? printDiffPreview(op, activeCwd) : 0;
|
|
2543
|
+
const allowed = await askPermission(label, key, alwaysAllowed, op.type === 'run', previewLines > 0, previewLines);
|
|
2241
2544
|
executedInlineOps.add(inlineOpFingerprint(op));
|
|
2242
2545
|
if (!allowed) {
|
|
2243
2546
|
if (op.path)
|
|
@@ -2264,19 +2567,43 @@ export async function startRepl(cwd) {
|
|
|
2264
2567
|
process.stdout.write('\r\x1b[K');
|
|
2265
2568
|
printOpResult(opResult);
|
|
2266
2569
|
if (opResult.type === 'error' && op.type !== 'run') {
|
|
2267
|
-
// If text not found, include current file content so AI can retry accurately
|
|
2268
2570
|
let errMsg = `[Operation failed — ${label}]: ${opResult.message ?? 'Unknown error'}`;
|
|
2269
2571
|
if (opResult.message?.includes('not found') && op.path) {
|
|
2270
2572
|
try {
|
|
2271
2573
|
const fp = path.resolve(activeCwd, op.path);
|
|
2272
2574
|
if (fs.existsSync(fp)) {
|
|
2273
2575
|
const cur = fs.readFileSync(fp, 'utf-8');
|
|
2274
|
-
const
|
|
2275
|
-
|
|
2276
|
-
|
|
2576
|
+
const curLines = cur.split('\n');
|
|
2577
|
+
const nLines = curLines.length;
|
|
2578
|
+
const isDeleteOp = !op.replace || op.replace.trim() === '';
|
|
2579
|
+
// Auto-search for what AI was trying to find — inject exact block
|
|
2580
|
+
const findLines = (op.find ?? '').split('\n').map(l => l.replace(/^\s*\d+\s*[│|:]\s?/, '').trim()).filter(Boolean);
|
|
2581
|
+
const searchTerm = findLines[0]?.slice(0, 60) ?? '';
|
|
2582
|
+
let foundAt = -1;
|
|
2583
|
+
if (searchTerm.length > 4) {
|
|
2584
|
+
for (let si = 0; si < curLines.length; si++) {
|
|
2585
|
+
if (curLines[si].toLowerCase().includes(searchTerm.toLowerCase())) {
|
|
2586
|
+
foundAt = si;
|
|
2587
|
+
break;
|
|
2588
|
+
}
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
if (foundAt >= 0) {
|
|
2592
|
+
// Found the approximate location — inject surrounding block
|
|
2593
|
+
const start = Math.max(0, foundAt - 2);
|
|
2594
|
+
const end = Math.min(curLines.length - 1, foundAt + 100);
|
|
2595
|
+
const block = curLines.slice(start, end + 1).join('\n');
|
|
2596
|
+
errMsg += `\n\n[FOUND near line ${foundAt + 1} in ${op.path}]:\n${block}\n`;
|
|
2597
|
+
errMsg += isDeleteOp
|
|
2598
|
+
? `\nDELETE FIX: Copy the EXACT full container (from its opening <div>/<section> tag through the matching closing tag) from above. Put it in <edit_file path="${op.path}"><find>EXACT_TEXT</find><replace></replace>. Text must match CHARACTER FOR CHARACTER.`
|
|
2599
|
+
: `\nEDIT FIX: Copy the EXACT text you want to change from above. Use <edit_file path="${op.path}"><find>EXACT_TEXT</find><replace>NEW_TEXT</replace>.`;
|
|
2600
|
+
}
|
|
2601
|
+
else if (nLines <= 400) {
|
|
2602
|
+
errMsg += `\n\nFull content of ${op.path} (${nLines} lines):\n${cur}\n\nFIX: Copy the EXACT text from above into <find>. Character-for-character, no changes.`;
|
|
2277
2603
|
}
|
|
2278
2604
|
else {
|
|
2279
|
-
|
|
2605
|
+
const safe = searchTerm.replace(/['"<>{}()\[\]]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 35);
|
|
2606
|
+
errMsg += `\n\nFIX STEPS:\n1. <search_code pattern="${safe}" path="${op.path}"/>\n2. <read_file path="${op.path}" lines="N-M"/> (use line numbers from search)\n3. Copy EXACT text into <find> — no modifications, no line numbers`;
|
|
2280
2607
|
}
|
|
2281
2608
|
}
|
|
2282
2609
|
}
|
|
@@ -2516,11 +2843,23 @@ export async function startRepl(cwd) {
|
|
|
2516
2843
|
// Pattern: response has numbered items like "2. something" or "3. something"
|
|
2517
2844
|
// but very few actual file operation tags were output → force continuation
|
|
2518
2845
|
if (!readFileContinue && !streamCancelled) {
|
|
2846
|
+
const hasAnyReadOp = allOps.some(op => op.type === 'read_file' || op.type === 'read_folder' || op.type === 'search_code');
|
|
2847
|
+
const writeOps = allOps.filter(op => op.type !== 'read_file' && op.type !== 'read_folder' && op.type !== 'search_code').length;
|
|
2519
2848
|
const plannedSteps = (normalized.match(/^\s*[2-9]\.\s+\S/mg) ?? []).length;
|
|
2520
|
-
|
|
2521
|
-
if (plannedSteps >= 1 &&
|
|
2522
|
-
history.push({ role: 'system', content: 'You described the steps but did not output any <edit_file> or <write_file> tags
|
|
2849
|
+
// AI described a plan (numbered steps 2+) but did nothing at all — force execution
|
|
2850
|
+
if (plannedSteps >= 1 && writeOps === 0 && !hasAnyReadOp) {
|
|
2851
|
+
history.push({ role: 'system', content: 'You described the steps but did not output any tags. Output the actual <edit_file> or <write_file> tags NOW to apply the changes. No explanation — just the tags.' });
|
|
2523
2852
|
readFileContinue = true;
|
|
2853
|
+
// AI gave a non-trivial response with zero operations of any kind
|
|
2854
|
+
}
|
|
2855
|
+
else if (writeOps === 0 && allOps.length === 0 && normalized.trim().length > 200) {
|
|
2856
|
+
const hasCodeFence = normalized.includes('```');
|
|
2857
|
+
const hasStepList = (normalized.match(/^\s*\d+[.)]\s+\S/mg) ?? []).length >= 2;
|
|
2858
|
+
const hasWouldNeed = /(?:should|would need to|need to|you(?:'d)? need to|we(?:'d)? need to)\s+(?:edit|modify|update|change|create|add|remove)/i.test(normalized);
|
|
2859
|
+
if ((hasCodeFence || hasStepList) && hasWouldNeed) {
|
|
2860
|
+
history.push({ role: 'system', content: 'You explained what to do but did not output any tags. Apply the changes NOW using <edit_file> or <write_file> tags. Do not re-explain — just output the tags.' });
|
|
2861
|
+
readFileContinue = true;
|
|
2862
|
+
}
|
|
2524
2863
|
}
|
|
2525
2864
|
}
|
|
2526
2865
|
const readOps = allOps.filter(op => op.type === 'read_file' || op.type === 'read_folder' || op.type === 'search_code');
|
|
@@ -2608,6 +2947,22 @@ export async function startRepl(cwd) {
|
|
|
2608
2947
|
const lastLine = (res.output ?? '').split('\n').filter(Boolean).pop() ?? '';
|
|
2609
2948
|
rightCol = chalk.hex('#6b7280')(lastLine.startsWith('Total:') ? lastLine.slice(7).trim() : `${ms}ms`);
|
|
2610
2949
|
displayLabel = `"${op.pattern}"`;
|
|
2950
|
+
// Auto-read section around first match so AI has exact text without another round-trip
|
|
2951
|
+
const firstMatchLine = (res.output ?? '').match(/^([^\n:]+):(\d+):/m);
|
|
2952
|
+
if (firstMatchLine) {
|
|
2953
|
+
const matchFile = firstMatchLine[1].trim();
|
|
2954
|
+
const matchLineNum = parseInt(firstMatchLine[2]);
|
|
2955
|
+
if (!isNaN(matchLineNum) && matchFile) {
|
|
2956
|
+
const readStart = Math.max(1, matchLineNum - 3);
|
|
2957
|
+
const readEnd = matchLineNum + 280;
|
|
2958
|
+
const autoKey = `[File content — ${matchFile}][lines=${readStart}-${readEnd}]`;
|
|
2959
|
+
const alreadyHaveIt = history.slice(-10).some(m => m.role === 'system' && m.content.includes(`[File content — ${matchFile}]`));
|
|
2960
|
+
if (!alreadyHaveIt) {
|
|
2961
|
+
validOps.push({ type: 'read_file', path: matchFile, lines: `${readStart}-${readEnd}` });
|
|
2962
|
+
void autoKey;
|
|
2963
|
+
}
|
|
2964
|
+
}
|
|
2965
|
+
}
|
|
2611
2966
|
}
|
|
2612
2967
|
else if (op.type === 'read_folder') {
|
|
2613
2968
|
ctxLabel = `[Folder contents — "${op.path}"]:\n${res.output ?? ''}`;
|
|
@@ -2708,7 +3063,8 @@ export async function startRepl(cwd) {
|
|
|
2708
3063
|
continue;
|
|
2709
3064
|
}
|
|
2710
3065
|
}
|
|
2711
|
-
const
|
|
3066
|
+
const previewLines2 = (op.type === 'write' || op.type === 'edit') ? printDiffPreview(op, activeCwd) : 0;
|
|
3067
|
+
const allowed = await askPermission(label, key, alwaysAllowed, op.type === 'run', previewLines2 > 0, previewLines2);
|
|
2712
3068
|
if (!allowed) {
|
|
2713
3069
|
if (op.path)
|
|
2714
3070
|
skippedPaths.add(op.path);
|
|
@@ -2737,7 +3093,47 @@ export async function startRepl(cwd) {
|
|
|
2737
3093
|
printOpResult(opResult);
|
|
2738
3094
|
// Collect file operation errors for AI feedback
|
|
2739
3095
|
if (opResult.type === 'error' && op.type !== 'run') {
|
|
2740
|
-
|
|
3096
|
+
let errMsg = `[Operation failed — ${label}]: ${opResult.message ?? 'Unknown error'}`;
|
|
3097
|
+
if (opResult.message?.includes('not found') && op.path) {
|
|
3098
|
+
try {
|
|
3099
|
+
const fp = path.resolve(activeCwd, op.path);
|
|
3100
|
+
if (fs.existsSync(fp)) {
|
|
3101
|
+
const cur = fs.readFileSync(fp, 'utf-8');
|
|
3102
|
+
const curLines = cur.split('\n');
|
|
3103
|
+
const nLines = curLines.length;
|
|
3104
|
+
const isDeleteOp = !op.replace || op.replace.trim() === '';
|
|
3105
|
+
// Auto-search for approximate location of what AI tried to find
|
|
3106
|
+
const findLines = (op.find ?? '').split('\n').map(l => l.replace(/^\s*\d+\s*[│|:]\s?/, '').trim()).filter(Boolean);
|
|
3107
|
+
const searchTerm = findLines[0]?.slice(0, 60) ?? '';
|
|
3108
|
+
let foundAt = -1;
|
|
3109
|
+
if (searchTerm.length > 4) {
|
|
3110
|
+
for (let si = 0; si < curLines.length; si++) {
|
|
3111
|
+
if (curLines[si].toLowerCase().includes(searchTerm.toLowerCase())) {
|
|
3112
|
+
foundAt = si;
|
|
3113
|
+
break;
|
|
3114
|
+
}
|
|
3115
|
+
}
|
|
3116
|
+
}
|
|
3117
|
+
if (foundAt >= 0) {
|
|
3118
|
+
const start = Math.max(0, foundAt - 2);
|
|
3119
|
+
const end = Math.min(curLines.length - 1, foundAt + 100);
|
|
3120
|
+
errMsg += `\n\n[FOUND near line ${foundAt + 1}]:\n${curLines.slice(start, end + 1).join('\n')}\n`;
|
|
3121
|
+
errMsg += isDeleteOp
|
|
3122
|
+
? `\nDELETE FIX: Copy the EXACT full container from above into <edit_file><find>EXACT</find><replace></replace>.`
|
|
3123
|
+
: `\nEDIT FIX: Copy EXACT text from above into <edit_file><find>EXACT</find><replace>NEW</replace>.`;
|
|
3124
|
+
}
|
|
3125
|
+
else if (nLines <= 400) {
|
|
3126
|
+
errMsg += `\n\nFull file (${nLines} lines):\n${cur}\n\nFIX: Copy EXACT text from above into <find>.`;
|
|
3127
|
+
}
|
|
3128
|
+
else {
|
|
3129
|
+
const safe = searchTerm.replace(/['"<>{}()\[\]]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 35);
|
|
3130
|
+
errMsg += `\n\nFIX: <search_code pattern="${safe}" path="${op.path}"/> → <read_file lines="N-M"/> → copy EXACT text into <find>.`;
|
|
3131
|
+
}
|
|
3132
|
+
}
|
|
3133
|
+
}
|
|
3134
|
+
catch { /* ignore */ }
|
|
3135
|
+
}
|
|
3136
|
+
fileOpErrors.push(errMsg);
|
|
2741
3137
|
}
|
|
2742
3138
|
if (op.type === 'run' && op.command) {
|
|
2743
3139
|
if (opResult.type === 'run') {
|
|
@@ -2805,7 +3201,7 @@ export async function startRepl(cwd) {
|
|
|
2805
3201
|
: raw;
|
|
2806
3202
|
printError(msg);
|
|
2807
3203
|
resolve();
|
|
2808
|
-
}, streamAbort.signal);
|
|
3204
|
+
}, streamAbort.signal, useProModel ? PRO_MODEL : FLASH_MODEL);
|
|
2809
3205
|
});
|
|
2810
3206
|
// ── Remove streaming key listener ─────────────────────────
|
|
2811
3207
|
process.stdin.removeListener('data', onStreamKey);
|
package/dist/services/ai.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
const AI_PROXY = 'https://dravix.app/ai-proxy.php';
|
|
2
2
|
const PROMPT_URL = 'https://dravix.app/cli-prompt.php';
|
|
3
|
+
export const FLASH_MODEL = 'deepseek/deepseek-v4-flash'; // fast, cheap — default for all tasks
|
|
4
|
+
export const PRO_MODEL = 'deepseek/deepseek-r1'; // high-reasoning — used after errors/retries
|
|
3
5
|
export async function fetchSystemPrompt(token) {
|
|
4
6
|
try {
|
|
5
7
|
const res = await fetch(`${PROMPT_URL}?token=${encodeURIComponent(token)}`, {
|
|
@@ -21,13 +23,14 @@ export async function fetchSystemPrompt(token) {
|
|
|
21
23
|
const TIMEOUT_MS = 300_000; // 5 min — AI may generate large responses
|
|
22
24
|
const CHUNK_TIMEOUT_MS = 60_000; // 1 min without any chunk = abort
|
|
23
25
|
const MAX_CONTINUATIONS = 5; // auto-continue up to 5 times on length cutoff
|
|
26
|
+
const MAX_429_RETRIES = 3; // retry on rate-limit with exponential backoff
|
|
24
27
|
// Convert history to OpenAI-compatible format:
|
|
25
28
|
// Only the first message can be role:system — everything else must alternate user/assistant.
|
|
26
29
|
// Mid-conversation system messages (file contents, /add files) become role:user.
|
|
27
30
|
// Consecutive user/system-as-user messages are merged to maintain proper alternation.
|
|
28
31
|
function prepareMessages(messages) {
|
|
29
32
|
// Trim context: if total chars exceed ~180k (~45k tokens), drop oldest non-system messages
|
|
30
|
-
const MAX_CHARS =
|
|
33
|
+
const MAX_CHARS = 150_000;
|
|
31
34
|
let totalChars = messages.reduce((s, m) => s + String(m.content).length, 0);
|
|
32
35
|
const trimmed = [...messages];
|
|
33
36
|
while (totalChars > MAX_CHARS && trimmed.length > 4) {
|
|
@@ -51,9 +54,9 @@ function prepareMessages(messages) {
|
|
|
51
54
|
}
|
|
52
55
|
return merged;
|
|
53
56
|
}
|
|
54
|
-
async function doSingleRequest(messages, token, abort, onChunk) {
|
|
57
|
+
async function doSingleRequest(messages, token, abort, onChunk, model = FLASH_MODEL) {
|
|
55
58
|
const requestTimer = setTimeout(() => abort.abort(), TIMEOUT_MS);
|
|
56
|
-
|
|
59
|
+
const attemptFetch = async (attempt) => {
|
|
57
60
|
const res = await fetch(AI_PROXY, {
|
|
58
61
|
method: 'POST',
|
|
59
62
|
signal: abort.signal,
|
|
@@ -64,13 +67,22 @@ async function doSingleRequest(messages, token, abort, onChunk) {
|
|
|
64
67
|
},
|
|
65
68
|
body: JSON.stringify({
|
|
66
69
|
provider: 'openrouter',
|
|
67
|
-
model
|
|
70
|
+
model,
|
|
68
71
|
messages: prepareMessages(messages),
|
|
69
72
|
stream: true,
|
|
70
73
|
temperature: 0.1,
|
|
71
74
|
max_tokens: 16384,
|
|
72
75
|
}),
|
|
73
76
|
});
|
|
77
|
+
// Retry on rate-limit with exponential backoff (2s, 4s, 8s)
|
|
78
|
+
if (res.status === 429 && attempt < MAX_429_RETRIES - 1) {
|
|
79
|
+
await new Promise(r => setTimeout(r, 2000 * Math.pow(2, attempt)));
|
|
80
|
+
return attemptFetch(attempt + 1);
|
|
81
|
+
}
|
|
82
|
+
return res;
|
|
83
|
+
};
|
|
84
|
+
try {
|
|
85
|
+
const res = await attemptFetch(0);
|
|
74
86
|
if (!res.ok || !res.body) {
|
|
75
87
|
const text = await res.text().catch(() => '');
|
|
76
88
|
let msg = `HTTP ${res.status}`;
|
|
@@ -159,7 +171,7 @@ async function doSingleRequest(messages, token, abort, onChunk) {
|
|
|
159
171
|
clearTimeout(requestTimer);
|
|
160
172
|
}
|
|
161
173
|
}
|
|
162
|
-
export async function streamChat(messages, token, onChunk, onDone, onError, cancelSignal) {
|
|
174
|
+
export async function streamChat(messages, token, onChunk, onDone, onError, cancelSignal, model = FLASH_MODEL) {
|
|
163
175
|
const abort = new AbortController();
|
|
164
176
|
const forwardCancel = () => abort.abort();
|
|
165
177
|
cancelSignal?.addEventListener('abort', forwardCancel);
|
|
@@ -170,7 +182,7 @@ export async function streamChat(messages, token, onChunk, onDone, onError, canc
|
|
|
170
182
|
onDone();
|
|
171
183
|
return;
|
|
172
184
|
}
|
|
173
|
-
const { finishReason, response } = await doSingleRequest(currentMessages, token, abort, onChunk);
|
|
185
|
+
const { finishReason, response } = await doSingleRequest(currentMessages, token, abort, onChunk, model);
|
|
174
186
|
if (finishReason === 'length' && attempt < MAX_CONTINUATIONS) {
|
|
175
187
|
// Token limit hit — auto-continue seamlessly, no visible break to user
|
|
176
188
|
const tail = response.slice(-400);
|
|
@@ -107,39 +107,45 @@ export function computeDiff(oldText, newText) {
|
|
|
107
107
|
}
|
|
108
108
|
export function parseOps(text) {
|
|
109
109
|
const ops = [];
|
|
110
|
-
|
|
110
|
+
// Q = accept both " and ' for attribute values
|
|
111
|
+
const Q = `["']`;
|
|
112
|
+
const AV = (q) => `[^${q}]`;
|
|
113
|
+
const writeRe = /<write_file\s+path=(["'])([^"']+)\1>([\s\S]*?)<\/write_file>/g;
|
|
111
114
|
let m;
|
|
112
115
|
while ((m = writeRe.exec(text)) !== null) {
|
|
113
|
-
ops.push({ type: 'write', path: m[
|
|
116
|
+
ops.push({ type: 'write', path: m[2], content: m[3].replace(/^\n/, '') });
|
|
114
117
|
}
|
|
115
|
-
const editRe = /<edit_file\s+path="([^"]+)
|
|
118
|
+
const editRe = /<edit_file\s+path=(["'])([^"']+)\1>[\s\S]*?<find>([\s\S]*?)<\/find>[\s\S]*?<replace>([\s\S]*?)<\/replace>[\s\S]*?<\/edit_file>/g;
|
|
116
119
|
while ((m = editRe.exec(text)) !== null) {
|
|
117
|
-
ops.push({ type: 'edit', path: m[
|
|
120
|
+
ops.push({ type: 'edit', path: m[2], find: m[3], replace: m[4] });
|
|
118
121
|
}
|
|
119
|
-
const mkdirRe = /<create_folder\s+path="([^"]+)
|
|
122
|
+
const mkdirRe = /<create_folder\s+path=(["'])([^"']+)\1\s*(?:\/>|><\/create_folder>)/g;
|
|
120
123
|
while ((m = mkdirRe.exec(text)) !== null) {
|
|
121
|
-
ops.push({ type: 'mkdir', path: m[
|
|
124
|
+
ops.push({ type: 'mkdir', path: m[2] });
|
|
122
125
|
}
|
|
123
|
-
const deleteRe = /<delete_file\s+path="([^"]+)
|
|
126
|
+
const deleteRe = /<delete_file\s+path=(["'])([^"']+)\1\s*(?:\/>|><\/delete_file>)/g;
|
|
124
127
|
while ((m = deleteRe.exec(text)) !== null) {
|
|
125
|
-
ops.push({ type: 'delete', path: m[
|
|
128
|
+
ops.push({ type: 'delete', path: m[2] });
|
|
126
129
|
}
|
|
127
|
-
const runRe = /<run_command(?:\s+cwd="([^"]*)
|
|
130
|
+
const runRe = /<run_command(?:\s+cwd=(["'])([^"']*)\2)?>([\s\S]*?)<\/run_command>/g;
|
|
128
131
|
while ((m = runRe.exec(text)) !== null) {
|
|
129
|
-
ops.push({ type: 'run', command: m[
|
|
132
|
+
ops.push({ type: 'run', command: m[3].trim(), workdir: m[2] || undefined });
|
|
130
133
|
}
|
|
131
|
-
const readRe = /<read_file\s+path="([^"]+)
|
|
134
|
+
const readRe = /<read_file\s+path=(["'])([^"']+)\1(?:\s+lines=(["'])([^"']+)\3)?\s*(?:\/>|><\/read_file>)/g;
|
|
132
135
|
while ((m = readRe.exec(text)) !== null) {
|
|
133
|
-
ops.push({ type: 'read_file', path: m[
|
|
136
|
+
ops.push({ type: 'read_file', path: m[2], lines: m[4] || undefined });
|
|
134
137
|
}
|
|
135
|
-
const readFolderRe = /<read_folder\s+path="([^"]+)
|
|
138
|
+
const readFolderRe = /<read_folder\s+path=(["'])([^"']+)\1\s*(?:\/>|><\/read_folder>)/g;
|
|
136
139
|
while ((m = readFolderRe.exec(text)) !== null) {
|
|
137
|
-
ops.push({ type: 'read_folder', path: m[
|
|
140
|
+
ops.push({ type: 'read_folder', path: m[2] });
|
|
138
141
|
}
|
|
139
|
-
const searchRe = /<search_code\s+pattern="([^"]+)
|
|
142
|
+
const searchRe = /<search_code\s+pattern=(["'])([^"']+)\1(?:\s+path=(["'])([^"']*)\3)?\s*(?:\/>|><\/search_code>)/g;
|
|
140
143
|
while ((m = searchRe.exec(text)) !== null) {
|
|
141
|
-
ops.push({ type: 'search_code', pattern: m[
|
|
144
|
+
ops.push({ type: 'search_code', pattern: m[2], path: m[4] || undefined });
|
|
142
145
|
}
|
|
146
|
+
// suppress unused-variable warnings for Q / AV helpers used only for readability
|
|
147
|
+
void Q;
|
|
148
|
+
void AV;
|
|
143
149
|
return ops;
|
|
144
150
|
}
|
|
145
151
|
export async function executeSingleOp(op, cwd, onStage) {
|
|
@@ -194,43 +200,112 @@ export async function executeSingleOp(op, cwd, onStage) {
|
|
|
194
200
|
}
|
|
195
201
|
stage(`Reading ${resolvedPath}`);
|
|
196
202
|
const oldContent = fs.readFileSync(fullPath, 'utf-8');
|
|
197
|
-
// ──
|
|
203
|
+
// ── Multi-stage robust match ────────────────────────────────────────────
|
|
198
204
|
let actualFind = op.find;
|
|
199
205
|
if (!oldContent.includes(op.find)) {
|
|
200
|
-
// Try matching with trailing-whitespace normalization (most common AI copy error)
|
|
201
206
|
const oldLines = oldContent.split('\n');
|
|
202
|
-
const
|
|
207
|
+
const rawFind = op.find.split('\n');
|
|
208
|
+
// Try to find findLines inside oldLines — compare with trimEnd per line
|
|
209
|
+
const tryMatch = (findLines) => {
|
|
210
|
+
if (findLines.length === 0 || findLines.length > oldLines.length)
|
|
211
|
+
return -1;
|
|
212
|
+
outer2: for (let i = 0; i <= oldLines.length - findLines.length; i++) {
|
|
213
|
+
for (let j = 0; j < findLines.length; j++) {
|
|
214
|
+
if (oldLines[i + j].trimEnd() !== findLines[j].trimEnd())
|
|
215
|
+
continue outer2;
|
|
216
|
+
}
|
|
217
|
+
return i;
|
|
218
|
+
}
|
|
219
|
+
return -1;
|
|
220
|
+
};
|
|
221
|
+
// Strip " 42 │ " / "42: " / "42| " line-number prefixes AI copies from read_file
|
|
222
|
+
const stripLineNums = (lines) => lines.map(l => l.replace(/^\s*\d+\s*[│|:]\s?/, ''));
|
|
223
|
+
// Trim leading and trailing blank-only lines
|
|
224
|
+
const trimBlanks = (lines) => {
|
|
225
|
+
let s = 0, e = lines.length - 1;
|
|
226
|
+
while (s <= e && !lines[s].trim())
|
|
227
|
+
s++;
|
|
228
|
+
while (e >= s && !lines[e].trim())
|
|
229
|
+
e--;
|
|
230
|
+
return s <= e ? lines.slice(s, e + 1) : [];
|
|
231
|
+
};
|
|
203
232
|
let matchStart = -1;
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
233
|
+
let matchLen = rawFind.length;
|
|
234
|
+
// Stage 2: trimEnd normalization
|
|
235
|
+
matchStart = tryMatch(rawFind);
|
|
236
|
+
// Stage 3: strip line-number prefixes
|
|
237
|
+
if (matchStart < 0) {
|
|
238
|
+
const stripped = stripLineNums(rawFind);
|
|
239
|
+
matchStart = tryMatch(stripped);
|
|
240
|
+
if (matchStart >= 0)
|
|
241
|
+
matchLen = stripped.length;
|
|
242
|
+
}
|
|
243
|
+
// Stage 4: trim surrounding blank lines
|
|
244
|
+
if (matchStart < 0) {
|
|
245
|
+
const trimmed = trimBlanks(rawFind);
|
|
246
|
+
if (trimmed.length > 0 && trimmed.length < rawFind.length) {
|
|
247
|
+
matchStart = tryMatch(trimmed);
|
|
248
|
+
if (matchStart >= 0)
|
|
249
|
+
matchLen = trimmed.length;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
// Stage 5: strip line numbers + trim blanks
|
|
253
|
+
if (matchStart < 0) {
|
|
254
|
+
const trimmed = trimBlanks(stripLineNums(rawFind));
|
|
255
|
+
if (trimmed.length > 0) {
|
|
256
|
+
matchStart = tryMatch(trimmed);
|
|
257
|
+
if (matchStart >= 0)
|
|
258
|
+
matchLen = trimmed.length;
|
|
208
259
|
}
|
|
209
|
-
matchStart = i;
|
|
210
|
-
break;
|
|
211
260
|
}
|
|
212
261
|
if (matchStart >= 0) {
|
|
213
|
-
// Rebuild actualFind from the REAL file lines so replaceAll
|
|
214
|
-
actualFind = oldLines.slice(matchStart, matchStart +
|
|
262
|
+
// Rebuild actualFind from the REAL file lines so replaceAll is exact
|
|
263
|
+
actualFind = oldLines.slice(matchStart, matchStart + matchLen).join('\n');
|
|
215
264
|
}
|
|
216
265
|
else {
|
|
217
266
|
// Truly not found — give AI a useful hint
|
|
218
|
-
const firstLine =
|
|
267
|
+
const firstLine = rawFind.map(l => l.replace(/^\s*\d+\s*[│|:]\s?/, '')).find(l => l.trim()) ?? '';
|
|
268
|
+
const safePattern = firstLine.replace(/['"<>]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 40);
|
|
219
269
|
let hintLine = -1;
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
270
|
+
const searchKey = firstLine.trim().slice(0, Math.min(30, firstLine.trim().length));
|
|
271
|
+
if (searchKey.length > 4) {
|
|
272
|
+
for (let i = 0; i < oldLines.length; i++) {
|
|
273
|
+
if (oldLines[i].includes(searchKey)) {
|
|
274
|
+
hintLine = i + 1;
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
224
277
|
}
|
|
225
278
|
}
|
|
226
279
|
const hint = hintLine > 0
|
|
227
|
-
? ` First line found near line ${hintLine} but block
|
|
228
|
-
|
|
280
|
+
? ` First line found near line ${hintLine} but block did not match exactly.` +
|
|
281
|
+
` Use <read_file path="${resolvedPath}" lines="${Math.max(1, hintLine - 2)}-${hintLine + 25}"/> to get the EXACT text, then copy it verbatim (no line numbers) into <find>.`
|
|
282
|
+
: ` Text not found anywhere in ${resolvedPath}.` +
|
|
283
|
+
(safePattern.length > 3 ? ` Try <search_code pattern="${safePattern}" path="${resolvedPath}"/> to locate it.` : '') +
|
|
284
|
+
` Then use <read_file lines="N-M"/> to read that section and copy the EXACT text into <find>.`;
|
|
229
285
|
return { type: 'error', message: `Text not found in ${resolvedPath}.${hint}` };
|
|
230
286
|
}
|
|
231
287
|
}
|
|
288
|
+
// Count occurrences — replaceAll on non-unique text corrupts multiple locations
|
|
289
|
+
let occurrenceCount = 0;
|
|
290
|
+
let scanPos = 0;
|
|
291
|
+
while ((scanPos = oldContent.indexOf(actualFind, scanPos)) !== -1) {
|
|
292
|
+
occurrenceCount++;
|
|
293
|
+
scanPos += actualFind.length;
|
|
294
|
+
}
|
|
295
|
+
if (occurrenceCount > 1) {
|
|
296
|
+
const safePattern = actualFind
|
|
297
|
+
.split('\n').find(l => l.trim())?.replace(/['"<>{}()\[\]]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 40) ?? '';
|
|
298
|
+
return {
|
|
299
|
+
type: 'error',
|
|
300
|
+
message: `Found ${occurrenceCount} identical occurrences in ${resolvedPath}. ` +
|
|
301
|
+
`The <find> text must match EXACTLY ONE location. ` +
|
|
302
|
+
`Add more surrounding lines to make it unique, or use ` +
|
|
303
|
+
`<read_file path="${resolvedPath}" lines="N-M"/> to get the exact block.` +
|
|
304
|
+
(safePattern.length > 4 ? ` (First line: "${safePattern}")` : ''),
|
|
305
|
+
};
|
|
306
|
+
}
|
|
232
307
|
stage(`Applying ${resolvedPath}`);
|
|
233
|
-
const newContent = oldContent.
|
|
308
|
+
const newContent = oldContent.replace(actualFind, op.replace);
|
|
234
309
|
// No actual change — skip write and return skipped
|
|
235
310
|
if (newContent === oldContent)
|
|
236
311
|
return { type: 'skipped', path: resolvedPath };
|
|
@@ -427,9 +502,9 @@ export async function executeSingleOp(op, cwd, onStage) {
|
|
|
427
502
|
const total = allLines.length;
|
|
428
503
|
// Files under 6000 lines → always return full content so AI has complete context
|
|
429
504
|
const LARGE_FILE_THRESHOLD = 6000;
|
|
430
|
-
// Only truly huge files (>6000 lines) get paginated — return first
|
|
505
|
+
// Only truly huge files (>6000 lines) get paginated — return first 2000 lines + hint
|
|
431
506
|
if (!op.lines && total > LARGE_FILE_THRESHOLD) {
|
|
432
|
-
const PAGE =
|
|
507
|
+
const PAGE = 2000;
|
|
433
508
|
const preview = allLines.slice(0, PAGE).map((l, i) => `${String(i + 1).padStart(4)} │ ${l}`).join('\n');
|
|
434
509
|
const pages = Math.ceil(total / PAGE);
|
|
435
510
|
const output = `[File: ${resolvedPath} — ${total} lines — page 1/${pages}]\n` +
|