@hapico/cli 0.0.27 → 0.0.29

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.
@@ -1,18 +1,47 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
- import { checkbox, input, editor, confirm } from "@inquirer/prompts";
5
- import { exec } from "node:child_process";
6
- import util from "node:util";
4
+ import { diff_match_patch } from "diff-match-patch";
7
5
  import express from "express";
8
6
  import open from "open";
9
- // import net
10
- import os from "os";
11
-
12
- const execAsync = util.promisify(exec);
13
7
  import net from "node:net";
14
8
 
9
+ const scanRecursively = async (
10
+ dir: string,
11
+ baseDir: string
12
+ ): Promise<FileNode[]> => {
13
+ let results: FileNode[] = [];
14
+ try {
15
+ const list = await fs.readdir(dir, { withFileTypes: true });
15
16
 
17
+ for (const entry of list) {
18
+ const fullPath = path.join(dir, entry.name);
19
+ const relativePath = path.relative(baseDir, fullPath);
20
+
21
+ if (shouldIgnorePath(relativePath)) continue;
22
+
23
+ if (entry.isDirectory()) {
24
+ const subResults = await scanRecursively(fullPath, baseDir);
25
+ results = results.concat(subResults);
26
+ } else if (entry.isFile()) {
27
+ try {
28
+ const content = await fs.readFile(fullPath, "utf-8");
29
+ results.push({
30
+ path: relativePath,
31
+ fullPath: fullPath,
32
+ content: content,
33
+ extension: entry.name.split(".").pop() || "txt",
34
+ });
35
+ } catch (err) {
36
+ // Bỏ qua file nhị phân hoặc không đọc được
37
+ }
38
+ }
39
+ }
40
+ } catch (error) {
41
+ // Bỏ qua nếu lỗi permission hoặc path không tồn tại
42
+ }
43
+ return results;
44
+ };
16
45
 
17
46
  // random port from 3000-3999
18
47
  // Check if port is available would be better in real-world usage
@@ -94,118 +123,261 @@ const shouldIgnorePath = (filePath: string): boolean => {
94
123
  return false;
95
124
  };
96
125
 
