@acmecloud/core 1.0.2
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/agent/index.d.ts +52 -0
- package/dist/agent/index.js +476 -0
- package/dist/config/index.d.ts +83 -0
- package/dist/config/index.js +318 -0
- package/dist/context/index.d.ts +1 -0
- package/dist/context/index.js +30 -0
- package/dist/llm/provider.d.ts +27 -0
- package/dist/llm/provider.js +202 -0
- package/dist/llm/vision.d.ts +7 -0
- package/dist/llm/vision.js +37 -0
- package/dist/mcp/index.d.ts +10 -0
- package/dist/mcp/index.js +84 -0
- package/dist/prompt/anthropic.d.ts +1 -0
- package/dist/prompt/anthropic.js +32 -0
- package/dist/prompt/architect.d.ts +1 -0
- package/dist/prompt/architect.js +17 -0
- package/dist/prompt/autopilot.d.ts +1 -0
- package/dist/prompt/autopilot.js +18 -0
- package/dist/prompt/beast.d.ts +1 -0
- package/dist/prompt/beast.js +83 -0
- package/dist/prompt/gemini.d.ts +1 -0
- package/dist/prompt/gemini.js +45 -0
- package/dist/prompt/index.d.ts +18 -0
- package/dist/prompt/index.js +239 -0
- package/dist/prompt/zen.d.ts +1 -0
- package/dist/prompt/zen.js +13 -0
- package/dist/session/index.d.ts +18 -0
- package/dist/session/index.js +97 -0
- package/dist/skills/index.d.ts +6 -0
- package/dist/skills/index.js +72 -0
- package/dist/tools/batch.d.ts +2 -0
- package/dist/tools/batch.js +65 -0
- package/dist/tools/browser.d.ts +7 -0
- package/dist/tools/browser.js +86 -0
- package/dist/tools/edit.d.ts +11 -0
- package/dist/tools/edit.js +312 -0
- package/dist/tools/index.d.ts +13 -0
- package/dist/tools/index.js +980 -0
- package/dist/tools/lsp-client.d.ts +11 -0
- package/dist/tools/lsp-client.js +224 -0
- package/package.json +42 -0
- package/src/agent/index.ts +588 -0
- package/src/config/index.ts +383 -0
- package/src/context/index.ts +34 -0
- package/src/llm/provider.ts +237 -0
- package/src/llm/vision.ts +43 -0
- package/src/mcp/index.ts +110 -0
- package/src/prompt/anthropic.ts +32 -0
- package/src/prompt/architect.ts +17 -0
- package/src/prompt/autopilot.ts +18 -0
- package/src/prompt/beast.ts +83 -0
- package/src/prompt/gemini.ts +45 -0
- package/src/prompt/index.ts +267 -0
- package/src/prompt/zen.ts +13 -0
- package/src/session/index.ts +129 -0
- package/src/skills/index.ts +86 -0
- package/src/tools/batch.ts +73 -0
- package/src/tools/browser.ts +95 -0
- package/src/tools/edit.ts +317 -0
- package/src/tools/index.ts +1112 -0
- package/src/tools/lsp-client.ts +303 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import * as fs from 'fs';
|
|
5
|
+
|
|
6
|
+
let db: Database.Database | null = null;
|
|
7
|
+
const DB_DIR = path.join(os.homedir(), '.acmecode');
|
|
8
|
+
const DB_PATH = path.join(DB_DIR, 'sessions.db');
|
|
9
|
+
|
|
10
|
+
export interface Session {
|
|
11
|
+
id: string;
|
|
12
|
+
title: string;
|
|
13
|
+
updated_at: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SessionMessage {
|
|
17
|
+
id: number;
|
|
18
|
+
session_id: string;
|
|
19
|
+
role: string;
|
|
20
|
+
content_json: string;
|
|
21
|
+
created_at: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function initDb() {
|
|
25
|
+
if (db) return;
|
|
26
|
+
|
|
27
|
+
if (!fs.existsSync(DB_DIR)) {
|
|
28
|
+
fs.mkdirSync(DB_DIR, { recursive: true });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
db = new Database(DB_PATH);
|
|
32
|
+
|
|
33
|
+
db.exec(`
|
|
34
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
35
|
+
id TEXT PRIMARY KEY,
|
|
36
|
+
title TEXT NOT NULL,
|
|
37
|
+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
41
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
42
|
+
session_id TEXT NOT NULL,
|
|
43
|
+
role TEXT NOT NULL,
|
|
44
|
+
content_json TEXT NOT NULL,
|
|
45
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
46
|
+
FOREIGN KEY(session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
|
47
|
+
);
|
|
48
|
+
`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function createSession(id: string, title?: string): Session {
|
|
52
|
+
initDb();
|
|
53
|
+
const titleToUse = title || `Session ${new Date().toLocaleString()}`;
|
|
54
|
+
const stmt = db!.prepare('INSERT INTO sessions (id, title) VALUES (?, ?)');
|
|
55
|
+
stmt.run(id, titleToUse);
|
|
56
|
+
|
|
57
|
+
return { id, title: titleToUse, updated_at: new Date().toISOString() };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function saveMessages(sessionId: string, messages: any[]) {
|
|
61
|
+
initDb();
|
|
62
|
+
|
|
63
|
+
// Clear existing messages for this session to overwrite with the updated array
|
|
64
|
+
// This is a simple strategy since the messages array grows append-only
|
|
65
|
+
const deleteStmt = db!.prepare('DELETE FROM messages WHERE session_id = ?');
|
|
66
|
+
deleteStmt.run(sessionId);
|
|
67
|
+
|
|
68
|
+
const insertStmt = db!.prepare('INSERT INTO messages (session_id, role, content_json) VALUES (?, ?, ?)');
|
|
69
|
+
|
|
70
|
+
const insertMany = db!.transaction((msgs) => {
|
|
71
|
+
for (const msg of msgs) {
|
|
72
|
+
insertStmt.run(sessionId, msg.role, JSON.stringify(msg.content));
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
insertMany(messages);
|
|
77
|
+
|
|
78
|
+
// Auto-generate session title from the first user message
|
|
79
|
+
const userMsgs = messages.filter(m => m.role === 'user');
|
|
80
|
+
if (userMsgs.length === 1 && userMsgs[0]?.content) {
|
|
81
|
+
let firstText = '';
|
|
82
|
+
if (typeof userMsgs[0].content === 'string') firstText = userMsgs[0].content;
|
|
83
|
+
else if (Array.isArray(userMsgs[0].content)) {
|
|
84
|
+
firstText = userMsgs[0].content.find((c: any) => c.type === 'text')?.text || '';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (firstText) {
|
|
88
|
+
// Remove slash commands like /skill foo
|
|
89
|
+
const cleanText = firstText.replace(/^\/\w+\s*/, '').trim();
|
|
90
|
+
if (cleanText) {
|
|
91
|
+
const newTitle = cleanText.length > 30 ? cleanText.slice(0, 27) + '...' : cleanText;
|
|
92
|
+
|
|
93
|
+
// Only update if it's currently a default generated timestamp title
|
|
94
|
+
const row = db!.prepare('SELECT title FROM sessions WHERE id = ?').get(sessionId) as { title: string } | undefined;
|
|
95
|
+
if (row && row.title.startsWith('Session ')) {
|
|
96
|
+
const updateTitleStmt = db!.prepare('UPDATE sessions SET title = ? WHERE id = ?');
|
|
97
|
+
updateTitleStmt.run(newTitle, sessionId);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Update session timestamp
|
|
104
|
+
const updateStmt = db!.prepare('UPDATE sessions SET updated_at = CURRENT_TIMESTAMP WHERE id = ?');
|
|
105
|
+
updateStmt.run(sessionId);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function loadSession(sessionId: string): any[] {
|
|
109
|
+
initDb();
|
|
110
|
+
const stmt = db!.prepare('SELECT role, content_json FROM messages WHERE session_id = ? ORDER BY id ASC');
|
|
111
|
+
const rows = stmt.all(sessionId) as { role: string, content_json: string }[];
|
|
112
|
+
|
|
113
|
+
return rows.map(row => ({
|
|
114
|
+
role: row.role,
|
|
115
|
+
content: JSON.parse(row.content_json)
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function listSessions(): Session[] {
|
|
120
|
+
initDb();
|
|
121
|
+
const stmt = db!.prepare('SELECT id, title, updated_at FROM sessions ORDER BY updated_at DESC');
|
|
122
|
+
return stmt.all() as Session[];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function deleteSession(sessionId: string) {
|
|
126
|
+
initDb();
|
|
127
|
+
const stmt = db!.prepare('DELETE FROM sessions WHERE id = ?');
|
|
128
|
+
stmt.run(sessionId);
|
|
129
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import * as fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
|
|
5
|
+
export interface Skill {
|
|
6
|
+
name: string;
|
|
7
|
+
description: string;
|
|
8
|
+
content: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Searches upward from startDir for the given targets.
|
|
13
|
+
*/
|
|
14
|
+
async function findUpDirectories(startDir: string, targets: string[]): Promise<string[]> {
|
|
15
|
+
const results: string[] = [];
|
|
16
|
+
let current = path.resolve(startDir);
|
|
17
|
+
const root = path.parse(current).root;
|
|
18
|
+
|
|
19
|
+
while (true) {
|
|
20
|
+
for (const target of targets) {
|
|
21
|
+
const fullPath = path.join(current, target);
|
|
22
|
+
try {
|
|
23
|
+
const stat = await fs.stat(fullPath);
|
|
24
|
+
if (stat.isDirectory()) {
|
|
25
|
+
results.push(fullPath);
|
|
26
|
+
}
|
|
27
|
+
} catch {
|
|
28
|
+
// Ignore
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (current === root) break;
|
|
32
|
+
current = path.dirname(current);
|
|
33
|
+
}
|
|
34
|
+
return results;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function loadSkills(): Promise<Skill[]> {
|
|
38
|
+
const globalSkillsPath = path.join(os.homedir(), '.acmecode', 'skills');
|
|
39
|
+
|
|
40
|
+
// OpenCode patterns: .agents/skills, .claude/skills, .acmecode/skills
|
|
41
|
+
const searchTargets = [
|
|
42
|
+
'.acmecode/skills',
|
|
43
|
+
'.agents/skills',
|
|
44
|
+
'.claude/skills',
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
const localSkillDirs = await findUpDirectories(process.cwd(), searchTargets);
|
|
48
|
+
|
|
49
|
+
// Priority: Lower directories in the tree (closer to project root) are processed first,
|
|
50
|
+
// and higher directories (closer to CWD) are processed last to overwrite.
|
|
51
|
+
// Global is processed first of all.
|
|
52
|
+
const allDirs = [globalSkillsPath, ...localSkillDirs.reverse()];
|
|
53
|
+
const skillsMap = new Map<string, Skill>();
|
|
54
|
+
|
|
55
|
+
for (const dir of allDirs) {
|
|
56
|
+
try {
|
|
57
|
+
const files = await fs.readdir(dir);
|
|
58
|
+
for (const file of files) {
|
|
59
|
+
if (file.endsWith('.md')) {
|
|
60
|
+
const content = await fs.readFile(path.join(dir, file), 'utf8');
|
|
61
|
+
let name = file.replace('.md', '');
|
|
62
|
+
|
|
63
|
+
// Improved parsing: Look for name and description in YAML frontmatter or content
|
|
64
|
+
let description = `Skill ${name}`;
|
|
65
|
+
|
|
66
|
+
// Try to extract name from frontmatter
|
|
67
|
+
const nameMatch = content.match(/^name:\s*(.*)/m);
|
|
68
|
+
if (nameMatch) name = nameMatch[1].trim();
|
|
69
|
+
|
|
70
|
+
const descriptionMatch = content.match(/^description:\s*(.*)/m);
|
|
71
|
+
if (descriptionMatch) {
|
|
72
|
+
description = descriptionMatch[1].trim();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
skillsMap.set(name, { name, description, content });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
} catch (err: any) {
|
|
79
|
+
if (err.code !== 'ENOENT') {
|
|
80
|
+
console.warn(`Could not read skills directory ${dir}: ${err.message}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return Array.from(skillsMap.values());
|
|
86
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export const BATCH_WHITELIST = new Set([
|
|
2
|
+
"read_file",
|
|
3
|
+
"webfetch",
|
|
4
|
+
"websearch",
|
|
5
|
+
"codesearch",
|
|
6
|
+
"grep_search",
|
|
7
|
+
"list_dir",
|
|
8
|
+
"lsp",
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
export async function executeBatch(
|
|
12
|
+
args: any,
|
|
13
|
+
toolExecutors: Record<string, (args: any) => Promise<string>>,
|
|
14
|
+
): Promise<string> {
|
|
15
|
+
let calls: any[] = [];
|
|
16
|
+
|
|
17
|
+
// 支持多种格式
|
|
18
|
+
if (Array.isArray(args)) {
|
|
19
|
+
calls = args;
|
|
20
|
+
} else if (args.calls && Array.isArray(args.calls)) {
|
|
21
|
+
calls = args.calls;
|
|
22
|
+
} else if (args.tools) {
|
|
23
|
+
// LLM 可能传 JSON 字符串
|
|
24
|
+
if (typeof args.tools === "string") {
|
|
25
|
+
try {
|
|
26
|
+
calls = JSON.parse(args.tools);
|
|
27
|
+
} catch {
|
|
28
|
+
return "Error: Failed to parse tools JSON string.";
|
|
29
|
+
}
|
|
30
|
+
} else if (Array.isArray(args.tools)) {
|
|
31
|
+
calls = args.tools;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!Array.isArray(calls) || calls.length === 0) {
|
|
36
|
+
return "Error: No tool calls provided in batch.";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const promises = calls.map(async (call, index) => {
|
|
40
|
+
const tool = call.tool || call.name;
|
|
41
|
+
|
|
42
|
+
// 参数可能在 arguments、parameters,或直接在顶层
|
|
43
|
+
let toolArgs = call.arguments || call.parameters;
|
|
44
|
+
if (!toolArgs || typeof toolArgs !== "object") {
|
|
45
|
+
// 提取除了 name/tool 以外的字段作为参数
|
|
46
|
+
const { name, tool: _, ...rest } = call;
|
|
47
|
+
toolArgs = rest;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!tool) {
|
|
51
|
+
return `[Call ${index + 1}] Error: Missing tool name.`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!BATCH_WHITELIST.has(tool)) {
|
|
55
|
+
return `[Call ${index + 1}: ${tool}] Error: Tool "${tool}" is not allowed in batch mode (only idempotent/read tools are permitted).`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const executor = toolExecutors[tool];
|
|
59
|
+
if (!executor) {
|
|
60
|
+
return `[Call ${index + 1}: ${tool}] Error: Tool "${tool}" not found.`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const result = await executor(toolArgs);
|
|
65
|
+
return `[Call ${index + 1}: ${tool}]\n${result}`;
|
|
66
|
+
} catch (err: any) {
|
|
67
|
+
return `[Call ${index + 1}: ${tool}] Error: ${err.message}`;
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const results = await Promise.all(promises);
|
|
72
|
+
return results.join("\n\n" + "─".repeat(40) + "\n\n");
|
|
73
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { chromium, Browser, Page } from 'playwright';
|
|
2
|
+
import { analyzeImage } from '../llm/vision.js';
|
|
3
|
+
import { loadModelConfig } from '../config/index.js';
|
|
4
|
+
import * as fs from 'fs/promises';
|
|
5
|
+
import { existsSync, mkdirSync } from 'fs';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
|
|
8
|
+
let browser: Browser | null = null;
|
|
9
|
+
let page: Page | null = null;
|
|
10
|
+
|
|
11
|
+
async function getBrowser() {
|
|
12
|
+
try {
|
|
13
|
+
if (!browser) {
|
|
14
|
+
browser = await chromium.launch({ headless: true });
|
|
15
|
+
}
|
|
16
|
+
if (!page) {
|
|
17
|
+
const context = await browser.newContext({
|
|
18
|
+
viewport: { width: 1280, height: 720 }
|
|
19
|
+
});
|
|
20
|
+
page = await context.newPage();
|
|
21
|
+
}
|
|
22
|
+
return { browser, page };
|
|
23
|
+
} catch (err: any) {
|
|
24
|
+
if (err.message.includes('executable') || err.message.includes('not found')) {
|
|
25
|
+
throw new Error(`Browser not found. Please run: npx playwright install chromium`);
|
|
26
|
+
}
|
|
27
|
+
throw err;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function executeBrowserAction(args: { action: string, url?: string, selector?: string, text?: string }): Promise<string> {
|
|
32
|
+
const { page } = await getBrowser();
|
|
33
|
+
const config = loadModelConfig();
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
switch (args.action) {
|
|
37
|
+
case 'navigate':
|
|
38
|
+
if (!args.url) return 'Error: URL is required for navigate action.';
|
|
39
|
+
await page.goto(args.url, { waitUntil: 'networkidle' });
|
|
40
|
+
const title = await page.title();
|
|
41
|
+
return `Successfully navigated to ${args.url}. Page title: ${title}`;
|
|
42
|
+
|
|
43
|
+
case 'screenshot':
|
|
44
|
+
const screenshot = await page.screenshot({ fullPage: false });
|
|
45
|
+
const base64 = screenshot.toString('base64');
|
|
46
|
+
|
|
47
|
+
// Save to disk
|
|
48
|
+
const screenshotDir = path.resolve(process.cwd(), '.acmecode', 'screenshots');
|
|
49
|
+
if (!existsSync(screenshotDir)) {
|
|
50
|
+
mkdirSync(screenshotDir, { recursive: true });
|
|
51
|
+
}
|
|
52
|
+
const filename = `screenshot_${new Date().toISOString().replace(/[:.]/g, '-')}.png`;
|
|
53
|
+
const filePath = path.join(screenshotDir, filename);
|
|
54
|
+
await fs.writeFile(filePath, screenshot);
|
|
55
|
+
|
|
56
|
+
const stats = `Screenshot saved to: ${path.relative(process.cwd(), filePath)}`;
|
|
57
|
+
|
|
58
|
+
// Delegate to vision model if configured
|
|
59
|
+
if (config.visionModel) {
|
|
60
|
+
const analysis = await analyzeImage(base64, config);
|
|
61
|
+
return `${stats}\n\n${analysis}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return stats;
|
|
65
|
+
|
|
66
|
+
case 'click':
|
|
67
|
+
if (!args.selector) return 'Error: Selector is required for click action.';
|
|
68
|
+
await page.click(args.selector);
|
|
69
|
+
return `Clicked element: ${args.selector}`;
|
|
70
|
+
|
|
71
|
+
case 'type':
|
|
72
|
+
if (!args.selector || !args.text) return 'Error: Selector and text are required for type action.';
|
|
73
|
+
await page.fill(args.selector, args.text);
|
|
74
|
+
return `Typed "${args.text}" into ${args.selector}`;
|
|
75
|
+
|
|
76
|
+
case 'scroll':
|
|
77
|
+
await page.mouse.wheel(0, 500);
|
|
78
|
+
return 'Scrolled down.';
|
|
79
|
+
|
|
80
|
+
default:
|
|
81
|
+
return `Error: Unknown action "${args.action}"`;
|
|
82
|
+
}
|
|
83
|
+
} catch (err: any) {
|
|
84
|
+
return `Browser error: ${err.message}`;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Cleanup function to be called on process exit
|
|
89
|
+
export async function closeBrowser() {
|
|
90
|
+
if (browser) {
|
|
91
|
+
await browser.close();
|
|
92
|
+
browser = null;
|
|
93
|
+
page = null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import * as fs from 'fs/promises';
|
|
3
|
+
|
|
4
|
+
// ── Fuzzy Replacement Logic (Ported from opencode) ──
|
|
5
|
+
|
|
6
|
+
export type Replacer = (content: string, find: string) => Generator<string, void, unknown>;
|
|
7
|
+
|
|
8
|
+
const SINGLE_CANDIDATE_SIMILARITY_THRESHOLD = 0.0;
|
|
9
|
+
const MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD = 0.3;
|
|
10
|
+
|
|
11
|
+
function levenshtein(a: string, b: string): number {
|
|
12
|
+
if (a === "" || b === "") return Math.max(a.length, b.length);
|
|
13
|
+
const matrix = Array.from({ length: a.length + 1 }, (_, i) =>
|
|
14
|
+
Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)),
|
|
15
|
+
);
|
|
16
|
+
for (let i = 1; i <= a.length; i++) {
|
|
17
|
+
for (let j = 1; j <= b.length; j++) {
|
|
18
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
19
|
+
matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return matrix[a.length][b.length];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const SimpleReplacer: Replacer = function* (_content, find) {
|
|
26
|
+
yield find;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const LineTrimmedReplacer: Replacer = function* (content, find) {
|
|
30
|
+
const originalLines = content.split("\n");
|
|
31
|
+
const searchLines = find.split("\n");
|
|
32
|
+
if (searchLines[searchLines.length - 1] === "") searchLines.pop();
|
|
33
|
+
|
|
34
|
+
for (let i = 0; i <= originalLines.length - searchLines.length; i++) {
|
|
35
|
+
let matches = true;
|
|
36
|
+
for (let j = 0; j < searchLines.length; j++) {
|
|
37
|
+
if (originalLines[i + j].trim() !== searchLines[j].trim()) {
|
|
38
|
+
matches = false;
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (matches) {
|
|
43
|
+
let matchStartIndex = 0;
|
|
44
|
+
for (let k = 0; k < i; k++) matchStartIndex += originalLines[k].length + 1;
|
|
45
|
+
let matchEndIndex = matchStartIndex;
|
|
46
|
+
for (let k = 0; k < searchLines.length; k++) {
|
|
47
|
+
matchEndIndex += originalLines[i + k].length;
|
|
48
|
+
if (k < searchLines.length - 1) matchEndIndex += 1;
|
|
49
|
+
}
|
|
50
|
+
yield content.substring(matchStartIndex, matchEndIndex);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const BlockAnchorReplacer: Replacer = function* (content, find) {
|
|
56
|
+
const originalLines = content.split("\n");
|
|
57
|
+
const searchLines = find.split("\n");
|
|
58
|
+
if (searchLines.length < 3) return;
|
|
59
|
+
if (searchLines[searchLines.length - 1] === "") searchLines.pop();
|
|
60
|
+
|
|
61
|
+
const firstLineSearch = searchLines[0].trim();
|
|
62
|
+
const lastLineSearch = searchLines[searchLines.length - 1].trim();
|
|
63
|
+
const searchBlockSize = searchLines.length;
|
|
64
|
+
|
|
65
|
+
const candidates: Array<{ startLine: number; endLine: number }> = [];
|
|
66
|
+
for (let i = 0; i < originalLines.length; i++) {
|
|
67
|
+
if (originalLines[i].trim() !== firstLineSearch) continue;
|
|
68
|
+
for (let j = i + 2; j < originalLines.length; j++) {
|
|
69
|
+
if (originalLines[j].trim() === lastLineSearch) {
|
|
70
|
+
candidates.push({ startLine: i, endLine: j });
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (candidates.length === 0) return;
|
|
77
|
+
|
|
78
|
+
if (candidates.length === 1) {
|
|
79
|
+
const { startLine, endLine } = candidates[0];
|
|
80
|
+
const actualBlockSize = endLine - startLine + 1;
|
|
81
|
+
let similarity = 0;
|
|
82
|
+
let linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2);
|
|
83
|
+
|
|
84
|
+
if (linesToCheck > 0) {
|
|
85
|
+
for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {
|
|
86
|
+
const originalLine = originalLines[startLine + j].trim();
|
|
87
|
+
const searchLine = searchLines[j].trim();
|
|
88
|
+
const maxLen = Math.max(originalLine.length, searchLine.length);
|
|
89
|
+
if (maxLen === 0) continue;
|
|
90
|
+
const distance = levenshtein(originalLine, searchLine);
|
|
91
|
+
similarity += (1 - distance / maxLen) / linesToCheck;
|
|
92
|
+
if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) break;
|
|
93
|
+
}
|
|
94
|
+
} else {
|
|
95
|
+
similarity = 1.0;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) {
|
|
99
|
+
let matchStartIndex = 0;
|
|
100
|
+
for (let k = 0; k < startLine; k++) matchStartIndex += originalLines[k].length + 1;
|
|
101
|
+
let matchEndIndex = matchStartIndex;
|
|
102
|
+
for (let k = startLine; k <= endLine; k++) {
|
|
103
|
+
matchEndIndex += originalLines[k].length;
|
|
104
|
+
if (k < endLine) matchEndIndex += 1;
|
|
105
|
+
}
|
|
106
|
+
yield content.substring(matchStartIndex, matchEndIndex);
|
|
107
|
+
}
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let bestMatch: { startLine: number; endLine: number } | null = null;
|
|
112
|
+
let maxSimilarity = -1;
|
|
113
|
+
|
|
114
|
+
for (const candidate of candidates) {
|
|
115
|
+
const { startLine, endLine } = candidate;
|
|
116
|
+
const actualBlockSize = endLine - startLine + 1;
|
|
117
|
+
let similarity = 0;
|
|
118
|
+
let linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2);
|
|
119
|
+
|
|
120
|
+
if (linesToCheck > 0) {
|
|
121
|
+
for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {
|
|
122
|
+
const originalLine = originalLines[startLine + j].trim();
|
|
123
|
+
const searchLine = searchLines[j].trim();
|
|
124
|
+
const maxLen = Math.max(originalLine.length, searchLine.length);
|
|
125
|
+
if (maxLen === 0) continue;
|
|
126
|
+
const distance = levenshtein(originalLine, searchLine);
|
|
127
|
+
similarity += 1 - distance / maxLen;
|
|
128
|
+
}
|
|
129
|
+
similarity /= linesToCheck;
|
|
130
|
+
} else {
|
|
131
|
+
similarity = 1.0;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (similarity > maxSimilarity) {
|
|
135
|
+
maxSimilarity = similarity;
|
|
136
|
+
bestMatch = candidate;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (maxSimilarity >= MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD && bestMatch) {
|
|
141
|
+
const { startLine, endLine } = bestMatch;
|
|
142
|
+
let matchStartIndex = 0;
|
|
143
|
+
for (let k = 0; k < startLine; k++) matchStartIndex += originalLines[k].length + 1;
|
|
144
|
+
let matchEndIndex = matchStartIndex;
|
|
145
|
+
for (let k = startLine; k <= endLine; k++) {
|
|
146
|
+
matchEndIndex += originalLines[k].length;
|
|
147
|
+
if (k < endLine) matchEndIndex += 1;
|
|
148
|
+
}
|
|
149
|
+
yield content.substring(matchStartIndex, matchEndIndex);
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
export const WhitespaceNormalizedReplacer: Replacer = function* (content, find) {
|
|
154
|
+
const normalizeWhitespace = (text: string) => text.replace(/\s+/g, " ").trim();
|
|
155
|
+
const normalizedFind = normalizeWhitespace(find);
|
|
156
|
+
|
|
157
|
+
const lines = content.split("\n");
|
|
158
|
+
for (let i = 0; i < lines.length; i++) {
|
|
159
|
+
const line = lines[i];
|
|
160
|
+
if (normalizeWhitespace(line) === normalizedFind) {
|
|
161
|
+
yield line;
|
|
162
|
+
} else {
|
|
163
|
+
const normalizedLine = normalizeWhitespace(line);
|
|
164
|
+
if (normalizedLine.includes(normalizedFind)) {
|
|
165
|
+
const words = find.trim().split(/\s+/);
|
|
166
|
+
if (words.length > 0) {
|
|
167
|
+
const pattern = words.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\s+");
|
|
168
|
+
try {
|
|
169
|
+
const match = line.match(new RegExp(pattern));
|
|
170
|
+
if (match) yield match[0];
|
|
171
|
+
} catch { }
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const findLines = find.split("\n");
|
|
178
|
+
if (findLines.length > 1) {
|
|
179
|
+
for (let i = 0; i <= lines.length - findLines.length; i++) {
|
|
180
|
+
const block = lines.slice(i, i + findLines.length);
|
|
181
|
+
if (normalizeWhitespace(block.join("\n")) === normalizedFind) {
|
|
182
|
+
yield block.join("\n");
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
export const IndentationFlexibleReplacer: Replacer = function* (content, find) {
|
|
189
|
+
const removeIndentation = (text: string) => {
|
|
190
|
+
const lines = text.split("\n");
|
|
191
|
+
const nonEmptyLines = lines.filter((line) => line.trim().length > 0);
|
|
192
|
+
if (nonEmptyLines.length === 0) return text;
|
|
193
|
+
const minIndent = Math.min(...nonEmptyLines.map(line => line.match(/^(\s*)/)?.[1].length ?? 0));
|
|
194
|
+
return lines.map((line) => (line.trim().length === 0 ? line : line.slice(minIndent))).join("\n");
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const normalizedFind = removeIndentation(find);
|
|
198
|
+
const contentLines = content.split("\n");
|
|
199
|
+
const findLines = find.split("\n");
|
|
200
|
+
|
|
201
|
+
for (let i = 0; i <= contentLines.length - findLines.length; i++) {
|
|
202
|
+
const block = contentLines.slice(i, i + findLines.length).join("\n");
|
|
203
|
+
if (removeIndentation(block) === normalizedFind) yield block;
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
export const EscapeNormalizedReplacer: Replacer = function* (content, find) {
|
|
208
|
+
const unescapeString = (str: string): string => {
|
|
209
|
+
return str.replace(/\\(n|t|r|'|"|`|\\|\n|\$)/g, (match, capturedChar) => {
|
|
210
|
+
switch (capturedChar) {
|
|
211
|
+
case "n": return "\n";
|
|
212
|
+
case "t": return "\t";
|
|
213
|
+
case "r": return "\r";
|
|
214
|
+
default: return capturedChar === match[1] ? match[1] : match;
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
};
|
|
218
|
+
const unescapedFind = unescapeString(find);
|
|
219
|
+
if (content.includes(unescapedFind)) yield unescapedFind;
|
|
220
|
+
|
|
221
|
+
const lines = content.split("\n");
|
|
222
|
+
const findLines = unescapedFind.split("\n");
|
|
223
|
+
for (let i = 0; i <= lines.length - findLines.length; i++) {
|
|
224
|
+
const block = lines.slice(i, i + findLines.length).join("\n");
|
|
225
|
+
if (unescapeString(block) === unescapedFind) yield block;
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
export const MultiOccurrenceReplacer: Replacer = function* (content, find) {
|
|
230
|
+
let startIndex = 0;
|
|
231
|
+
while (true) {
|
|
232
|
+
const index = content.indexOf(find, startIndex);
|
|
233
|
+
if (index === -1) break;
|
|
234
|
+
yield find;
|
|
235
|
+
startIndex = index + find.length;
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
export const TrimmedBoundaryReplacer: Replacer = function* (content, find) {
|
|
240
|
+
const trimmedFind = find.trim();
|
|
241
|
+
if (trimmedFind === find) return;
|
|
242
|
+
if (content.includes(trimmedFind)) yield trimmedFind;
|
|
243
|
+
|
|
244
|
+
const lines = content.split("\n");
|
|
245
|
+
const findLines = find.split("\n");
|
|
246
|
+
for (let i = 0; i <= lines.length - findLines.length; i++) {
|
|
247
|
+
const block = lines.slice(i, i + findLines.length).join("\n");
|
|
248
|
+
if (block.trim() === trimmedFind) yield block;
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
export const ContextAwareReplacer: Replacer = function* (content, find) {
|
|
253
|
+
const findLines = find.split("\n");
|
|
254
|
+
if (findLines.length < 3) return;
|
|
255
|
+
if (findLines[findLines.length - 1] === "") findLines.pop();
|
|
256
|
+
|
|
257
|
+
const contentLines = content.split("\n");
|
|
258
|
+
const firstLine = findLines[0].trim();
|
|
259
|
+
const lastLine = findLines[findLines.length - 1].trim();
|
|
260
|
+
|
|
261
|
+
for (let i = 0; i < contentLines.length; i++) {
|
|
262
|
+
if (contentLines[i].trim() !== firstLine) continue;
|
|
263
|
+
for (let j = i + 2; j < contentLines.length; j++) {
|
|
264
|
+
if (contentLines[j].trim() === lastLine) {
|
|
265
|
+
const blockLines = contentLines.slice(i, j + 1);
|
|
266
|
+
const block = blockLines.join("\n");
|
|
267
|
+
if (blockLines.length === findLines.length) {
|
|
268
|
+
let matchingLines = 0;
|
|
269
|
+
let totalNonEmptyLines = 0;
|
|
270
|
+
for (let k = 1; k < blockLines.length - 1; k++) {
|
|
271
|
+
const blockLine = blockLines[k].trim();
|
|
272
|
+
const findLine = findLines[k].trim();
|
|
273
|
+
if (blockLine.length > 0 || findLine.length > 0) {
|
|
274
|
+
totalNonEmptyLines++;
|
|
275
|
+
if (blockLine === findLine) matchingLines++;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (totalNonEmptyLines === 0 || matchingLines / totalNonEmptyLines >= 0.5) {
|
|
279
|
+
yield block;
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
export function replaceCode(content: string, oldString: string, newString: string, replaceAll = false): string {
|
|
290
|
+
if (oldString === newString) throw new Error("No changes to apply: oldString and newString are identical.");
|
|
291
|
+
let notFound = true;
|
|
292
|
+
|
|
293
|
+
for (const replacer of [
|
|
294
|
+
SimpleReplacer,
|
|
295
|
+
LineTrimmedReplacer,
|
|
296
|
+
BlockAnchorReplacer,
|
|
297
|
+
WhitespaceNormalizedReplacer,
|
|
298
|
+
IndentationFlexibleReplacer,
|
|
299
|
+
EscapeNormalizedReplacer,
|
|
300
|
+
TrimmedBoundaryReplacer,
|
|
301
|
+
ContextAwareReplacer,
|
|
302
|
+
MultiOccurrenceReplacer,
|
|
303
|
+
]) {
|
|
304
|
+
for (const search of replacer(content, oldString)) {
|
|
305
|
+
const index = content.indexOf(search);
|
|
306
|
+
if (index === -1) continue;
|
|
307
|
+
notFound = false;
|
|
308
|
+
if (replaceAll) return content.replaceAll(search, newString);
|
|
309
|
+
const lastIndex = content.lastIndexOf(search);
|
|
310
|
+
if (index !== lastIndex) continue;
|
|
311
|
+
return content.substring(0, index) + newString + content.substring(index + search.length);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (notFound) throw new Error("Could not find oldString in the file. It must match exactly, including whitespace, indentation, and line endings.");
|
|
316
|
+
throw new Error("Found multiple matches for oldString. Provide more surrounding context to make the match unique.");
|
|
317
|
+
}
|