@taj-special/dravix-code 1.1.5 → 1.1.7
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 +463 -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,83 @@ 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
|
+
### ACTIVATE Web Designer mode (when user asks to build/design a website or UI):
|
|
59
|
+
<design_mode>
|
|
60
|
+
Modern responsive layout
|
|
61
|
+
Clean typography and spacing
|
|
62
|
+
Professional color palette
|
|
63
|
+
Smooth animations and transitions
|
|
64
|
+
Mobile-first design
|
|
65
|
+
</design_mode>
|
|
66
|
+
List the relevant design features inside the tag based on what the user wants to build. This activates the full Web Designer skill set.
|
|
67
|
+
|
|
68
|
+
## RULES:
|
|
69
|
+
- File content labeled [File: name — FULL] is already provided — use it, do NOT read again.
|
|
70
|
+
- Copy <find> text CHARACTER BY CHARACTER from the actual file — never write from memory.
|
|
71
|
+
- Include enough surrounding lines so <find> matches exactly ONE location.
|
|
72
|
+
- DELETE = <replace></replace> with nothing inside — if you put anything inside, it is NOT a delete.
|
|
73
|
+
- "Found N identical occurrences" → add more lines to <find> until it is unique.
|
|
74
|
+
|
|
75
|
+
## PERSONALITY & COMMUNICATION:
|
|
76
|
+
- Be warm, friendly, and professional — like a skilled teammate who enjoys the work.
|
|
77
|
+
- Before acting: one short sentence saying what you are about to do — write it in the SAME language the user used.
|
|
78
|
+
- After acting: confirm what was done in a natural, friendly tone — in the SAME language the user used.
|
|
79
|
+
- If the task is interesting or you notice something worth mentioning (a side effect, a tip, a related thing), say it briefly.
|
|
80
|
+
- 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.`;
|
|
81
|
+
// Full prompt used when server returns empty
|
|
82
|
+
const FALLBACK_PROMPT = `You are Dravix Code — a hyper-professional AI coding assistant running in the terminal.
|
|
83
|
+
You are an elite software engineer who takes immediate, precise action on every request.
|
|
84
|
+
|
|
85
|
+
Core identity:
|
|
86
|
+
- You write production-quality code with no unnecessary comments or fluff
|
|
87
|
+
- You take action first, explain only when absolutely necessary
|
|
88
|
+
- You handle any language: TypeScript, JavaScript, Python, Go, Rust, HTML/CSS, SQL, and more
|
|
89
|
+
- You work on any project type: web apps, mobile apps, CLIs, APIs, databases, scripts
|
|
90
|
+
|
|
91
|
+
${BEHAVIORAL_RULES}`;
|
|
15
92
|
// prompts removed
|
|
16
93
|
// Removed — fetched from server
|
|
17
94
|
const CREATOR_EXTRA = ``;
|
|
@@ -484,6 +561,61 @@ class MarkdownRenderer {
|
|
|
484
561
|
return out;
|
|
485
562
|
}
|
|
486
563
|
}
|
|
564
|
+
// ── Targeted section finder — finds proper HTML container boundaries ──
|
|
565
|
+
function findSectionBlocks(content, keywords) {
|
|
566
|
+
const lines = content.split('\n');
|
|
567
|
+
const seen = new Set();
|
|
568
|
+
const out = [];
|
|
569
|
+
for (const kw of keywords) {
|
|
570
|
+
if (kw.length < 3)
|
|
571
|
+
continue;
|
|
572
|
+
const kwL = kw.toLowerCase();
|
|
573
|
+
for (let i = 0; i < lines.length; i++) {
|
|
574
|
+
if (seen.has(i))
|
|
575
|
+
continue;
|
|
576
|
+
if (!lines[i].toLowerCase().includes(kwL))
|
|
577
|
+
continue;
|
|
578
|
+
// ── Go UP: find enclosing container opening tag ──────────────
|
|
579
|
+
let startIdx = Math.max(0, i - 3);
|
|
580
|
+
const kwIndent = (lines[i].match(/^(\s*)/)?.[1] ?? '').length;
|
|
581
|
+
for (let j = i - 1; j >= Math.max(0, i - 60); j--) {
|
|
582
|
+
const trimmed = lines[j].trim();
|
|
583
|
+
const lineIndent = (lines[j].match(/^(\s*)/)?.[1] ?? '').length;
|
|
584
|
+
// Opening block tag at same or lower indent level
|
|
585
|
+
if (lineIndent <= kwIndent && /<(section|div|article|main|header|footer|ul|ol)\b[^>]*>/.test(trimmed)) {
|
|
586
|
+
startIdx = j;
|
|
587
|
+
break;
|
|
588
|
+
}
|
|
589
|
+
// CSS section comment /* ═══ ... ═══ */
|
|
590
|
+
if (lineIndent <= kwIndent && /\/\*.*[═=─\-]{2,}/.test(trimmed)) {
|
|
591
|
+
startIdx = Math.max(0, j);
|
|
592
|
+
break;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
// ── Go DOWN: find matching closing tag ───────────────────────
|
|
596
|
+
const openMatch = lines[startIdx].match(/<(section|div|article|main|header|footer|ul|ol)\b/);
|
|
597
|
+
let endIdx = Math.min(lines.length - 1, i + 180);
|
|
598
|
+
if (openMatch) {
|
|
599
|
+
const tag = openMatch[1];
|
|
600
|
+
let depth = 0;
|
|
601
|
+
for (let j = startIdx; j <= Math.min(lines.length - 1, i + 300); j++) {
|
|
602
|
+
depth += (lines[j].match(new RegExp(`<${tag}[\\s>]`, 'g')) ?? []).length;
|
|
603
|
+
depth -= (lines[j].match(new RegExp(`</${tag}>`, 'g')) ?? []).length;
|
|
604
|
+
if (depth <= 0 && j > startIdx) {
|
|
605
|
+
endIdx = j;
|
|
606
|
+
break;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
const block = lines.slice(startIdx, endIdx + 1).join('\n');
|
|
611
|
+
out.push({ keyword: kw, lineNo: i + 1, block, startLine: startIdx + 1, endLine: endIdx + 1 });
|
|
612
|
+
for (let j = startIdx; j <= endIdx; j++)
|
|
613
|
+
seen.add(j);
|
|
614
|
+
break;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return out;
|
|
618
|
+
}
|
|
487
619
|
function normalizeResponse(text) {
|
|
488
620
|
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
621
|
}
|
|
@@ -526,7 +658,148 @@ function clearMenu(lines) {
|
|
|
526
658
|
process.stdout.write('\x1b[1A\x1b[2K');
|
|
527
659
|
}
|
|
528
660
|
}
|
|
529
|
-
|
|
661
|
+
// Returns number of terminal lines printed (for clearing after acceptance)
|
|
662
|
+
function printDiffPreview(op, cwd) {
|
|
663
|
+
let printed = 0;
|
|
664
|
+
try {
|
|
665
|
+
const cols = process.stdout.columns ?? 80;
|
|
666
|
+
const maxW = Math.min(cols - 12, 120);
|
|
667
|
+
const clip = (t) => t.length > maxW ? t.slice(0, maxW - 1) + '…' : t;
|
|
668
|
+
let diff = null;
|
|
669
|
+
if (op.type === 'write' && op.path && op.content !== undefined) {
|
|
670
|
+
const fp = path.resolve(cwd, op.path);
|
|
671
|
+
const old = fs.existsSync(fp) ? fs.readFileSync(fp, 'utf-8') : '';
|
|
672
|
+
diff = computeDiff(old, op.content);
|
|
673
|
+
}
|
|
674
|
+
else if (op.type === 'edit' && op.path && op.find !== undefined && op.replace !== undefined) {
|
|
675
|
+
// Find real line offset in file so line numbers match actual file
|
|
676
|
+
const rawDiff = computeDiff(op.find, op.replace);
|
|
677
|
+
try {
|
|
678
|
+
const fp = path.resolve(cwd, op.path);
|
|
679
|
+
if (fs.existsSync(fp)) {
|
|
680
|
+
const fileContent = fs.readFileSync(fp, 'utf-8');
|
|
681
|
+
const idx = fileContent.indexOf(op.find.trim());
|
|
682
|
+
const offset = idx >= 0 ? fileContent.slice(0, idx).split('\n').length - 1 : 0;
|
|
683
|
+
diff = rawDiff.map(d => ({ ...d, lineNo: d.lineNo + offset }));
|
|
684
|
+
}
|
|
685
|
+
else {
|
|
686
|
+
diff = rawDiff;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
catch {
|
|
690
|
+
diff = rawDiff;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
if (!diff)
|
|
694
|
+
return 0;
|
|
695
|
+
const adds = diff.filter(d => d.kind === 'add').length;
|
|
696
|
+
const removes = diff.filter(d => d.kind === 'remove').length;
|
|
697
|
+
if (adds === 0 && removes === 0)
|
|
698
|
+
return 0;
|
|
699
|
+
const statStr = [
|
|
700
|
+
removes > 0 ? chalk.hex('#f87171').bold(`-${removes}`) : '',
|
|
701
|
+
adds > 0 ? chalk.hex('#34d399').bold(`+${adds}`) : '',
|
|
702
|
+
].filter(Boolean).join(' ');
|
|
703
|
+
const writeLine = (s) => { process.stdout.write(s + '\n'); printed++; };
|
|
704
|
+
process.stdout.write('\n');
|
|
705
|
+
printed++;
|
|
706
|
+
writeLine(chalk.hex('#fbbf24')(' ~') +
|
|
707
|
+
chalk.hex('#94a3b8')(' ') +
|
|
708
|
+
chalk.hex('#e2e8f0')(op.path ?? '') +
|
|
709
|
+
(statStr ? ' ' + statStr : ''));
|
|
710
|
+
const CONTEXT = 2;
|
|
711
|
+
const show = new Uint8Array(diff.length);
|
|
712
|
+
for (let i = 0; i < diff.length; i++) {
|
|
713
|
+
if (diff[i].kind !== 'context') {
|
|
714
|
+
for (let c = Math.max(0, i - CONTEXT); c <= Math.min(diff.length - 1, i + CONTEXT); c++)
|
|
715
|
+
show[c] = 1;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
const renderLine = (line) => {
|
|
719
|
+
const num = String(line.lineNo).padStart(5, ' ');
|
|
720
|
+
if (line.kind === 'remove') {
|
|
721
|
+
const content = ` ${num} ${chalk.hex('#f87171')('-')} ${clip(line.text)}`;
|
|
722
|
+
writeLine(' ' + chalk.hex('#7f1d1d')('▌') + chalk.bgHex('#1c0a0a')(content.padEnd(cols - 3)));
|
|
723
|
+
}
|
|
724
|
+
else if (line.kind === 'add') {
|
|
725
|
+
const content = ` ${num} ${chalk.hex('#34d399')('+')} ${clip(line.text)}`;
|
|
726
|
+
writeLine(' ' + chalk.hex('#065f46')('▌') + chalk.bgHex('#021a0e')(content.padEnd(cols - 3)));
|
|
727
|
+
}
|
|
728
|
+
else {
|
|
729
|
+
writeLine(chalk.hex('#374151')(` ${num} ${clip(line.text)}`));
|
|
730
|
+
}
|
|
731
|
+
};
|
|
732
|
+
const hunks = [];
|
|
733
|
+
let curHunk = [];
|
|
734
|
+
let lastIdx = -1;
|
|
735
|
+
for (let i = 0; i < diff.length; i++) {
|
|
736
|
+
if (!show[i])
|
|
737
|
+
continue;
|
|
738
|
+
if (lastIdx >= 0 && i > lastIdx + 1) {
|
|
739
|
+
hunks.push(curHunk);
|
|
740
|
+
curHunk = [];
|
|
741
|
+
}
|
|
742
|
+
lastIdx = i;
|
|
743
|
+
curHunk.push(diff[i]);
|
|
744
|
+
}
|
|
745
|
+
if (curHunk.length > 0)
|
|
746
|
+
hunks.push(curHunk);
|
|
747
|
+
let shown = 0;
|
|
748
|
+
const MAX_LINES = 60;
|
|
749
|
+
for (let h = 0; h < hunks.length; h++) {
|
|
750
|
+
if (shown >= MAX_LINES)
|
|
751
|
+
break;
|
|
752
|
+
if (h > 0) {
|
|
753
|
+
writeLine(chalk.hex('#374151')(' ╌╌╌'));
|
|
754
|
+
}
|
|
755
|
+
const hunk = hunks[h];
|
|
756
|
+
const before = [];
|
|
757
|
+
const hRem = [];
|
|
758
|
+
const hAdd = [];
|
|
759
|
+
let pendingCtx = [];
|
|
760
|
+
let seenChange = false;
|
|
761
|
+
for (const line of hunk) {
|
|
762
|
+
if (line.kind === 'context') {
|
|
763
|
+
if (!seenChange)
|
|
764
|
+
before.push(line);
|
|
765
|
+
else
|
|
766
|
+
pendingCtx.push(line);
|
|
767
|
+
}
|
|
768
|
+
else {
|
|
769
|
+
seenChange = true;
|
|
770
|
+
pendingCtx = [];
|
|
771
|
+
if (line.kind === 'remove')
|
|
772
|
+
hRem.push(line);
|
|
773
|
+
else
|
|
774
|
+
hAdd.push(line);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
for (const l of before)
|
|
778
|
+
renderLine(l);
|
|
779
|
+
for (const l of hRem) {
|
|
780
|
+
if (shown >= MAX_LINES)
|
|
781
|
+
break;
|
|
782
|
+
renderLine(l);
|
|
783
|
+
shown++;
|
|
784
|
+
}
|
|
785
|
+
for (const l of hAdd) {
|
|
786
|
+
if (shown >= MAX_LINES)
|
|
787
|
+
break;
|
|
788
|
+
renderLine(l);
|
|
789
|
+
shown++;
|
|
790
|
+
}
|
|
791
|
+
for (const l of pendingCtx)
|
|
792
|
+
renderLine(l);
|
|
793
|
+
}
|
|
794
|
+
const total = diff.filter(d => d.kind !== 'context').length;
|
|
795
|
+
if (total > MAX_LINES) {
|
|
796
|
+
writeLine(chalk.hex('#4b5563')(` … ${total - MAX_LINES} more lines`));
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
catch { /* ignore */ }
|
|
800
|
+
return printed;
|
|
801
|
+
}
|
|
802
|
+
async function askPermission(label, key, alwaysAllowed, noAlways, diffShown, previewLineCount = 0) {
|
|
530
803
|
if (!noAlways && alwaysAllowed.has(key))
|
|
531
804
|
return true;
|
|
532
805
|
return new Promise((resolve) => {
|
|
@@ -538,37 +811,48 @@ async function askPermission(label, key, alwaysAllowed, noAlways) {
|
|
|
538
811
|
const { icon, paint } = opStyle(label);
|
|
539
812
|
const optLabels = noAlways ? OPT_LABELS_RUN : OPT_LABELS_DEFAULT;
|
|
540
813
|
const optCount = optLabels.length;
|
|
541
|
-
//
|
|
542
|
-
|
|
543
|
-
const menuLines = (noAlways ? 3 : 4) + 1 + optCount;
|
|
814
|
+
// when diff already shown: just blank + options (no box)
|
|
815
|
+
const menuLines = diffShown ? (1 + optCount) : (noAlways ? 3 : 4) + 1 + optCount;
|
|
544
816
|
function printMenu() {
|
|
545
817
|
if (drawn)
|
|
546
818
|
clearMenu(menuLines);
|
|
547
819
|
drawn = true;
|
|
548
|
-
let
|
|
549
|
-
if (
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
820
|
+
let lines;
|
|
821
|
+
if (diffShown) {
|
|
822
|
+
// Diff already shown above — just show options, no redundant box
|
|
823
|
+
lines = [
|
|
824
|
+
'',
|
|
825
|
+
...optLabels.map((lbl, i) => i === sel
|
|
826
|
+
? ` ${colors.primary('›')} ${colors.primary.bold(lbl)}`
|
|
827
|
+
: ` ${colors.muted(lbl)}`),
|
|
555
828
|
];
|
|
556
829
|
}
|
|
557
830
|
else {
|
|
558
|
-
boxLines
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
831
|
+
let boxLines;
|
|
832
|
+
if (noAlways) {
|
|
833
|
+
const dashes = SEP.length - 2 - 'run command'.length - 1;
|
|
834
|
+
boxLines = [
|
|
835
|
+
colors.dim(' ╭─ ') + colors.primary('run command') + colors.dim(' ' + '─'.repeat(Math.max(dashes, 1))),
|
|
836
|
+
` │ ${chalk.hex('#fbbf24')('$')} ${chalk.white(opValue)}`,
|
|
837
|
+
colors.dim(' ╰' + SEP),
|
|
838
|
+
];
|
|
839
|
+
}
|
|
840
|
+
else {
|
|
841
|
+
boxLines = [
|
|
842
|
+
colors.dim(' ╭' + SEP),
|
|
843
|
+
` │ ${paint(icon + ' ' + opType)}`,
|
|
844
|
+
` │ ${chalk.white(opValue)}`,
|
|
845
|
+
colors.dim(' ╰' + SEP),
|
|
846
|
+
];
|
|
847
|
+
}
|
|
848
|
+
lines = [
|
|
849
|
+
...boxLines,
|
|
850
|
+
'',
|
|
851
|
+
...optLabels.map((lbl, i) => i === sel
|
|
852
|
+
? ` ${colors.primary('›')} ${colors.primary.bold(lbl)}`
|
|
853
|
+
: ` ${colors.muted(lbl)}`),
|
|
563
854
|
];
|
|
564
855
|
}
|
|
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
856
|
process.stdout.write(lines.join('\n') + '\n');
|
|
573
857
|
}
|
|
574
858
|
printMenu();
|
|
@@ -578,6 +862,10 @@ async function askPermission(label, key, alwaysAllowed, noAlways) {
|
|
|
578
862
|
function confirm(idx) {
|
|
579
863
|
cleanup();
|
|
580
864
|
clearMenu(menuLines);
|
|
865
|
+
// Clear the diff preview lines (all except the first blank separator)
|
|
866
|
+
for (let i = 0; i < previewLineCount - 1; i++) {
|
|
867
|
+
process.stdout.write('\x1b[1A\x1b[2K');
|
|
868
|
+
}
|
|
581
869
|
const skipIdx = noAlways ? 1 : 2;
|
|
582
870
|
if (idx === skipIdx) {
|
|
583
871
|
process.stdout.write(` ${colors.muted('○')} ${colors.muted('Skipped: ' + label)}\n`);
|
|
@@ -1930,9 +2218,15 @@ export async function startRepl(cwd) {
|
|
|
1930
2218
|
let SYSTEM_PROMPT = '';
|
|
1931
2219
|
if (token) {
|
|
1932
2220
|
const { prompt, webDesignerSkill } = await fetchSystemPrompt(token);
|
|
1933
|
-
|
|
2221
|
+
// Always inject behavioral rules — append to server prompt so critical rules are never missing
|
|
2222
|
+
SYSTEM_PROMPT = prompt
|
|
2223
|
+
? prompt + '\n' + BEHAVIORAL_RULES
|
|
2224
|
+
: FALLBACK_PROMPT;
|
|
1934
2225
|
_serverWebDesignerSkill = webDesignerSkill;
|
|
1935
2226
|
}
|
|
2227
|
+
else {
|
|
2228
|
+
SYSTEM_PROMPT = FALLBACK_PROMPT;
|
|
2229
|
+
}
|
|
1936
2230
|
// activeCwd can change when /resume loads a conversation from a different directory
|
|
1937
2231
|
let activeCwd = cwd;
|
|
1938
2232
|
const buildSystemMsg = (dir) => ({
|
|
@@ -1953,6 +2247,7 @@ export async function startRepl(cwd) {
|
|
|
1953
2247
|
let pendingOutputTokens = 0;
|
|
1954
2248
|
let pendingUserTokens = 0;
|
|
1955
2249
|
let hasPendingReport = false;
|
|
2250
|
+
let useProModel = false; // switched to PRO_MODEL after file op errors
|
|
1956
2251
|
process.stdin.once('end', () => {
|
|
1957
2252
|
console.log(colors.muted('\n Goodbye!\n'));
|
|
1958
2253
|
process.exit(0);
|
|
@@ -2002,24 +2297,40 @@ export async function startRepl(cwd) {
|
|
|
2002
2297
|
if (readFileContinue) {
|
|
2003
2298
|
readFileContinue = false;
|
|
2004
2299
|
readFileTurnCount++;
|
|
2005
|
-
|
|
2300
|
+
// Remind AI of the original user request — show first + last line so pasted code
|
|
2301
|
+
// doesn't eclipse the actual instruction written at the end.
|
|
2302
|
+
const rawUserMsg = lastUserLine.trim();
|
|
2303
|
+
const msgLines = rawUserMsg.split('\n').map(l => l.trim()).filter(Boolean);
|
|
2304
|
+
let userReminder = '';
|
|
2305
|
+
if (msgLines.length > 0) {
|
|
2306
|
+
const first = msgLines[0].slice(0, 100);
|
|
2307
|
+
const last = msgLines[msgLines.length - 1].slice(0, 150);
|
|
2308
|
+
const display = msgLines.length <= 2 ? msgLines.join(' / ').slice(0, 200) : `${first} [...] ${last}`;
|
|
2309
|
+
userReminder = `The user's request: "${display}". `;
|
|
2310
|
+
}
|
|
2311
|
+
const taskInstruction = userReminder
|
|
2312
|
+
? `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.`
|
|
2313
|
+
: `Execute the user's request using the file content above.`;
|
|
2314
|
+
// Keep using FLASH_MODEL after file reads
|
|
2315
|
+
if (readFileTurnCount > 5 && !forcedEditMode) {
|
|
2006
2316
|
forcedEditMode = true;
|
|
2007
2317
|
history.push({
|
|
2008
2318
|
role: 'system',
|
|
2009
|
-
content: `[
|
|
2319
|
+
content: `[CONTEXT READY] ${taskInstruction}\nDo not read any more files — use what is already in context.`,
|
|
2010
2320
|
});
|
|
2011
2321
|
process.stdout.write('\n' + colors.muted(' ⚠ Too many read operations — forced edit mode') + '\n');
|
|
2012
2322
|
}
|
|
2013
|
-
else if (readFileTurnCount >=
|
|
2014
|
-
// Escalating signal: AI has read enough, must act now
|
|
2323
|
+
else if (readFileTurnCount >= 3) {
|
|
2015
2324
|
history.push({
|
|
2016
2325
|
role: 'system',
|
|
2017
|
-
content: `[
|
|
2326
|
+
content: `[CONTEXT READY] ${taskInstruction}`,
|
|
2018
2327
|
});
|
|
2019
2328
|
}
|
|
2020
2329
|
else {
|
|
2021
|
-
|
|
2022
|
-
|
|
2330
|
+
history.push({
|
|
2331
|
+
role: 'system',
|
|
2332
|
+
content: `[FILE CONTENT READY] ${taskInstruction}`,
|
|
2333
|
+
});
|
|
2023
2334
|
}
|
|
2024
2335
|
skipInput = true;
|
|
2025
2336
|
result = { text: '', lines: [] };
|
|
@@ -2089,11 +2400,19 @@ export async function startRepl(cwd) {
|
|
|
2089
2400
|
if (!skipInput) {
|
|
2090
2401
|
readFileTurnCount = 0; // reset loop counter for each new user message
|
|
2091
2402
|
forcedEditMode = false; // reset forced edit mode for each new user message
|
|
2403
|
+
useProModel = false; // reset model selection for each new user message
|
|
2092
2404
|
// ── Refresh project context (files may have changed since last turn) ──
|
|
2093
2405
|
history[0] = buildSystemMsg(activeCwd);
|
|
2094
2406
|
// ── Clear stale operational messages left from previous auto-continue turns ──
|
|
2095
2407
|
// They pollute future user messages when prepareMessages merges consecutive roles.
|
|
2096
|
-
const STALE_PREFIXES = [
|
|
2408
|
+
const STALE_PREFIXES = [
|
|
2409
|
+
'[File updated]', '[SYSTEM LIMIT]', '[BLOCKED]', 'Please proceed with the task',
|
|
2410
|
+
'[FORCED EDIT MODE]', '[ACTION REQUIRED]', '[Auto-reading',
|
|
2411
|
+
'[Web Designer mode', 'You have done ', 'You now have the file content',
|
|
2412
|
+
'You described the steps but', 'Your previous response was cut off',
|
|
2413
|
+
'[CONTEXT READY]', '[TARGET SECTION', '[FILE CONTENT READY]',
|
|
2414
|
+
'The user\'s exact request was:',
|
|
2415
|
+
];
|
|
2097
2416
|
for (let i = history.length - 1; i > 0; i--) {
|
|
2098
2417
|
if (history[i].role === 'system' &&
|
|
2099
2418
|
STALE_PREFIXES.some(p => history[i].content.startsWith(p))) {
|
|
@@ -2106,7 +2425,6 @@ export async function startRepl(cwd) {
|
|
|
2106
2425
|
}
|
|
2107
2426
|
// ── Auto-inject mentioned file contents ──────────────────────
|
|
2108
2427
|
// 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
2428
|
const FILE_PATTERN = /[\w\-.\/\\]+\.\w{2,5}/g;
|
|
2111
2429
|
const mentioned = [...new Set(line.match(FILE_PATTERN) ?? [])];
|
|
2112
2430
|
let fileContext = '';
|
|
@@ -2116,20 +2434,14 @@ export async function startRepl(cwd) {
|
|
|
2116
2434
|
if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) {
|
|
2117
2435
|
try {
|
|
2118
2436
|
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
2119
|
-
const
|
|
2120
|
-
const lineCount =
|
|
2437
|
+
const fileLines = content.split('\n');
|
|
2438
|
+
const lineCount = fileLines.length;
|
|
2121
2439
|
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;
|
|
2440
|
+
fileContext += `\n\n[File: ${fname} — ${lineCount} lines — FULL]\n` + content;
|
|
2125
2441
|
}
|
|
2126
2442
|
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)`;
|
|
2443
|
+
fileContext += `\n\n[File: ${fname} — ${lineCount} lines]\n` +
|
|
2444
|
+
`Use: <search_code pattern="TERM" path="${fname}"/> → <read_file path="${fname}"/> → <edit_file>\n`;
|
|
2133
2445
|
}
|
|
2134
2446
|
}
|
|
2135
2447
|
catch { /* skip unreadable */ }
|
|
@@ -2237,7 +2549,8 @@ export async function startRepl(cwd) {
|
|
|
2237
2549
|
executedInlineOps.add(inlineOpFingerprint(op));
|
|
2238
2550
|
}
|
|
2239
2551
|
else {
|
|
2240
|
-
const
|
|
2552
|
+
const previewLines = (op.type === 'write' || op.type === 'edit') ? printDiffPreview(op, activeCwd) : 0;
|
|
2553
|
+
const allowed = await askPermission(label, key, alwaysAllowed, op.type === 'run', previewLines > 0, previewLines);
|
|
2241
2554
|
executedInlineOps.add(inlineOpFingerprint(op));
|
|
2242
2555
|
if (!allowed) {
|
|
2243
2556
|
if (op.path)
|
|
@@ -2264,19 +2577,43 @@ export async function startRepl(cwd) {
|
|
|
2264
2577
|
process.stdout.write('\r\x1b[K');
|
|
2265
2578
|
printOpResult(opResult);
|
|
2266
2579
|
if (opResult.type === 'error' && op.type !== 'run') {
|
|
2267
|
-
// If text not found, include current file content so AI can retry accurately
|
|
2268
2580
|
let errMsg = `[Operation failed — ${label}]: ${opResult.message ?? 'Unknown error'}`;
|
|
2269
2581
|
if (opResult.message?.includes('not found') && op.path) {
|
|
2270
2582
|
try {
|
|
2271
2583
|
const fp = path.resolve(activeCwd, op.path);
|
|
2272
2584
|
if (fs.existsSync(fp)) {
|
|
2273
2585
|
const cur = fs.readFileSync(fp, 'utf-8');
|
|
2274
|
-
const
|
|
2275
|
-
|
|
2276
|
-
|
|
2586
|
+
const curLines = cur.split('\n');
|
|
2587
|
+
const nLines = curLines.length;
|
|
2588
|
+
const isDeleteOp = !op.replace || op.replace.trim() === '';
|
|
2589
|
+
// Auto-search for what AI was trying to find — inject exact block
|
|
2590
|
+
const findLines = (op.find ?? '').split('\n').map(l => l.replace(/^\s*\d+\s*[│|:]\s?/, '').trim()).filter(Boolean);
|
|
2591
|
+
const searchTerm = findLines[0]?.slice(0, 60) ?? '';
|
|
2592
|
+
let foundAt = -1;
|
|
2593
|
+
if (searchTerm.length > 4) {
|
|
2594
|
+
for (let si = 0; si < curLines.length; si++) {
|
|
2595
|
+
if (curLines[si].toLowerCase().includes(searchTerm.toLowerCase())) {
|
|
2596
|
+
foundAt = si;
|
|
2597
|
+
break;
|
|
2598
|
+
}
|
|
2599
|
+
}
|
|
2600
|
+
}
|
|
2601
|
+
if (foundAt >= 0) {
|
|
2602
|
+
// Found the approximate location — inject surrounding block
|
|
2603
|
+
const start = Math.max(0, foundAt - 2);
|
|
2604
|
+
const end = Math.min(curLines.length - 1, foundAt + 100);
|
|
2605
|
+
const block = curLines.slice(start, end + 1).join('\n');
|
|
2606
|
+
errMsg += `\n\n[FOUND near line ${foundAt + 1} in ${op.path}]:\n${block}\n`;
|
|
2607
|
+
errMsg += isDeleteOp
|
|
2608
|
+
? `\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.`
|
|
2609
|
+
: `\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>.`;
|
|
2610
|
+
}
|
|
2611
|
+
else if (nLines <= 400) {
|
|
2612
|
+
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
2613
|
}
|
|
2278
2614
|
else {
|
|
2279
|
-
|
|
2615
|
+
const safe = searchTerm.replace(/['"<>{}()\[\]]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 35);
|
|
2616
|
+
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
2617
|
}
|
|
2281
2618
|
}
|
|
2282
2619
|
}
|
|
@@ -2516,11 +2853,23 @@ export async function startRepl(cwd) {
|
|
|
2516
2853
|
// Pattern: response has numbered items like "2. something" or "3. something"
|
|
2517
2854
|
// but very few actual file operation tags were output → force continuation
|
|
2518
2855
|
if (!readFileContinue && !streamCancelled) {
|
|
2856
|
+
const hasAnyReadOp = allOps.some(op => op.type === 'read_file' || op.type === 'read_folder' || op.type === 'search_code');
|
|
2857
|
+
const writeOps = allOps.filter(op => op.type !== 'read_file' && op.type !== 'read_folder' && op.type !== 'search_code').length;
|
|
2519
2858
|
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
|
|
2859
|
+
// AI described a plan (numbered steps 2+) but did nothing at all — force execution
|
|
2860
|
+
if (plannedSteps >= 1 && writeOps === 0 && !hasAnyReadOp) {
|
|
2861
|
+
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
2862
|
readFileContinue = true;
|
|
2863
|
+
// AI gave a non-trivial response with zero operations of any kind
|
|
2864
|
+
}
|
|
2865
|
+
else if (writeOps === 0 && allOps.length === 0 && normalized.trim().length > 200) {
|
|
2866
|
+
const hasCodeFence = normalized.includes('```');
|
|
2867
|
+
const hasStepList = (normalized.match(/^\s*\d+[.)]\s+\S/mg) ?? []).length >= 2;
|
|
2868
|
+
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);
|
|
2869
|
+
if ((hasCodeFence || hasStepList) && hasWouldNeed) {
|
|
2870
|
+
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.' });
|
|
2871
|
+
readFileContinue = true;
|
|
2872
|
+
}
|
|
2524
2873
|
}
|
|
2525
2874
|
}
|
|
2526
2875
|
const readOps = allOps.filter(op => op.type === 'read_file' || op.type === 'read_folder' || op.type === 'search_code');
|
|
@@ -2608,6 +2957,22 @@ export async function startRepl(cwd) {
|
|
|
2608
2957
|
const lastLine = (res.output ?? '').split('\n').filter(Boolean).pop() ?? '';
|
|
2609
2958
|
rightCol = chalk.hex('#6b7280')(lastLine.startsWith('Total:') ? lastLine.slice(7).trim() : `${ms}ms`);
|
|
2610
2959
|
displayLabel = `"${op.pattern}"`;
|
|
2960
|
+
// Auto-read section around first match so AI has exact text without another round-trip
|
|
2961
|
+
const firstMatchLine = (res.output ?? '').match(/^([^\n:]+):(\d+):/m);
|
|
2962
|
+
if (firstMatchLine) {
|
|
2963
|
+
const matchFile = firstMatchLine[1].trim();
|
|
2964
|
+
const matchLineNum = parseInt(firstMatchLine[2]);
|
|
2965
|
+
if (!isNaN(matchLineNum) && matchFile) {
|
|
2966
|
+
const readStart = Math.max(1, matchLineNum - 3);
|
|
2967
|
+
const readEnd = matchLineNum + 280;
|
|
2968
|
+
const autoKey = `[File content — ${matchFile}][lines=${readStart}-${readEnd}]`;
|
|
2969
|
+
const alreadyHaveIt = history.slice(-10).some(m => m.role === 'system' && m.content.includes(`[File content — ${matchFile}]`));
|
|
2970
|
+
if (!alreadyHaveIt) {
|
|
2971
|
+
validOps.push({ type: 'read_file', path: matchFile, lines: `${readStart}-${readEnd}` });
|
|
2972
|
+
void autoKey;
|
|
2973
|
+
}
|
|
2974
|
+
}
|
|
2975
|
+
}
|
|
2611
2976
|
}
|
|
2612
2977
|
else if (op.type === 'read_folder') {
|
|
2613
2978
|
ctxLabel = `[Folder contents — "${op.path}"]:\n${res.output ?? ''}`;
|
|
@@ -2708,7 +3073,8 @@ export async function startRepl(cwd) {
|
|
|
2708
3073
|
continue;
|
|
2709
3074
|
}
|
|
2710
3075
|
}
|
|
2711
|
-
const
|
|
3076
|
+
const previewLines2 = (op.type === 'write' || op.type === 'edit') ? printDiffPreview(op, activeCwd) : 0;
|
|
3077
|
+
const allowed = await askPermission(label, key, alwaysAllowed, op.type === 'run', previewLines2 > 0, previewLines2);
|
|
2712
3078
|
if (!allowed) {
|
|
2713
3079
|
if (op.path)
|
|
2714
3080
|
skippedPaths.add(op.path);
|
|
@@ -2737,7 +3103,47 @@ export async function startRepl(cwd) {
|
|
|
2737
3103
|
printOpResult(opResult);
|
|
2738
3104
|
// Collect file operation errors for AI feedback
|
|
2739
3105
|
if (opResult.type === 'error' && op.type !== 'run') {
|
|
2740
|
-
|
|
3106
|
+
let errMsg = `[Operation failed — ${label}]: ${opResult.message ?? 'Unknown error'}`;
|
|
3107
|
+
if (opResult.message?.includes('not found') && op.path) {
|
|
3108
|
+
try {
|
|
3109
|
+
const fp = path.resolve(activeCwd, op.path);
|
|
3110
|
+
if (fs.existsSync(fp)) {
|
|
3111
|
+
const cur = fs.readFileSync(fp, 'utf-8');
|
|
3112
|
+
const curLines = cur.split('\n');
|
|
3113
|
+
const nLines = curLines.length;
|
|
3114
|
+
const isDeleteOp = !op.replace || op.replace.trim() === '';
|
|
3115
|
+
// Auto-search for approximate location of what AI tried to find
|
|
3116
|
+
const findLines = (op.find ?? '').split('\n').map(l => l.replace(/^\s*\d+\s*[│|:]\s?/, '').trim()).filter(Boolean);
|
|
3117
|
+
const searchTerm = findLines[0]?.slice(0, 60) ?? '';
|
|
3118
|
+
let foundAt = -1;
|
|
3119
|
+
if (searchTerm.length > 4) {
|
|
3120
|
+
for (let si = 0; si < curLines.length; si++) {
|
|
3121
|
+
if (curLines[si].toLowerCase().includes(searchTerm.toLowerCase())) {
|
|
3122
|
+
foundAt = si;
|
|
3123
|
+
break;
|
|
3124
|
+
}
|
|
3125
|
+
}
|
|
3126
|
+
}
|
|
3127
|
+
if (foundAt >= 0) {
|
|
3128
|
+
const start = Math.max(0, foundAt - 2);
|
|
3129
|
+
const end = Math.min(curLines.length - 1, foundAt + 100);
|
|
3130
|
+
errMsg += `\n\n[FOUND near line ${foundAt + 1}]:\n${curLines.slice(start, end + 1).join('\n')}\n`;
|
|
3131
|
+
errMsg += isDeleteOp
|
|
3132
|
+
? `\nDELETE FIX: Copy the EXACT full container from above into <edit_file><find>EXACT</find><replace></replace>.`
|
|
3133
|
+
: `\nEDIT FIX: Copy EXACT text from above into <edit_file><find>EXACT</find><replace>NEW</replace>.`;
|
|
3134
|
+
}
|
|
3135
|
+
else if (nLines <= 400) {
|
|
3136
|
+
errMsg += `\n\nFull file (${nLines} lines):\n${cur}\n\nFIX: Copy EXACT text from above into <find>.`;
|
|
3137
|
+
}
|
|
3138
|
+
else {
|
|
3139
|
+
const safe = searchTerm.replace(/['"<>{}()\[\]]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 35);
|
|
3140
|
+
errMsg += `\n\nFIX: <search_code pattern="${safe}" path="${op.path}"/> → <read_file lines="N-M"/> → copy EXACT text into <find>.`;
|
|
3141
|
+
}
|
|
3142
|
+
}
|
|
3143
|
+
}
|
|
3144
|
+
catch { /* ignore */ }
|
|
3145
|
+
}
|
|
3146
|
+
fileOpErrors.push(errMsg);
|
|
2741
3147
|
}
|
|
2742
3148
|
if (op.type === 'run' && op.command) {
|
|
2743
3149
|
if (opResult.type === 'run') {
|
|
@@ -2805,7 +3211,7 @@ export async function startRepl(cwd) {
|
|
|
2805
3211
|
: raw;
|
|
2806
3212
|
printError(msg);
|
|
2807
3213
|
resolve();
|
|
2808
|
-
}, streamAbort.signal);
|
|
3214
|
+
}, streamAbort.signal, useProModel ? PRO_MODEL : FLASH_MODEL);
|
|
2809
3215
|
});
|
|
2810
3216
|
// ── Remove streaming key listener ─────────────────────────
|
|
2811
3217
|
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` +
|