97
- const scanRecursively = async (
98
- dir: string,
99
- baseDir: string
100
- ): Promise<FileNode[]> => {
101
- let results: FileNode[] = [];
102
- try {
103
- const list = await fs.readdir(dir, { withFileTypes: true });
126
+ /**
127
+ * ------------------------------------------------------------------
128
+ * TYPES
129
+ * ------------------------------------------------------------------
130
+ */
131
+ interface PatchBlock {
132
+ search: string;
133
+ replace: string;
134
+ }
104
135
 
105
- for (const entry of list) {
106
- const fullPath = path.join(dir, entry.name);
107
- const relativePath = path.relative(baseDir, fullPath);
136
+ /**
137
+ * ------------------------------------------------------------------
138
+ * HELPER: STRING SIMILARITY (Levenshtein based)
139
+ * Tính độ giống nhau giữa 2 chuỗi (0 -> 1)
140
+ * ------------------------------------------------------------------
141
+ */
142
+ const calculateSimilarity = (s1: string, s2: string): number => {
143
+ const longer = s1.length > s2.length ? s1 : s2;
144
+ const shorter = s1.length > s2.length ? s2 : s1;
145
+ const longerLength = longer.length;
146
+ if (longerLength === 0) {
147
+ return 1.0;
148
+ }
149
+
150
+ // Sử dụng dmp để tính Levenshtein distance cho chính xác và nhanh
151
+ const dmp = new diff_match_patch();
152
+ const diffs = dmp.diff_main(longer, shorter);
153
+ const levenshtein = dmp.diff_levenshtein(diffs);
154
+
155
+ return (longerLength - levenshtein) / longerLength;
156
+ };
108
157
 
109
- if (shouldIgnorePath(relativePath)) continue;
158
+ /**
159
+ * ------------------------------------------------------------------
160
+ * HELPER: FUZZY LINE MATCHING
161
+ * Tìm đoạn code khớp nhất trong file gốc dựa trên điểm số (Confidence)
162
+ * ------------------------------------------------------------------
163
+ */
164
+ const applyFuzzyLineMatch = (
165
+ original: string,
166
+ search: string,
167
+ replace: string,
168
+ threshold: number = 0.85 // Ngưỡng chấp nhận (85% giống nhau)
169
+ ): { result: string | null; confidence: number } => {
170
+ const originalLines = original.split(/\r?\n/);
171
+ const searchLines = search.split(/\r?\n/);
172
+
173
+ // Lọc bỏ dòng trống ở đầu/cuối search block để match chính xác hơn
174
+ while (searchLines.length > 0 && searchLines[0].trim() === "") searchLines.shift();
175
+ while (searchLines.length > 0 && searchLines[searchLines.length - 1].trim() === "") searchLines.pop();
176
+
177
+ if (searchLines.length === 0) return { result: null, confidence: 0 };
178
+
179
+ let bestMatchIndex = -1;
180
+ let bestMatchScore = 0;
181
+
182
+ // Duyệt qua file gốc (Sliding Window)
183
+ // Chỉ cần duyệt đến vị trí mà số dòng còn lại đủ để chứa searchLines
184
+ for (let i = 0; i <= originalLines.length - searchLines.length; i++) {
185
+ let currentScoreTotal = 0;
186
+ let possible = true;
110
187
 
111
- if (entry.isDirectory()) {
112
- const subResults = await scanRecursively(fullPath, baseDir);
113
- results = results.concat(subResults);
114
- } else if (entry.isFile()) {
115
- try {
116
- const content = await fs.readFile(fullPath, "utf-8");
117
- results.push({
118
- path: relativePath,
119
- fullPath: fullPath,
120
- content: content,
121
- extension: entry.name.split(".").pop() || "txt",
122
- });
123
- } catch (err) {
124
- // Bỏ qua file nhị phân hoặc không đọc được
125
- }
188
+ for (let j = 0; j < searchLines.length; j++) {
189
+ const originalLine = originalLines[i + j].trim();
190
+ const searchLine = searchLines[j].trim();
191
+
192
+ // So sánh nhanh: Nếu giống hệt nhau
193
+ if (originalLine === searchLine) {
194
+ currentScoreTotal += 1;
195
+ continue;
196
+ }
197
+
198
+ // So sánh chậm: Tính độ tương đồng
199
+ const similarity = calculateSimilarity(originalLine, searchLine);
200
+
201
+ // Nếu một dòng quá khác biệt (< 60%), coi như block này không khớp
202
+ // (Giúp tối ưu hiệu năng, cắt nhánh sớm)
203
+ if (similarity < 0.6) {
204
+ possible = false;
205
+ break;
126
206
  }
207
+ currentScoreTotal += similarity;
127
208
  }
128
- } catch (error) {
129
- // Bỏ qua nếu lỗi permission hoặc path không tồn tại
130
- }
131
- return results;
132
- };
133
209
 
134
- const areLinesFuzzyEqual = (line1: string, line2: string): boolean => {
135
- return line1.trim() === line2.trim();
136
- };
210
+ if (possible) {
211
+ const avgScore = currentScoreTotal / searchLines.length;
212
+ if (avgScore > bestMatchScore) {
213
+ bestMatchScore = avgScore;
214
+ bestMatchIndex = i;
215
+ }
216
+ }
217
+ }
137
218
 
138
- const performFuzzyReplace = (
139
- originalContent: string,
140
- searchBlock: string,
141
- replaceBlock: string
142
- ): string | null => {
143
- const originalLines = originalContent.split(/\r?\n/);
144
- const searchLines = searchBlock.split(/\r?\n/);
145
-
146
- while (searchLines.length > 0 && searchLines[0].trim() === "") {
147
- searchLines.shift();
219
+ // LOG CONFIDENCE ĐỂ DEBUG
220
+ if (bestMatchScore > 0) {
221
+ console.log(`[FuzzyMatch] Best match score: ${(bestMatchScore * 100).toFixed(2)}% at line ${bestMatchIndex + 1}`);
148
222
  }
149
- while (
150
- searchLines.length > 0 &&
151
- searchLines[searchLines.length - 1].trim() === ""
152
- ) {
153
- searchLines.pop();
223
+
224
+ // Nếu tìm thấy vị trí có độ tin cậy cao hơn ngưỡng
225
+ if (bestMatchIndex !== -1 && bestMatchScore >= threshold) {
226
+ const before = originalLines.slice(0, bestMatchIndex);
227
+ // Lưu ý: Đoạn replace thay thế đúng số lượng dòng của searchLines
228
+ // (Giả định search và match trong original có cùng số dòng hiển thị)
229
+ const after = originalLines.slice(bestMatchIndex + searchLines.length);
230
+
231
+ return {
232
+ result: [...before, replace, ...after].join("\n"),
233
+ confidence: bestMatchScore
234
+ };
154
235
  }
155
236
 
156
- if (searchLines.length === 0) return null;
237
+ return { result: null, confidence: bestMatchScore };
238
+ };
157
239
 
158
- for (let i = 0; i <= originalLines.length - searchLines.length; i++) {
159
- let match = true;
160
- for (let j = 0; j < searchLines.length; j++) {
161
- if (!areLinesFuzzyEqual(originalLines[i + j], searchLines[j])) {
162
- match = false;
163
- break;
164
- }
240
+ /**
241
+ * ------------------------------------------------------------------
242
+ * 1. STATE MACHINE PARSER (FILE-AWARE)
243
+ * ------------------------------------------------------------------
244
+ */
245
+ const parseLLMPatch = (patchContent: string): Record<string, PatchBlock[]> => {
246
+ const lines = patchContent.split(/\r?\n/);
247
+ const filePatches: Record<string, PatchBlock[]> = {};
248
+
249
+ let currentFile = "unknown";
250
+ let inSearch = false;
251
+ let inReplace = false;
252
+ let searchLines: string[] = [];
253
+ let replaceLines: string[] = [];
254
+
255
+ const isFileMarker = (line: string) => {
256
+ const match = line.match(/^### File:\s*[`']?([^`'\s]+)[`']?/i);
257
+ return match ? match[1] : null;
258
+ };
259
+ const isSearchMarker = (line: string) => /^\s*<{7,}\s*SEARCH\s*$/.test(line);
260
+ const isDividerMarker = (line: string) => /^\s*={7,}\s*$/.test(line);
261
+ const isEndMarker = (line: string) => /^\s*>{7,}\s*REPLACE\s*$/.test(line);
262
+
263
+ for (const line of lines) {
264
+ const fileName = isFileMarker(line);
265
+ if (fileName) {
266
+ currentFile = fileName;
267
+ continue;
165
268
  }
166
- if (match) {
167
- const before = originalLines.slice(0, i).join("\n");
168
- const after = originalLines.slice(i + searchLines.length).join("\n");
169
- return `${before}\n${replaceBlock}\n${after}`;
269
+
270
+ if (isSearchMarker(line)) {
271
+ inSearch = true;
272
+ inReplace = false;
273
+ searchLines = [];
274
+ replaceLines = [];
275
+ continue;
276
+ }
277
+ if (isDividerMarker(line)) {
278
+ if (inSearch) { inSearch = false; inReplace = true; }
279
+ continue;
170
280
  }
281
+ if (isEndMarker(line)) {
282
+ if (inReplace || inSearch) {
283
+ if (searchLines.length > 0 || replaceLines.length > 0) {
284
+ if (!filePatches[currentFile]) filePatches[currentFile] = [];
285
+ filePatches[currentFile].push({
286
+ search: searchLines.join("\n"),
287
+ replace: replaceLines.join("\n")
288
+ });
289
+ }
290
+ }
291
+ inSearch = false; inReplace = false; searchLines = []; replaceLines = [];
292
+ continue;
293
+ }
294
+ if (inSearch) searchLines.push(line);
295
+ else if (inReplace) replaceLines.push(line);
171
296
  }
172
- return null;
297
+ return filePatches;
173
298
  };
174
299
 
175
- const applyPatch = (originalContent: string, patchContent: string): string => {
300
+ /**
301
+ * ------------------------------------------------------------------
302
+ * 2. APPLY PATCH (UPDATED)
303
+ * ------------------------------------------------------------------
304
+ */
305
+ export const applyPatch = (
306
+ originalContent: string,
307
+ patches: PatchBlock[]
308
+ ): string => {
309
+ if (!patches || patches.length === 0) return originalContent;
310
+
176
311
  let result = originalContent;
177
- const patchRegex =
178
- /<<<<<<< SEARCH\s*\n([\s\S]*?)\n?=======\s*\n([\s\S]*?)\n?>>>>>>> REPLACE/g;
179
- const matches = [...patchContent.matchAll(patchRegex)];
180
312
 
181
- if (matches.length === 0) {
182
- return originalContent;
183
- }
313
+ for (const patch of patches) {
314
+ const { search, replace } = patch;
184
315
 
185
- for (const match of matches) {
186
- const [_, searchBlock, replaceBlock] = match;
316
+ // NẾU SEARCH RỖNG (INSERT/APPEND)
317
+ if (search.trim() === "") {
318
+ console.log(`[ApplyPatch] Inserting/appending block.`);
319
+ result += `\n${replace}`;
320
+ continue;
321
+ }
187
322
 
188
- if (result.includes(searchBlock)) {
189
- result = result.replace(searchBlock, replaceBlock);
323
+ // --- STRATEGY 1: EXACT MATCH ---
324
+ if (result.includes(search)) {
325
+ console.log(`[ApplyPatch] Exact match found.`);
326
+ result = result.replace(search, replace);
190
327
  continue;
191
328
  }
192
329
 
193
- const trimmedSearch = searchBlock.trim();
194
- const trimmedReplace = replaceBlock.trim();
330
+ // --- STRATEGY 2: NORMALIZED MATCH (Trim) ---
331
+ const trimmedSearch = search.trim();
195
332
  if (result.includes(trimmedSearch)) {
196
- result = result.replace(trimmedSearch, trimmedReplace);
333
+ console.log(`[ApplyPatch] Trimmed match found.`);
334
+ result = result.replace(trimmedSearch, replace.trim());
197
335
  continue;
198
336
  }
337
+
338
+ // --- STRATEGY 3: FUZZY LINE MATCH (BEST FOR CODE) ---
339
+ // Tìm vị trí tốt nhất dựa trên similarity score của từng dòng
340
+ const fuzzyMatch = applyFuzzyLineMatch(result, search, replace, 0.80); // 80% confidence
341
+ if (fuzzyMatch.result !== null) {
342
+ console.log(`[ApplyPatch] Fuzzy line match applied (Confidence: ${(fuzzyMatch.confidence * 100).toFixed(0)}%)`);
343
+ result = fuzzyMatch.result;
344
+ continue;
345
+ } else {
346
+ console.warn(`[ApplyPatch] Failed to match block. Max confidence was: ${(fuzzyMatch.confidence * 100).toFixed(0)}%`);
347
+ }
199
348
 
200
- const fuzzyResult = performFuzzyReplace(result, searchBlock, replaceBlock);
201
- if (fuzzyResult) {
202
- result = fuzzyResult;
203
- continue;
349
+ // --- STRATEGY 4: DMP FALLBACK (LAST RESORT) ---
350
+ // Nếu cả fuzzy line match cũng tạch (ví dụ do search block quá ngắn hoặc cấu trúc quá nát)
351
+ // thì mới dùng DMP patch_apply.
352
+ try {
353
+ const dmp = new diff_match_patch();
354
+ dmp.Match_Threshold = 0.5;
355
+ dmp.Match_Distance = 1000;
356
+
357
+ const dmpPatches = dmp.patch_make(search, replace);
358
+ const [newText, applyResults] = dmp.patch_apply(dmpPatches, result);
359
+
360
+ // Kiểm tra xem có apply được hết các hunk không
361
+ const successCount = applyResults.filter(Boolean).length;
362
+ const successRate = successCount / applyResults.length;
363
+
364
+ if (successRate === 1 && newText !== result) {
365
+ console.log(`[ApplyPatch] DMP Patch applied via fuzzy logic.`);
366
+ result = newText;
367
+ } else {
368
+ console.error(`[ApplyPatch] All strategies failed for block starting with: ${search.substring(0, 30)}...`);
369
+ // Tuỳ chọn: Throw error để báo cho LLM biết là apply thất bại
370
+ // throw new Error("Could not apply patch block");
371
+ }
372
+ } catch (e) {
373
+ console.error(`[ApplyPatch] DMP error:`, e);
204
374
  }
205
375
  }
376
+
206
377
  return result;
207
378
  };
208
379
 
380
+
209
381
  // ==========================================
210
382
  // 3. WEB UI SERVER (UPDATED UI)
211
383
  // ==========================================
@@ -238,18 +410,50 @@ const startServer = async (rootDir: string) => {
238
410
 
239
411
  // API 3: Apply Patch
240
412
  app.post("/api/apply", async (req, res) => {
241
- const { llmResponse, filePaths } = req.body;
413
+ const { llmResponse, filePaths: selectedPaths } = req.body;
414
+
415
+ // 1. Parse all patches from LLM response
416
+ const filePatches = parseLLMPatch(llmResponse);
417
+
418
+ // 2. Scan project files
242
419
  const allFiles = await scanRecursively(rootDir, rootDir);
243
- const selectedFiles = allFiles.filter((f) => filePaths.includes(f.path));
244
-
245
420
  const modifiedFiles: string[] = [];
246
- for (const file of selectedFiles) {
247
- const newContent = applyPatch(file.content, llmResponse);
248
- if (newContent !== file.content) {
249
- await fs.writeFile(file.fullPath, newContent, "utf-8");
250
- modifiedFiles.push(file.path);
421
+
422
+ // 3. Match patches to files and apply
423
+ for (const file of allFiles) {
424
+ // Find patches meant for this specific file path
425
+ // Note: LLM might use different path separators, so we normalize
426
+ const normalizedPath = file.path.replace(/\\/g, '/');
427
+ const patchesForFile = Object.entries(filePatches).find(([path]) => {
428
+ const normalizedKey = path.replace(/\\/g, '/');
429
+ return normalizedKey === normalizedPath || normalizedPath.endsWith('/' + normalizedKey);
430
+ })?.[1];
431
+
432
+ // Only apply if the file was selected in UI AND has patches in the response
433
+ if (selectedPaths.includes(file.path) && patchesForFile) {
434
+ console.log(`[Apply] Processing ${patchesForFile.length} blocks for: ${file.path}`);
435
+ const newContent = applyPatch(file.content, patchesForFile);
436
+
437
+ if (newContent !== file.content) {
438
+ await fs.writeFile(file.fullPath, newContent, "utf-8");
439
+ modifiedFiles.push(file.path);
440
+ }
251
441
  }
252
442
  }
443
+
444
+ // Fallback: If no matched files but only one file was selected, try applying "unknown" patches
445
+ if (modifiedFiles.length === 0 && selectedPaths.length === 1 && filePatches["unknown"]) {
446
+ const file = allFiles.find(f => f.path === selectedPaths[0]);
447
+ if (file) {
448
+ console.log(`[Apply] Fallback: Applying unknown blocks to single selected file: ${file.path}`);
449
+ const newContent = applyPatch(file.content, filePatches["unknown"]);
450
+ if (newContent !== file.content) {
451
+ await fs.writeFile(file.fullPath, newContent, "utf-8");
452
+ modifiedFiles.push(file.path);
453
+ }
454
+ }
455
+ }
456
+
253
457
  res.json({ modified: modifiedFiles });
254
458
  });
255
459
 
@@ -376,7 +580,8 @@ const startServer = async (rootDir: string) => {
376
580
 
377
581
  <div class="flex-1 flex flex-col bg-gray-850 min-w-0">
378
582
 
379
- <div class="flex-shrink-0 p-6 border-b border-apple-border">
583
+ <!-- 1. Instruction (Takes more space and resizable) -->
584
+ <div class="flex-1 p-6 flex flex-col min-h-0 border-b border-apple-border">
380
585
  <div class="flex justify-between items-end mb-3">
381
586
  <div>
382
587
  <h2 class="text-sm font-semibold text-gray-200">1. Instruction</h2>
@@ -392,12 +597,12 @@ const startServer = async (rootDir: string) => {
392
597
  </button>
393
598
  </div>
394
599
  <textarea x-model="instruction"
395
- class="w-full bg-apple-panel border border-apple-border rounded p-3 text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-apple-blue/50 focus:ring-1 focus:ring-apple-blue/50 transition resize-none font-mono"
396
- rows="3"
600
+ class="w-full flex-1 bg-apple-panel border border-apple-border rounded p-3 text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-apple-blue/50 focus:ring-1 focus:ring-apple-blue/50 transition resize-y font-mono min-h-[120px]"
397
601
  placeholder="e.g. Refactor the Button component to use TypeScript interfaces..."></textarea>
398
602
  </div>
399
603
 
400
- <div class="flex-1 p-6 flex flex-col min-h-0">
604
+ <!-- 2. LLM Patch (Fixed smaller size) -->
605
+ <div class="h-64 p-6 flex flex-col flex-shrink-0">
401
606
  <div class="flex justify-between items-end mb-3">
402
607
  <div>
403
608
  <h2 class="text-sm font-semibold text-gray-200">2. LLM Patch</h2>
@@ -410,11 +615,9 @@ const startServer = async (rootDir: string) => {
410
615
  <span x-show="applying">Applying...</span>
411
616
  </button>
412
617
  </div>
413
- <div class="flex-1 relative">
414
- <textarea x-model="llmResponse"
415
- class="absolute inset-0 w-full h-full bg-apple-panel border border-apple-border rounded p-3 text-sm text-gray-300 font-mono placeholder-gray-600 focus:outline-none focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/50 transition resize-none leading-relaxed"
416
- placeholder="<<<<<<< SEARCH..."></textarea>
417
- </div>
618
+ <textarea x-model="llmResponse"
619
+ class="w-full h-40 bg-apple-panel border border-apple-border rounded p-3 text-sm text-gray-300 font-mono placeholder-gray-600 focus:outline-none focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/50 transition resize-none leading-relaxed"
620
+ placeholder="<<<<<<< SEARCH..."></textarea>
418
621
  </div>
419
622
 
420
623
  </div>