@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 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
- async function askPermission(label, key, alwaysAllowed, noAlways) {
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
- // run: 3-line box (╭header + │cmd + ╰) + blank + options
542
- // file ops: 4-line box ( + │type + │value + ) + blank + options
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 boxLines;
549
- if (noAlways) {
550
- const dashes = SEP.length - 2 - 'run command'.length - 1;
551
- boxLines = [
552
- colors.dim(' ╭─ ') + colors.primary('run command') + colors.dim(' ' + '─'.repeat(Math.max(dashes, 1))),
553
- ` │ ${chalk.hex('#fbbf24')('$')} ${chalk.white(opValue)}`,
554
- colors.dim('' + SEP),
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
- colors.dim(' ╭' + SEP),
560
- ` │ ${paint(icon + ' ' + opType)}`,
561
- ` │ ${chalk.white(opValue)}`,
562
- colors.dim(' ' + SEP),
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
- SYSTEM_PROMPT = prompt;
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
- if (readFileTurnCount > 3 && !forcedEditMode) {
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: `[FORCED EDIT MODE] You have done ${readFileTurnCount} read/search operations — reads are now BLOCKED.\nYou MUST output a tag RIGHT NOW. No more explanation, no more searching:\n • <edit_file path="..."><find>EXACT text from file</find><replace>new content</replace></edit_file>\n • <write_file path="...">COMPLETE file content here</write_file>\nIf you are unsure of exact text → use <write_file> with the complete corrected content. OUTPUT THE TAG NOW.`,
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 >= 2) {
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: `[ACTION REQUIRED] You have done ${readFileTurnCount} read operations. STOP reading. You have enough context — output <edit_file> or <write_file> NOW. Do NOT search or read again. Output ONLY the tag.`,
2326
+ content: `[CONTEXT READY] ${taskInstruction}`,
2018
2327
  });
2019
2328
  }
2020
2329
  else {
2021
- // First continuation: gentle signal
2022
- history.push({ role: 'system', content: 'You now have the file content. Proceed with the task — output the edit or write tag directly, no explanation needed.' });
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 = ['[File updated]', '[SYSTEM LIMIT]', '[BLOCKED]', 'Please proceed with the task'];
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 lines = content.split('\n');
2120
- const lineCount = lines.length;
2437
+ const fileLines = content.split('\n');
2438
+ const lineCount = fileLines.length;
2121
2439
  if (lineCount <= FULL_INJECT_LINES) {
2122
- fileContext += `\n\n[File: ${fname} — FULL CONTENT — ${lineCount} lines]\n` +
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
- // Very large file (>3000 lines): preview + single read instruction
2128
- const preview = lines.slice(0, 300).join('\n');
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 allowed = await askPermission(label, key, alwaysAllowed, op.type === 'run');
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 nLines = cur.split('\n').length;
2275
- if (nLines <= 400) {
2276
- errMsg += `\n\nCurrent content of ${op.path}:\n${cur}\n\nUse <write_file> with the complete corrected content.`;
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
- errMsg += `\n\nFile has ${nLines} lines. Use the correct workflow:\n 1. <search_code pattern="first few words of what you want to find" path="${op.path}"/> — get line number\n 2. <read_file path="${op.path}" lines="N-M"/> — read that exact section\n 3. Copy the EXACT text (after the tab, without line numbers) into <find>`;
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
- const executedOps = allOps.filter(op => op.type !== 'read_file' && op.type !== 'read_folder' && op.type !== 'search_code').length;
2521
- if (plannedSteps >= 1 && executedOps === 0 && !allOps.length) {
2522
- history.push({ role: 'system', content: 'You described the steps but did not output any <edit_file> or <write_file> tags. Output the actual tags NOW to apply the changes. No more descriptions — just the 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 allowed = await askPermission(label, key, alwaysAllowed, op.type === 'run');
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
- fileOpErrors.push(`[Operation failed — ${label}]: ${opResult.message ?? 'Unknown error'}`);
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);
@@ -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 = 80_000;
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
- try {
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: 'deepseek/deepseek-v4-flash',
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
- const writeRe = /<write_file\s+path="([^"]+)">([\s\S]*?)<\/write_file>/g;
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[1], content: m[2].replace(/^\n/, '') });
116
+ ops.push({ type: 'write', path: m[2], content: m[3].replace(/^\n/, '') });
114
117
  }
115
- const editRe = /<edit_file\s+path="([^"]+)">[\s\S]*?<find>([\s\S]*?)<\/find>[\s\S]*?<replace>([\s\S]*?)<\/replace>[\s\S]*?<\/edit_file>/g;
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[1], find: m[2], replace: m[3] });
120
+ ops.push({ type: 'edit', path: m[2], find: m[3], replace: m[4] });
118
121
  }
119
- const mkdirRe = /<create_folder\s+path="([^"]+)"\s*(?:\/>|><\/create_folder>)/g;
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[1] });
124
+ ops.push({ type: 'mkdir', path: m[2] });
122
125
  }
123
- const deleteRe = /<delete_file\s+path="([^"]+)"\s*(?:\/>|><\/delete_file>)/g;
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[1] });
128
+ ops.push({ type: 'delete', path: m[2] });
126
129
  }
127
- const runRe = /<run_command(?:\s+cwd="([^"]*)")?>([\s\S]*?)<\/run_command>/g;
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[2].trim(), workdir: m[1] || undefined });
132
+ ops.push({ type: 'run', command: m[3].trim(), workdir: m[2] || undefined });
130
133
  }
131
- const readRe = /<read_file\s+path="([^"]+)"(?:\s+lines="([^"]+)")?\s*(?:\/>|><\/read_file>)/g;
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[1], lines: m[2] || undefined });
136
+ ops.push({ type: 'read_file', path: m[2], lines: m[4] || undefined });
134
137
  }
135
- const readFolderRe = /<read_folder\s+path="([^"]+)"\s*(?:\/>|><\/read_folder>)/g;
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[1] });
140
+ ops.push({ type: 'read_folder', path: m[2] });
138
141
  }
139
- const searchRe = /<search_code\s+pattern="([^"]+)"(?:\s+path="([^"]*)")?\s*(?:\/>|><\/search_code>)/g;
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[1], path: m[2] || undefined });
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
- // ── Robust match: try exact first, then trimEnd-normalized ──────────────
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 findLines = op.find.split('\n').map(l => l.trimEnd());
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
- outer: for (let i = 0; i <= oldLines.length - findLines.length; i++) {
205
- for (let j = 0; j < findLines.length; j++) {
206
- if (oldLines[i + j].trimEnd() !== findLines[j])
207
- continue outer;
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 works
214
- actualFind = oldLines.slice(matchStart, matchStart + findLines.length).join('\n');
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 = op.find.trim().split('\n')[0].trim().slice(0, 60);
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
- for (let i = 0; i < oldLines.length; i++) {
221
- if (firstLine.length > 4 && oldLines[i].includes(firstLine.slice(0, Math.min(30, firstLine.length)))) {
222
- hintLine = i + 1;
223
- break;
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 didn't match use <read_file path="${resolvedPath}" lines="${Math.max(1, hintLine - 2)}-${hintLine + 20}"/> to get EXACT text, then retry.`
228
- : ` Use <search_code pattern="${firstLine.slice(0, 40)}"/> to find the text, then <read_file lines="N-M"/> to read that section.`;
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.replaceAll(actualFind, op.replace);
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 600 lines + hint
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 = 600;
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` +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taj-special/dravix-code",
3
- "version": "1.1.5",
3
+ "version": "1.1.7",
4
4
  "description": "AI-powered coding assistant CLI — Dravix Code",
5
5
  "type": "module",
6
6
  "bin": {