diff --git a/esbuild.config.mjs b/esbuild.config.mjs index e215033..116e852 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -27,6 +27,10 @@ const context = await esbuild.context({ 'fs', 'obsidian', 'electron', + 'path', + 'moment', + 'node:events', + 'child_process', '@codemirror/autocomplete', '@codemirror/collab', '@codemirror/commands', diff --git a/package.json b/package.json index 6034982..efe52ca 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "license": "MIT", "devDependencies": { "@types/diff": "^5.2.3", + "@types/diff-match-patch": "^1.0.36", "@types/jest": "^29.5.13", "@types/lodash": "^4.14.195", "@types/lodash.debounce": "^4.0.9", @@ -67,9 +68,11 @@ "axios": "^1.8.3", "clsx": "^2.1.1", "diff": "^7.0.0", + "diff-match-patch": "^1.0.5", "drizzle-orm": "^0.35.2", "esbuild-plugin-inline-worker": "^0.1.1", "exponential-backoff": "^3.1.1", + "fastest-levenshtein": "^1.0.16", "fuse.js": "^7.1.0", "fuzzysort": "^3.1.0", "groq-sdk": "^0.7.0", @@ -96,6 +99,8 @@ "react-syntax-highlighter": "^15.5.0", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.0", + "simple-git": "^3.27.0", + "string-similarity": "^4.0.4", "uuid": "^10.0.0", "zod": "^3.22.4" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 672af68..3bdd9a7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: diff: specifier: ^7.0.0 version: 7.0.0 + diff-match-patch: + specifier: ^1.0.5 + version: 1.0.5 drizzle-orm: specifier: ^0.35.2 version: 0.35.3(@electric-sql/pglite@0.2.14)(@libsql/client-wasm@0.14.0)(@types/react@18.3.18)(react@18.3.1) @@ -68,6 +71,9 @@ importers: exponential-backoff: specifier: ^3.1.1 version: 3.1.2 + fastest-levenshtein: + specifier: ^1.0.16 + version: 1.0.16 fuse.js: specifier: ^7.1.0 version: 7.1.0 @@ -88,7 +94,7 @@ importers: version: 2.2.3 langchain: specifier: ^0.3.2 - version: 0.3.15(@langchain/core@0.3.40(openai@4.85.1(ws@8.18.0)(zod@3.24.2)))(axios@1.8.3)(handlebars@4.7.8)(openai@4.85.1(ws@8.18.0)(zod@3.24.2))(ws@8.18.0) + version: 0.3.15(@langchain/core@0.3.40(openai@4.85.1(ws@8.18.0)(zod@3.24.2)))(axios@1.8.3)(cheerio@0.16.0)(handlebars@4.7.8)(openai@4.85.1(ws@8.18.0)(zod@3.24.2))(ws@8.18.0) lexical: specifier: ^0.17.1 version: 0.17.1 @@ -146,6 +152,12 @@ importers: remark-gfm: specifier: ^4.0.0 version: 4.0.1 + simple-git: + specifier: ^3.27.0 + version: 3.27.0 + string-similarity: + specifier: ^4.0.4 + version: 4.0.4 uuid: specifier: ^10.0.0 version: 10.0.0 @@ -156,6 +168,9 @@ importers: '@types/diff': specifier: ^5.2.3 version: 5.2.3 + '@types/diff-match-patch': + specifier: ^1.0.36 + version: 1.0.36 '@types/jest': specifier: ^29.5.13 version: 29.5.14 @@ -1170,6 +1185,12 @@ packages: '@keyv/serialize@1.0.2': resolution: {integrity: sha512-+E/LyaAeuABniD/RvUezWVXKpeuvwLEA9//nE9952zBaOdBd2mQ3pPoM8cUe2X6IcMByfuSLzmYqnYshG60+HQ==} + '@kwsites/file-exists@1.1.1': + resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==} + + '@kwsites/promise-deferred@1.1.1': + resolution: {integrity: sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==} + '@langchain/core@0.3.40': resolution: {integrity: sha512-RGhJOTzJv6H+3veBAnDlH2KXuZ68CXMEg6B6DPTzL3IGDyd+vLxXG4FIttzUwjdeQKjrrFBwlXpJDl7bkoApzQ==} engines: {node: '>=18'} @@ -1677,6 +1698,9 @@ packages: '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/diff-match-patch@1.0.36': + resolution: {integrity: sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==} + '@types/diff@5.2.3': resolution: {integrity: sha512-K0Oqlrq3kQMaO2RhfrNQX5trmt+XLyom88zS0u84nnIcLvFnRUMRRHmrGny5GSM+kNO9IZLARsdQHDzkhAgmrQ==} @@ -1851,6 +1875,14 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + CSSselect@0.4.1: + resolution: {integrity: sha512-r4HWARRbQ6enGbdPCrl3bNybORIcU0AcBLTyaxcWNTRd6EH2/w9RInHkUbUhwehrBFN1KQz+yFulhyIH31ZXAw==} + deprecated: the module is now available as 'css-select' + + CSSwhat@0.4.7: + resolution: {integrity: sha512-bU5cYG02crjQGDN6wm8USThp/sr/MUulMTrVA1CENSBhv3B+mlJfYDP1em/wJlMT0aYcWso0cuT9NXW74yPfog==} + deprecated: the module is now available as 'css-what' + abab@2.0.6: resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} deprecated: Use your platform's native atob() and btoa() methods instead @@ -2108,6 +2140,10 @@ packages: character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + cheerio@0.16.0: + resolution: {integrity: sha512-GB+YYcb/2s1HpNXThiHyl1PO5evlkX+avFzggq9/4JZGLGbtMT5FE9GUFjxH+5nObb4Lfu72hAH4lqGljog0Mw==} + engines: {node: '>= 0.6'} + ci-info@3.9.0: resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} engines: {node: '>=8'} @@ -2162,6 +2198,9 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cosmiconfig@9.0.0: resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} engines: {node: '>=14'} @@ -2290,6 +2329,9 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + diff-match-patch@1.0.5: + resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==} + diff-sequences@29.6.3: resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -2310,11 +2352,29 @@ packages: resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} engines: {node: '>=6.0.0'} + dom-serializer@0.2.2: + resolution: {integrity: sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==} + + domelementtype@1.3.1: + resolution: {integrity: sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + domexception@4.0.0: resolution: {integrity: sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==} engines: {node: '>=12'} deprecated: Use your platform's native DOMException instead + domhandler@2.2.1: + resolution: {integrity: sha512-MFFBQFGkyTuNe3vL9WEw9JdlCwIoBYpOGESLeZAvc/jClYNsOl6P1KzevJbWg76GovdEycfR7/2/Ra7NnqtMKw==} + + domutils@1.4.3: + resolution: {integrity: sha512-ZkVgS/PpxjyJMb+S2iVHHEZjVnOUtjGp0/zstqKGTE9lrZtNHlNQmLwP/lhLMEApYbzc08BKMx9IFpKhaSbW1w==} + + domutils@1.5.1: + resolution: {integrity: sha512-gSu5Oi/I+3wDENBsOWBiRK1eoGxcywYSqg3rR960/+EfY0CF4EX1VPkgHOZ3WiS/Jg2DtliF6BhWcHlfpYUcGw==} + drizzle-kit@0.26.2: resolution: {integrity: sha512-cMq8omEKywjIy5KcqUo6LvEFxkl8/zYHsgYjFVXjmPWWtuW4blcz+YW9+oIhoaALgs2ebRjzXwsJgN9i6P49Dw==} hasBin: true @@ -2428,6 +2488,15 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + entities@1.0.0: + resolution: {integrity: sha512-LbLqfXgJMmy81t+7c14mnulFHJ170cM6E+0vMXR9k/ZiZwgX8i5pNgjTCX3SO4VeUsFLV+8InixoretwU+MjBQ==} + + entities@1.1.2: + resolution: {integrity: sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==} + + entities@2.2.0: + resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -2960,6 +3029,9 @@ packages: html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + htmlparser2@3.7.3: + resolution: {integrity: sha512-XdyuCBH3/tTuRTCMFolbj5stKZek8FK7KVXm+aHYivHmXVo18jINvc2jR5zgFkp//z2KWl5vppTJ4DWhltYruA==} + http-proxy-agent@5.0.0: resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} engines: {node: '>= 6'} @@ -3173,6 +3245,9 @@ packages: resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} engines: {node: '>= 0.4'} + isarray@0.0.1: + resolution: {integrity: sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==} + isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} @@ -3546,6 +3621,10 @@ packages: lodash.truncate@4.4.2: resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==} + lodash@2.4.2: + resolution: {integrity: sha512-Kak1hi6/hYHGVPmdyiZijoQyz5x2iGVzs6w9GYB/HiXEtylY7tIoYEROMjvM1d9nXJqPOrG2MNPMn01bJ+S0Rw==} + engines: {'0': node, '1': rhino} + lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} @@ -4133,6 +4212,9 @@ packages: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} + readable-stream@1.1.14: + resolution: {integrity: sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -4291,6 +4373,9 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-git@3.27.0: + resolution: {integrity: sha512-ivHoFS9Yi9GY49ogc6/YAi3Fl9ROnF4VyubNylgCkA+RVqLaKWnDSzXOVzya8csELIaWaYNutsEuAhZrtOjozA==} + simple-wcswidth@1.0.1: resolution: {integrity: sha512-xMO/8eNREtaROt7tJvWJqHBDTMFN4eiQ5I4JRMuilwfnFcV5W9u7RUkueNkdw0jPqGMX36iCywelS5yilTuOxg==} @@ -4336,6 +4421,10 @@ packages: resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} engines: {node: '>=10'} + string-similarity@4.0.4: + resolution: {integrity: sha512-/q/8Q4Bl4ZKAPjj8WerIBJWALKkaPRfrvhfF8k/B23i4nzrlRj2/go1m90In7nG/3XDSbOo0+pu6RvCTM9RGMQ==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -4359,6 +4448,9 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + string_decoder@0.10.31: + resolution: {integrity: sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==} + stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} @@ -5549,6 +5641,14 @@ snapshots: dependencies: buffer: 6.0.3 + '@kwsites/file-exists@1.1.1': + dependencies: + debug: 4.4.0 + transitivePeerDependencies: + - supports-color + + '@kwsites/promise-deferred@1.1.1': {} + '@langchain/core@0.3.40(openai@4.85.1(ws@8.18.0)(zod@3.24.2))': dependencies: '@cfworker/json-schema': 4.1.1 @@ -6186,6 +6286,8 @@ snapshots: dependencies: '@types/ms': 2.1.0 + '@types/diff-match-patch@1.0.36': {} + '@types/diff@5.2.3': {} '@types/eslint-utils@3.0.5': @@ -6395,6 +6497,15 @@ snapshots: '@ungap/structured-clone@1.3.0': {} + CSSselect@0.4.1: + dependencies: + CSSwhat: 0.4.7 + domutils: 1.4.3 + optional: true + + CSSwhat@0.4.7: + optional: true + abab@2.0.6: {} abort-controller@3.0.0: @@ -6706,6 +6817,14 @@ snapshots: character-reference-invalid@2.0.1: {} + cheerio@0.16.0: + dependencies: + CSSselect: 0.4.1 + entities: 1.1.2 + htmlparser2: 3.7.3 + lodash: 2.4.2 + optional: true + ci-info@3.9.0: {} cjs-module-lexer@1.4.3: {} @@ -6748,6 +6867,9 @@ snapshots: convert-source-map@2.0.0: {} + core-util-is@1.0.3: + optional: true + cosmiconfig@9.0.0(typescript@4.9.5): dependencies: env-paths: 2.2.1 @@ -6867,6 +6989,8 @@ snapshots: dependencies: dequal: 2.0.3 + diff-match-patch@1.0.5: {} + diff-sequences@29.6.3: {} diff@7.0.0: {} @@ -6883,10 +7007,38 @@ snapshots: dependencies: esutils: 2.0.3 + dom-serializer@0.2.2: + dependencies: + domelementtype: 2.3.0 + entities: 2.2.0 + optional: true + + domelementtype@1.3.1: + optional: true + + domelementtype@2.3.0: + optional: true + domexception@4.0.0: dependencies: webidl-conversions: 7.0.0 + domhandler@2.2.1: + dependencies: + domelementtype: 1.3.1 + optional: true + + domutils@1.4.3: + dependencies: + domelementtype: 1.3.1 + optional: true + + domutils@1.5.1: + dependencies: + dom-serializer: 0.2.2 + domelementtype: 1.3.1 + optional: true + drizzle-kit@0.26.2: dependencies: '@drizzle-team/brocli': 0.10.2 @@ -6920,6 +7072,15 @@ snapshots: emoji-regex@8.0.0: {} + entities@1.0.0: + optional: true + + entities@1.1.2: + optional: true + + entities@2.2.0: + optional: true + entities@4.5.0: {} env-paths@2.2.1: {} @@ -7725,6 +7886,15 @@ snapshots: html-void-elements@3.0.0: {} + htmlparser2@3.7.3: + dependencies: + domelementtype: 1.3.1 + domhandler: 2.2.1 + domutils: 1.5.1 + entities: 1.0.0 + readable-stream: 1.1.14 + optional: true + http-proxy-agent@5.0.0: dependencies: '@tootallnate/once': 2.0.0 @@ -7930,6 +8100,9 @@ snapshots: call-bound: 1.0.3 get-intrinsic: 1.2.7 + isarray@0.0.1: + optional: true + isarray@2.0.5: {} isexe@2.0.0: {} @@ -8407,7 +8580,7 @@ snapshots: known-css-properties@0.35.0: {} - langchain@0.3.15(@langchain/core@0.3.40(openai@4.85.1(ws@8.18.0)(zod@3.24.2)))(axios@1.8.3)(handlebars@4.7.8)(openai@4.85.1(ws@8.18.0)(zod@3.24.2))(ws@8.18.0): + langchain@0.3.15(@langchain/core@0.3.40(openai@4.85.1(ws@8.18.0)(zod@3.24.2)))(axios@1.8.3)(cheerio@0.16.0)(handlebars@4.7.8)(openai@4.85.1(ws@8.18.0)(zod@3.24.2))(ws@8.18.0): dependencies: '@langchain/core': 0.3.40(openai@4.85.1(ws@8.18.0)(zod@3.24.2)) '@langchain/openai': 0.4.4(@langchain/core@0.3.40(openai@4.85.1(ws@8.18.0)(zod@3.24.2)))(ws@8.18.0) @@ -8424,6 +8597,7 @@ snapshots: zod-to-json-schema: 3.24.1(zod@3.24.2) optionalDependencies: axios: 1.8.3 + cheerio: 0.16.0 handlebars: 4.7.8 transitivePeerDependencies: - encoding @@ -8479,6 +8653,9 @@ snapshots: lodash.truncate@4.4.2: {} + lodash@2.4.2: + optional: true + lodash@4.17.21: {} longest-streak@3.1.0: {} @@ -9265,6 +9442,14 @@ snapshots: dependencies: loose-envify: 1.4.0 + readable-stream@1.1.14: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 0.0.1 + string_decoder: 0.10.31 + optional: true + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -9468,6 +9653,14 @@ snapshots: signal-exit@4.1.0: {} + simple-git@3.27.0: + dependencies: + '@kwsites/file-exists': 1.1.1 + '@kwsites/promise-deferred': 1.1.1 + debug: 4.4.0 + transitivePeerDependencies: + - supports-color + simple-wcswidth@1.0.1: {} sisteransi@1.0.5: {} @@ -9509,6 +9702,8 @@ snapshots: char-regex: 1.0.2 strip-ansi: 6.0.1 + string-similarity@4.0.4: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -9559,6 +9754,9 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + string_decoder@0.10.31: + optional: true + stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 diff --git a/src/ChatView.tsx b/src/ChatView.tsx index 65f9da1..d576319 100644 --- a/src/ChatView.tsx +++ b/src/ChatView.tsx @@ -9,6 +9,7 @@ import { AppProvider } from './contexts/AppContext' import { DarkModeProvider } from './contexts/DarkModeContext' import { DatabaseProvider } from './contexts/DatabaseContext' import { DialogProvider } from './contexts/DialogContext' +import { DiffStrategyProvider } from './contexts/DiffStrategyContext' import { LLMProvider } from './contexts/LLMContext' import { RAGProvider } from './contexts/RAGContext' import { SettingsProvider } from './contexts/SettingsContext' @@ -17,101 +18,103 @@ import { MentionableBlockData } from './types/mentionable' import { InfioSettings } from './types/settings' export class ChatView extends ItemView { - private root: Root | null = null - private settings: InfioSettings - private initialChatProps?: ChatProps - private chatRef: React.RefObject = React.createRef() + private root: Root | null = null + private settings: InfioSettings + private initialChatProps?: ChatProps + private chatRef: React.RefObject = React.createRef() - constructor( - leaf: WorkspaceLeaf, - private plugin: InfioPlugin, - ) { - super(leaf) - this.settings = plugin.settings - this.initialChatProps = plugin.initChatProps - } + constructor( + leaf: WorkspaceLeaf, + private plugin: InfioPlugin, + ) { + super(leaf) + this.settings = plugin.settings + this.initialChatProps = plugin.initChatProps + } - getViewType() { - return CHAT_VIEW_TYPE - } + getViewType() { + return CHAT_VIEW_TYPE + } - getIcon() { - return 'wand-sparkles' - } + getIcon() { + return 'wand-sparkles' + } - getDisplayText() { - return 'Infio chat' - } + getDisplayText() { + return 'Infio chat' + } - async onOpen() { - await this.render() + async onOpen() { + await this.render() - // Consume chatProps - this.initialChatProps = undefined - } + // Consume chatProps + this.initialChatProps = undefined + } - async onClose() { - this.root?.unmount() - } + async onClose() { + this.root?.unmount() + } - async render() { - if (!this.root) { - this.root = createRoot(this.containerEl.children[1]) - } + async render() { + if (!this.root) { + this.root = createRoot(this.containerEl.children[1]) + } - const queryClient = new QueryClient({ - defaultOptions: { - queries: { - gcTime: 0, // Immediately garbage collect queries. It prevents memory leak on ChatView close. - }, - mutations: { - gcTime: 0, // Immediately garbage collect mutations. It prevents memory leak on ChatView close. - }, - }, - }) + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + gcTime: 0, // Immediately garbage collect queries. It prevents memory leak on ChatView close. + }, + mutations: { + gcTime: 0, // Immediately garbage collect mutations. It prevents memory leak on ChatView close. + }, + }, + }) - this.root.render( - - this.plugin.setSettings(newSettings)} - addSettingsChangeListener={(listener) => - this.plugin.addSettingsListener(listener) - } - > - - - this.plugin.getDbManager()} - > - this.plugin.getRAGEngine()}> - - - - - - - - - - - - - , - ) - } + this.root.render( + + this.plugin.setSettings(newSettings)} + addSettingsChangeListener={(listener) => + this.plugin.addSettingsListener(listener) + } + > + + + this.plugin.getDbManager()} + > + + this.plugin.getRAGEngine()}> + + + + + + + + + + + + + + , + ) + } - openNewChat(selectedBlock?: MentionableBlockData) { - this.chatRef.current?.openNewChat(selectedBlock) - } + openNewChat(selectedBlock?: MentionableBlockData) { + this.chatRef.current?.openNewChat(selectedBlock) + } - addSelectionToChat(selectedBlock: MentionableBlockData) { - this.chatRef.current?.addSelectionToChat(selectedBlock) - } + addSelectionToChat(selectedBlock: MentionableBlockData) { + this.chatRef.current?.addSelectionToChat(selectedBlock) + } - focusMessage() { - this.chatRef.current?.focusMessage() - } + focusMessage() { + this.chatRef.current?.focusMessage() + } } diff --git a/src/components/chat-view/Chat.tsx b/src/components/chat-view/Chat.tsx index 4d62074..4a3b6ab 100644 --- a/src/components/chat-view/Chat.tsx +++ b/src/components/chat-view/Chat.tsx @@ -17,6 +17,7 @@ import { v4 as uuidv4 } from 'uuid' import { ApplyViewState } from '../../ApplyView' import { APPLY_VIEW_TYPE } from '../../constants' import { useApp } from '../../contexts/AppContext' +import { useDiffStrategy } from '../../contexts/DiffStrategyContext' import { useLLM } from '../../contexts/LLMContext' import { useRAG } from '../../contexts/RAGContext' import { useSettings } from '../../contexts/SettingsContext' @@ -93,6 +94,7 @@ const Chat = forwardRef((props, ref) => { const app = useApp() const { settings, setSettings } = useSettings() const { getRAGEngine } = useRAG() + const diffStrategy = useDiffStrategy() const { createOrUpdateConversation, @@ -104,8 +106,8 @@ const Chat = forwardRef((props, ref) => { const { streamResponse, chatModel } = useLLM() const promptGenerator = useMemo(() => { - return new PromptGenerator(getRAGEngine, app, settings) - }, [getRAGEngine, app, settings]) + return new PromptGenerator(getRAGEngine, app, settings, diffStrategy) + }, [getRAGEngine, app, settings, diffStrategy]) const [inputMessage, setInputMessage] = useState(() => { const newMessage = getNewInputMessage(app) @@ -382,7 +384,7 @@ const Chat = forwardRef((props, ref) => { if (!applyRes) { throw new Error('Failed to apply edit changes') } - // 返回一个Promise,该Promise会在用户做出选择后解析 + // return a Promise, which will be resolved after user makes a choice return new Promise<{ type: string; applyMsgId: string; applyStatus: ApplyStatus; returnMsg?: ChatUserMessage }>((resolve) => { app.workspace.getLeaf(true).setViewState({ type: APPLY_VIEW_TYPE, @@ -419,7 +421,10 @@ const Chat = forwardRef((props, ref) => { fileContent, toolArgs.operations ) - // 返回一个Promise,该Promise会在用户做出选择后解析 + if (!applyRes) { + throw new Error('Failed to search_and_replace') + } + // return a Promise, which will be resolved after user makes a choice return new Promise<{ type: string; applyMsgId: string; applyStatus: ApplyStatus; returnMsg?: ChatUserMessage }>((resolve) => { app.workspace.getLeaf(true).setViewState({ type: APPLY_VIEW_TYPE, @@ -449,6 +454,45 @@ const Chat = forwardRef((props, ref) => { } satisfies ApplyViewState, }) }) + } else if (toolArgs.type === 'apply_diff') { + const diffResult = await diffStrategy.applyDiff( + activeFileContent, + toolArgs.diff + ) + if (!diffResult.success) { + console.log(diffResult) + throw new Error(`Failed to apply_diff`) + } + // return a Promise, which will be resolved after user makes a choice + return new Promise<{ type: string; applyMsgId: string; applyStatus: ApplyStatus; returnMsg?: ChatUserMessage }>((resolve) => { + app.workspace.getLeaf(true).setViewState({ + type: APPLY_VIEW_TYPE, + active: true, + state: { + file: activeFile, + originalContent: activeFileContent, + newContent: diffResult.content, + onClose: (applied: boolean) => { + const applyStatus = applied ? ApplyStatus.Applied : ApplyStatus.Rejected + const applyEditContent = applied ? 'Changes successfully applied' + : 'User rejected changes' + resolve({ + type: 'apply_diff', + applyMsgId, + applyStatus, + returnMsg: { + role: 'user', + applyStatus: ApplyStatus.Idle, + content: null, + promptContent: `[apply_diff for '${toolArgs.filepath}'] Result:\n${applyEditContent}\n`, + id: uuidv4(), + mentionables: [], + } + }); + } + } satisfies ApplyViewState, + }) + }) } else if (toolArgs.type === 'read_file') { const fileContent = activeFile.path === toolArgs.filepath ? activeFileContent : readFileContent(toolArgs.filepath) const formattedContent = `[read_file for '${toolArgs.filepath}'] Result:\n${addLineNumbers(fileContent)}\n`; diff --git a/src/components/chat-view/MarkdownApplyDiffBlock.tsx b/src/components/chat-view/MarkdownApplyDiffBlock.tsx new file mode 100644 index 0000000..f4d86ed --- /dev/null +++ b/src/components/chat-view/MarkdownApplyDiffBlock.tsx @@ -0,0 +1,93 @@ +import { Check, Edit, Loader2, X } from 'lucide-react' +import { PropsWithChildren, useState } from 'react' + +import { useDarkModeContext } from '../../contexts/DarkModeContext' +import { ApplyStatus, ToolArgs } from '../../types/apply' + +import { MemoizedSyntaxHighlighterWrapper } from './SyntaxHighlighterWrapper' + +export default function MarkdownApplyDiffBlock({ + mode, + applyStatus, + onApply, + path, + diff, + finish, +}: PropsWithChildren<{ + mode: string + applyStatus: ApplyStatus + onApply: (args: ToolArgs) => void + path: string + diff: string + finish: boolean +}>) { + const [applying, setApplying] = useState(false) + const { isDarkMode } = useDarkModeContext() + + console.log('MarkdownApplyDiffBlock', { mode, applyStatus, onApply, path, diff, finish }) + const handleApply = async () => { + if (applyStatus !== ApplyStatus.Idle) { + return + } + setApplying(true) + onApply({ + type: "apply_diff", + filepath: path, + diff, + finish, + }) + } + + return ( +
+
+ {path && ( +
+ + {mode}: {path} +
+ )} +
+ +
+
+
+ + {diff} + +
+
+ ) +} diff --git a/src/components/chat-view/MarkdownSearchAndReplace.tsx b/src/components/chat-view/MarkdownSearchAndReplace.tsx index 7302477..ccf78cf 100644 --- a/src/components/chat-view/MarkdownSearchAndReplace.tsx +++ b/src/components/chat-view/MarkdownSearchAndReplace.tsx @@ -1,24 +1,29 @@ import { Check, Loader2, Replace, X } from 'lucide-react' -import React from 'react' +import React, { useMemo } from 'react' import { useApp } from '../../contexts/AppContext' import { ApplyStatus, SearchAndReplaceToolArgs } from '../../types/apply' import { openMarkdownFile } from '../../utils/obsidian' +import { MemoizedSyntaxHighlighterWrapper } from './SyntaxHighlighterWrapper' +import { useDarkModeContext } from '../../contexts/DarkModeContext' export default function MarkdownSearchAndReplace({ applyStatus, onApply, path, + content, operations, finish }: { applyStatus: ApplyStatus onApply: (args: SearchAndReplaceToolArgs) => void path: string, + content: string, operations: SearchAndReplaceToolArgs['operations'], finish: boolean }) { const app = useApp() + const { isDarkMode } = useDarkModeContext() const [applying, setApplying] = React.useState(false) @@ -51,9 +56,13 @@ export default function MarkdownSearchAndReplace({
+ + {content} + ) } diff --git a/src/components/chat-view/ReactMarkdown.tsx b/src/components/chat-view/ReactMarkdown.tsx index 6a95486..6491f76 100644 --- a/src/components/chat-view/ReactMarkdown.tsx +++ b/src/components/chat-view/ReactMarkdown.tsx @@ -7,6 +7,7 @@ import { parseMsgBlocks, } from '../../utils/parse-infio-block' +import MarkdownApplyDiffBlock from './MarkdownApplyDiffBlock' import MarkdownEditFileBlock from './MarkdownEditFileBlock' import MarkdownFetchUrlsContentBlock from './MarkdownFetchUrlsContentBlock' import MarkdownListFilesBlock from './MarkdownListFilesBlock' @@ -18,6 +19,7 @@ import MarkdownSearchWebBlock from './MarkdownSearchWebBlock' import MarkdownSemanticSearchFilesBlock from './MarkdownSemanticSearchFilesBlock' import MarkdownSwitchModeBlock from './MarkdownSwitchModeBlock' import MarkdownWithIcons from './MarkdownWithIcon' + function ReactMarkdown({ applyStatus, onApply, @@ -27,6 +29,7 @@ function ReactMarkdown({ onApply: (toolArgs: ToolArgs) => void children: string }) { + const blocks: ParsedMsgBlock[] = useMemo( () => parseMsgBlocks(children), [children], @@ -73,6 +76,7 @@ function ReactMarkdown({ applyStatus={applyStatus} onApply={onApply} path={block.path} + content={block.content} operations={block.operations.map(op => ({ search: op.search, replace: op.replace, @@ -84,6 +88,16 @@ function ReactMarkdown({ }))} finish={block.finish} /> + ) : block.type === 'apply_diff' ? ( + ) : block.type === 'read_file' ? ( (null) + +export function DiffStrategyProvider({ + diffStrategy, + children, +}: PropsWithChildren<{ diffStrategy: DiffStrategy }>) { + + const value = useMemo(() => { + return diffStrategy + }, [diffStrategy]) + + return {children} +} + +export function useDiffStrategy() { + const context = useContext(DiffStrategyContext) + if (!context) { + throw new Error('DiffStrategyContext is not initialized') + } + return context +} diff --git a/src/contexts/LLMContext.tsx b/src/contexts/LLMContext.tsx index 2f2da29..85238d6 100644 --- a/src/contexts/LLMContext.tsx +++ b/src/contexts/LLMContext.tsx @@ -34,7 +34,7 @@ export type LLMContextType = { options?: LLMOptions, ) => Promise> chatModel: LLMModel - applyModel: LLMModel + // applyModel: LLMModel } const LLMContext = createContext(null) @@ -50,12 +50,12 @@ export function LLMProvider({ children }: PropsWithChildren) { } }, [settings]) - const applyModel = useMemo((): LLMModel => { - return { - provider: settings.applyModelProvider, - modelId: settings.applyModelId, - } - }, [settings]) + // const applyModel = useMemo((): LLMModel => { + // return { + // provider: settings.applyModelProvider, + // modelId: settings.applyModelId, + // } + // }, [settings]) useEffect(() => { const manager = new LLMManager(settings) @@ -92,7 +92,7 @@ export function LLMProvider({ children }: PropsWithChildren) { return ( {children} diff --git a/src/core/diff/DiffStrategy.ts b/src/core/diff/DiffStrategy.ts index de52498..08558fb 100644 --- a/src/core/diff/DiffStrategy.ts +++ b/src/core/diff/DiffStrategy.ts @@ -1,7 +1,10 @@ -import type { DiffStrategy } from "./types" -import { UnifiedDiffStrategy } from "./strategies/unified" -import { SearchReplaceDiffStrategy } from "./strategies/search-replace" +import { App } from "obsidian" + +import { MultiSearchReplaceDiffStrategy } from "./strategies/multi-search-replace" import { NewUnifiedDiffStrategy } from "./strategies/new-unified" +import { SearchReplaceDiffStrategy } from "./strategies/search-replace" +import { UnifiedDiffStrategy } from "./strategies/unified" +import type { DiffStrategy } from "./types" /** * Get the appropriate diff strategy for the given model * @param model The name of the model being used (e.g., 'gpt-4', 'claude-3-opus') @@ -9,14 +12,22 @@ import { NewUnifiedDiffStrategy } from "./strategies/new-unified" */ export function getDiffStrategy( model: string, + app: App, fuzzyMatchThreshold?: number, experimentalDiffStrategy: boolean = false, + multiSearchReplaceDiffStrategy: boolean = false, ): DiffStrategy { - if (experimentalDiffStrategy) { - return new NewUnifiedDiffStrategy(fuzzyMatchThreshold) - } - return new SearchReplaceDiffStrategy(fuzzyMatchThreshold) + // if (experimentalDiffStrategy) { + // return new NewUnifiedDiffStrategy(app, fuzzyMatchThreshold) + // } + + // if (multiSearchReplaceDiffStrategy) { + // return new MultiSearchReplaceDiffStrategy(fuzzyMatchThreshold) + // } else { + // return new SearchReplaceDiffStrategy(fuzzyMatchThreshold) + // } + return new MultiSearchReplaceDiffStrategy(0.9) } +export { SearchReplaceDiffStrategy, UnifiedDiffStrategy } export type { DiffStrategy } -export { UnifiedDiffStrategy, SearchReplaceDiffStrategy } diff --git a/src/core/diff/strategies/__tests__/multi-search-replace.test.ts b/src/core/diff/strategies/__tests__/multi-search-replace.test.ts new file mode 100644 index 0000000..8fc16d2 --- /dev/null +++ b/src/core/diff/strategies/__tests__/multi-search-replace.test.ts @@ -0,0 +1,1566 @@ +import { MultiSearchReplaceDiffStrategy } from "../multi-search-replace" + +describe("MultiSearchReplaceDiffStrategy", () => { + describe("exact matching", () => { + let strategy: MultiSearchReplaceDiffStrategy + + beforeEach(() => { + strategy = new MultiSearchReplaceDiffStrategy(1.0, 5) // Default 1.0 threshold for exact matching, 5 line buffer for tests + }) + + it("should replace matching content", async () => { + const originalContent = 'function hello() {\n console.log("hello")\n}\n' + const diffContent = `test.ts +<<<<<<< SEARCH +function hello() { + console.log("hello") +} +======= +function hello() { + console.log("hello world") +} +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe('function hello() {\n console.log("hello world")\n}\n') + } + }) + + it("should match content with different surrounding whitespace", async () => { + const originalContent = "\nfunction example() {\n return 42;\n}\n\n" + const diffContent = `test.ts +<<<<<<< SEARCH +function example() { + return 42; +} +======= +function example() { + return 43; +} +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe("\nfunction example() {\n return 43;\n}\n\n") + } + }) + + it("should match content with different indentation in search block", async () => { + const originalContent = " function test() {\n return true;\n }\n" + const diffContent = `test.ts +<<<<<<< SEARCH +function test() { + return true; +} +======= +function test() { + return false; +} +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(" function test() {\n return false;\n }\n") + } + }) + + it("should handle tab-based indentation", async () => { + const originalContent = "function test() {\n\treturn true;\n}\n" + const diffContent = `test.ts +<<<<<<< SEARCH +function test() { +\treturn true; +} +======= +function test() { +\treturn false; +} +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe("function test() {\n\treturn false;\n}\n") + } + }) + + it("should preserve mixed tabs and spaces", async () => { + const originalContent = "\tclass Example {\n\t constructor() {\n\t\tthis.value = 0;\n\t }\n\t}" + const diffContent = `test.ts +<<<<<<< SEARCH +\tclass Example { +\t constructor() { +\t\tthis.value = 0; +\t } +\t} +======= +\tclass Example { +\t constructor() { +\t\tthis.value = 1; +\t } +\t} +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe( + "\tclass Example {\n\t constructor() {\n\t\tthis.value = 1;\n\t }\n\t}", + ) + } + }) + + it("should handle additional indentation with tabs", async () => { + const originalContent = "\tfunction test() {\n\t\treturn true;\n\t}" + const diffContent = `test.ts +<<<<<<< SEARCH +function test() { +\treturn true; +} +======= +function test() { +\t// Add comment +\treturn false; +} +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe("\tfunction test() {\n\t\t// Add comment\n\t\treturn false;\n\t}") + } + }) + + it("should preserve exact indentation characters when adding lines", async () => { + const originalContent = "\tfunction test() {\n\t\treturn true;\n\t}" + const diffContent = `test.ts +<<<<<<< SEARCH +\tfunction test() { +\t\treturn true; +\t} +======= +\tfunction test() { +\t\t// First comment +\t\t// Second comment +\t\treturn true; +\t} +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe( + "\tfunction test() {\n\t\t// First comment\n\t\t// Second comment\n\t\treturn true;\n\t}", + ) + } + }) + + it("should handle Windows-style CRLF line endings", async () => { + const originalContent = "function test() {\r\n return true;\r\n}\r\n" + const diffContent = `test.ts +<<<<<<< SEARCH +function test() { + return true; +} +======= +function test() { + return false; +} +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe("function test() {\r\n return false;\r\n}\r\n") + } + }) + + it("should return false if search content does not match", async () => { + const originalContent = 'function hello() {\n console.log("hello")\n}\n' + const diffContent = `test.ts +<<<<<<< SEARCH +function hello() { + console.log("wrong") +} +======= +function hello() { + console.log("hello world") +} +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(false) + }) + + it("should return false if diff format is invalid", async () => { + const originalContent = 'function hello() {\n console.log("hello")\n}\n' + const diffContent = `test.ts\nInvalid diff format` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(false) + }) + + it("should handle multiple lines with proper indentation", async () => { + const originalContent = + "class Example {\n constructor() {\n this.value = 0\n }\n\n getValue() {\n return this.value\n }\n}\n" + const diffContent = `test.ts +<<<<<<< SEARCH + getValue() { + return this.value + } +======= + getValue() { + // Add logging + console.log("Getting value") + return this.value + } +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe( + 'class Example {\n constructor() {\n this.value = 0\n }\n\n getValue() {\n // Add logging\n console.log("Getting value")\n return this.value\n }\n}\n', + ) + } + }) + + it("should preserve whitespace exactly in the output", async () => { + const originalContent = " indented\n more indented\n back\n" + const diffContent = `test.ts +<<<<<<< SEARCH + indented + more indented + back +======= + modified + still indented + end +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(" modified\n still indented\n end\n") + } + }) + + it("should preserve indentation when adding new lines after existing content", async () => { + const originalContent = " onScroll={() => updateHighlights()}" + const diffContent = `test.ts +<<<<<<< SEARCH + onScroll={() => updateHighlights()} +======= + onScroll={() => updateHighlights()} + onDragOver={(e) => { + e.preventDefault() + e.stopPropagation() + }} +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe( + " onScroll={() => updateHighlights()}\n onDragOver={(e) => {\n e.preventDefault()\n e.stopPropagation()\n }}", + ) + } + }) + + it("should handle varying indentation levels correctly", async () => { + const originalContent = ` +class Example { + constructor() { + this.value = 0; + if (true) { + this.init(); + } + } +}`.trim() + + const diffContent = `test.ts +<<<<<<< SEARCH + class Example { + constructor() { + this.value = 0; + if (true) { + this.init(); + } + } + } +======= + class Example { + constructor() { + this.value = 1; + if (true) { + this.init(); + this.setup(); + this.validate(); + } + } + } +>>>>>>> REPLACE`.trim() + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe( + ` +class Example { + constructor() { + this.value = 1; + if (true) { + this.init(); + this.setup(); + this.validate(); + } + } +}`.trim(), + ) + } + }) + + it("should handle mixed indentation styles in the same file", async () => { + const originalContent = `class Example { + constructor() { + this.value = 0; + if (true) { + this.init(); + } + } +}`.trim() + const diffContent = `test.ts +<<<<<<< SEARCH + constructor() { + this.value = 0; + if (true) { + this.init(); + } + } +======= + constructor() { + this.value = 1; + if (true) { + this.init(); + this.validate(); + } + } +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`class Example { + constructor() { + this.value = 1; + if (true) { + this.init(); + this.validate(); + } + } +}`) + } + }) + + it("should handle Python-style significant whitespace", async () => { + const originalContent = `def example(): + if condition: + do_something() + for item in items: + process(item) + return True`.trim() + const diffContent = `test.ts +<<<<<<< SEARCH + if condition: + do_something() + for item in items: + process(item) +======= + if condition: + do_something() + while items: + item = items.pop() + process(item) +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`def example(): + if condition: + do_something() + while items: + item = items.pop() + process(item) + return True`) + } + }) + + it("should preserve empty lines with indentation", async () => { + const originalContent = `function test() { + const x = 1; + + if (x) { + return true; + } +}`.trim() + const diffContent = `test.ts +<<<<<<< SEARCH + const x = 1; + + if (x) { +======= + const x = 1; + + // Check x + if (x) { +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function test() { + const x = 1; + + // Check x + if (x) { + return true; + } +}`) + } + }) + + it("should handle indentation when replacing entire blocks", async () => { + const originalContent = `class Test { + method() { + if (true) { + console.log("test"); + } + } +}`.trim() + const diffContent = `test.ts +<<<<<<< SEARCH + method() { + if (true) { + console.log("test"); + } + } +======= + method() { + try { + if (true) { + console.log("test"); + } + } catch (e) { + console.error(e); + } + } +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`class Test { + method() { + try { + if (true) { + console.log("test"); + } + } catch (e) { + console.error(e); + } + } +}`) + } + }) + + it("should handle negative indentation relative to search content", async () => { + const originalContent = `class Example { + constructor() { + if (true) { + this.init(); + this.setup(); + } + } +}`.trim() + const diffContent = `test.ts +<<<<<<< SEARCH + this.init(); + this.setup(); +======= + this.init(); + this.setup(); +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`class Example { + constructor() { + if (true) { + this.init(); + this.setup(); + } + } +}`) + } + }) + + it("should handle extreme negative indentation (no indent)", async () => { + const originalContent = `class Example { + constructor() { + if (true) { + this.init(); + } + } +}`.trim() + const diffContent = `test.ts +<<<<<<< SEARCH + this.init(); +======= +this.init(); +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`class Example { + constructor() { + if (true) { +this.init(); + } + } +}`) + } + }) + + it("should handle mixed indentation changes in replace block", async () => { + const originalContent = `class Example { + constructor() { + if (true) { + this.init(); + this.setup(); + this.validate(); + } + } +}`.trim() + const diffContent = `test.ts +<<<<<<< SEARCH + this.init(); + this.setup(); + this.validate(); +======= + this.init(); + this.setup(); + this.validate(); +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`class Example { + constructor() { + if (true) { + this.init(); + this.setup(); + this.validate(); + } + } +}`) + } + }) + + it("should find matches from middle out", async () => { + const originalContent = ` +function one() { + return "target"; +} + +function two() { + return "target"; +} + +function three() { + return "target"; +} + +function four() { + return "target"; +} + +function five() { + return "target"; +}`.trim() + + const diffContent = `test.ts +<<<<<<< SEARCH + return "target"; +======= + return "updated"; +>>>>>>> REPLACE` + + // Search around the middle (function three) + // Even though all functions contain the target text, + // it should match the one closest to line 9 first + const result = await strategy.applyDiff(originalContent, diffContent, 9, 9) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function one() { + return "target"; +} + +function two() { + return "target"; +} + +function three() { + return "updated"; +} + +function four() { + return "target"; +} + +function five() { + return "target"; +}`) + } + }) + }) + + describe("line number stripping", () => { + describe("line number stripping", () => { + let strategy: MultiSearchReplaceDiffStrategy + + beforeEach(() => { + strategy = new MultiSearchReplaceDiffStrategy() + }) + + it("should strip line numbers from both search and replace sections", async () => { + const originalContent = "function test() {\n return true;\n}\n" + const diffContent = `test.ts +<<<<<<< SEARCH +1 | function test() { +2 | return true; +3 | } +======= +1 | function test() { +2 | return false; +3 | } +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe("function test() {\n return false;\n}\n") + } + }) + + it("should strip line numbers with leading spaces", async () => { + const originalContent = "function test() {\n return true;\n}\n" + const diffContent = `test.ts +<<<<<<< SEARCH + 1 | function test() { + 2 | return true; + 3 | } +======= + 1 | function test() { + 2 | return false; + 3 | } +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe("function test() {\n return false;\n}\n") + } + }) + + it("should not strip when not all lines have numbers in either section", async () => { + const originalContent = "function test() {\n return true;\n}\n" + const diffContent = `test.ts +<<<<<<< SEARCH +1 | function test() { +2 | return true; +3 | } +======= +1 | function test() { + return false; +3 | } +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(false) + }) + + it("should preserve content that naturally starts with pipe", async () => { + const originalContent = "|header|another|\n|---|---|\n|data|more|\n" + const diffContent = `test.ts +<<<<<<< SEARCH +1 | |header|another| +2 | |---|---| +3 | |data|more| +======= +1 | |header|another| +2 | |---|---| +3 | |data|updated| +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe("|header|another|\n|---|---|\n|data|updated|\n") + } + }) + + it("should preserve indentation when stripping line numbers", async () => { + const originalContent = " function test() {\n return true;\n }\n" + const diffContent = `test.ts +<<<<<<< SEARCH +1 | function test() { +2 | return true; +3 | } +======= +1 | function test() { +2 | return false; +3 | } +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(" function test() {\n return false;\n }\n") + } + }) + + it("should handle different line numbers between sections", async () => { + const originalContent = "function test() {\n return true;\n}\n" + const diffContent = `test.ts +<<<<<<< SEARCH +10 | function test() { +11 | return true; +12 | } +======= +20 | function test() { +21 | return false; +22 | } +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe("function test() {\n return false;\n}\n") + } + }) + + it("should not strip content that starts with pipe but no line number", async () => { + const originalContent = "| Pipe\n|---|\n| Data\n" + const diffContent = `test.ts +<<<<<<< SEARCH +| Pipe +|---| +| Data +======= +| Pipe +|---| +| Updated +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe("| Pipe\n|---|\n| Updated\n") + } + }) + + it("should handle mix of line-numbered and pipe-only content", async () => { + const originalContent = "| Pipe\n|---|\n| Data\n" + const diffContent = `test.ts +<<<<<<< SEARCH +| Pipe +|---| +| Data +======= +1 | | Pipe +2 | |---| +3 | | NewData +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe("1 | | Pipe\n2 | |---|\n3 | | NewData\n") + } + }) + }) + }) + + describe("insertion/deletion", () => { + let strategy: MultiSearchReplaceDiffStrategy + + beforeEach(() => { + strategy = new MultiSearchReplaceDiffStrategy() + }) + + describe("deletion", () => { + it("should delete code when replace block is empty", async () => { + const originalContent = `function test() { + console.log("hello"); + // Comment to remove + console.log("world"); +}` + const diffContent = `test.ts +<<<<<<< SEARCH + // Comment to remove +======= +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function test() { + console.log("hello"); + console.log("world"); +}`) + } + }) + + it("should delete multiple lines when replace block is empty", async () => { + const originalContent = `class Example { + constructor() { + // Initialize + this.value = 0; + // Set defaults + this.name = ""; + // End init + } +}` + const diffContent = `test.ts +<<<<<<< SEARCH + // Initialize + this.value = 0; + // Set defaults + this.name = ""; + // End init +======= +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`class Example { + constructor() { + } +}`) + } + }) + + it("should preserve indentation when deleting nested code", async () => { + const originalContent = `function outer() { + if (true) { + // Remove this + console.log("test"); + // And this + } + return true; +}` + const diffContent = `test.ts +<<<<<<< SEARCH + // Remove this + console.log("test"); + // And this +======= +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function outer() { + if (true) { + } + return true; +}`) + } + }) + }) + + describe("insertion", () => { + it("should insert code at specified line when search block is empty", async () => { + const originalContent = `function test() { + const x = 1; + return x; +}` + const diffContent = `test.ts +<<<<<<< SEARCH +:start_line:2 +:end_line:2 +------- +======= + console.log("Adding log"); +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent, 2, 2) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function test() { + console.log("Adding log"); + const x = 1; + return x; +}`) + } + }) + + it("should preserve indentation when inserting at nested location", async () => { + const originalContent = `function test() { + if (true) { + const x = 1; + } +}` + const diffContent = `test.ts +<<<<<<< SEARCH +:start_line:3 +:end_line:3 +------- +======= + console.log("Before"); + console.log("After"); +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent, 3, 3) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function test() { + if (true) { + console.log("Before"); + console.log("After"); + const x = 1; + } +}`) + } + }) + + it("should handle insertion at start of file", async () => { + const originalContent = `function test() { + return true; +}` + const diffContent = `test.ts +<<<<<<< SEARCH +:start_line:1 +:end_line:1 +------- +======= +// Copyright 2024 +// License: MIT + +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent, 1, 1) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`// Copyright 2024 +// License: MIT + +function test() { + return true; +}`) + } + }) + + it("should handle insertion at end of file", async () => { + const originalContent = `function test() { + return true; +}` + const diffContent = `test.ts +<<<<<<< SEARCH +:start_line:4 +:end_line:4 +------- +======= + +// End of file +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent, 4, 4) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function test() { + return true; +} + +// End of file`) + } + }) + + it("should error if no start_line is provided for insertion", async () => { + const originalContent = `function test() { + return true; +}` + const diffContent = `test.ts +<<<<<<< SEARCH +======= +console.log("test"); +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(false) + }) + }) + }) + + describe("fuzzy matching", () => { + let strategy: MultiSearchReplaceDiffStrategy + beforeEach(() => { + strategy = new MultiSearchReplaceDiffStrategy(0.9, 5) // 90% similarity threshold, 5 line buffer for tests + }) + + it("should match content with small differences (>90% similar)", async () => { + const originalContent = + "function getData() {\n const results = fetchData();\n return results.filter(Boolean);\n}\n" + const diffContent = `test.ts +<<<<<<< SEARCH +function getData() { + const result = fetchData(); + return results.filter(Boolean); +} +======= +function getData() { + const data = fetchData(); + return data.filter(Boolean); +} +>>>>>>> REPLACE` + + strategy = new MultiSearchReplaceDiffStrategy(0.9, 5) // Use 5 line buffer for tests + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe( + "function getData() {\n const data = fetchData();\n return data.filter(Boolean);\n}\n", + ) + } + }) + + it("should not match when content is too different (<90% similar)", async () => { + const originalContent = "function processUsers(data) {\n return data.map(user => user.name);\n}\n" + const diffContent = `test.ts +<<<<<<< SEARCH +function handleItems(items) { + return items.map(item => item.username); +} +======= +function processData(data) { + return data.map(d => d.value); +} +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(false) + }) + + it("should match content with extra whitespace", async () => { + const originalContent = "function sum(a, b) {\n return a + b;\n}" + const diffContent = `test.ts +<<<<<<< SEARCH +function sum(a, b) { + return a + b; +} +======= +function sum(a, b) { + return a + b + 1; +} +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe("function sum(a, b) {\n return a + b + 1;\n}") + } + }) + + it("should not exact match empty lines", async () => { + const originalContent = "function sum(a, b) {\n\n return a + b;\n}" + const diffContent = `test.ts +<<<<<<< SEARCH +function sum(a, b) { +======= +import { a } from "a"; +function sum(a, b) { +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe('import { a } from "a";\nfunction sum(a, b) {\n\n return a + b;\n}') + } + }) + }) + + describe("line-constrained search", () => { + let strategy: MultiSearchReplaceDiffStrategy + + beforeEach(() => { + strategy = new MultiSearchReplaceDiffStrategy(0.9, 5) + }) + + it("should find and replace within specified line range", async () => { + const originalContent = ` +function one() { + return 1; +} + +function two() { + return 2; +} + +function three() { + return 3; +} +`.trim() + const diffContent = `test.ts +<<<<<<< SEARCH +function two() { + return 2; +} +======= +function two() { + return "two"; +} +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent, 5, 7) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function one() { + return 1; +} + +function two() { + return "two"; +} + +function three() { + return 3; +}`) + } + }) + + it("should find and replace within buffer zone (5 lines before/after)", async () => { + const originalContent = ` +function one() { + return 1; +} + +function two() { + return 2; +} + +function three() { + return 3; +} +`.trim() + const diffContent = `test.ts +<<<<<<< SEARCH +function three() { + return 3; +} +======= +function three() { + return "three"; +} +>>>>>>> REPLACE` + + // Even though we specify lines 5-7, it should still find the match at lines 9-11 + // because it's within the 5-line buffer zone + const result = await strategy.applyDiff(originalContent, diffContent, 5, 7) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function one() { + return 1; +} + +function two() { + return 2; +} + +function three() { + return "three"; +}`) + } + }) + + it("should not find matches outside search range and buffer zone", async () => { + const originalContent = ` +function one() { + return 1; +} + +function two() { + return 2; +} + +function three() { + return 3; +} + +function four() { + return 4; +} + +function five() { + return 5; +} +`.trim() + const diffContent = `test.ts +<<<<<<< SEARCH +:start_line:5 +:end_line:7 +------- +function five() { + return 5; +} +======= +function five() { + return "five"; +} +>>>>>>> REPLACE` + + // Searching around function two() (lines 5-7) + // function five() is more than 5 lines away, so it shouldn't match + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(false) + }) + + it("should handle search range at start of file", async () => { + const originalContent = ` +function one() { + return 1; +} + +function two() { + return 2; +} +`.trim() + const diffContent = `test.ts +<<<<<<< SEARCH +function one() { + return 1; +} +======= +function one() { + return "one"; +} +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent, 1, 3) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function one() { + return "one"; +} + +function two() { + return 2; +}`) + } + }) + + it("should handle search range at end of file", async () => { + const originalContent = ` +function one() { + return 1; +} + +function two() { + return 2; +} +`.trim() + const diffContent = `test.ts +<<<<<<< SEARCH +function two() { + return 2; +} +======= +function two() { + return "two"; +} +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent, 5, 7) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function one() { + return 1; +} + +function two() { + return "two"; +}`) + } + }) + + it("should match specific instance of duplicate code using line numbers", async () => { + const originalContent = ` +function processData(data) { + return data.map(x => x * 2); +} + +function unrelatedStuff() { + console.log("hello"); +} + +// Another data processor +function processData(data) { + return data.map(x => x * 2); +} + +function moreStuff() { + console.log("world"); +} +`.trim() + const diffContent = `test.ts +<<<<<<< SEARCH +function processData(data) { + return data.map(x => x * 2); +} +======= +function processData(data) { + // Add logging + console.log("Processing data..."); + return data.map(x => x * 2); +} +>>>>>>> REPLACE` + + // Target the second instance of processData + const result = await strategy.applyDiff(originalContent, diffContent, 10, 12) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function processData(data) { + return data.map(x => x * 2); +} + +function unrelatedStuff() { + console.log("hello"); +} + +// Another data processor +function processData(data) { + // Add logging + console.log("Processing data..."); + return data.map(x => x * 2); +} + +function moreStuff() { + console.log("world"); +}`) + } + }) + + it("should search from start line to end of file when only start_line is provided", async () => { + const originalContent = ` +function one() { + return 1; +} + +function two() { + return 2; +} + +function three() { + return 3; +} +`.trim() + const diffContent = `test.ts +<<<<<<< SEARCH +function three() { + return 3; +} +======= +function three() { + return "three"; +} +>>>>>>> REPLACE` + + // Only provide start_line, should search from there to end of file + const result = await strategy.applyDiff(originalContent, diffContent, 8) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function one() { + return 1; +} + +function two() { + return 2; +} + +function three() { + return "three"; +}`) + } + }) + + it("should search from start of file to end line when only end_line is provided", async () => { + const originalContent = ` +function one() { + return 1; +} + +function two() { + return 2; +} + +function three() { + return 3; +} +`.trim() + const diffContent = `test.ts +<<<<<<< SEARCH +function one() { + return 1; +} +======= +function one() { + return "one"; +} +>>>>>>> REPLACE` + + // Only provide end_line, should search from start of file to there + const result = await strategy.applyDiff(originalContent, diffContent, undefined, 4) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function one() { + return "one"; +} + +function two() { + return 2; +} + +function three() { + return 3; +}`) + } + }) + + it("should prioritize exact line match over expanded search", async () => { + const originalContent = ` +function one() { + return 1; +} + +function process() { + return "old"; +} + +function process() { + return "old"; +} + +function two() { + return 2; +}` + const diffContent = `test.ts +<<<<<<< SEARCH +function process() { + return "old"; +} +======= +function process() { + return "new"; +} +>>>>>>> REPLACE` + + // Should match the second instance exactly at lines 10-12 + // even though the first instance at 6-8 is within the expanded search range + const result = await strategy.applyDiff(originalContent, diffContent, 10, 12) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(` +function one() { + return 1; +} + +function process() { + return "old"; +} + +function process() { + return "new"; +} + +function two() { + return 2; +}`) + } + }) + + it("should fall back to expanded search only if exact match fails", async () => { + const originalContent = ` +function one() { + return 1; +} + +function process() { + return "target"; +} + +function two() { + return 2; +}`.trim() + const diffContent = `test.ts +<<<<<<< SEARCH +function process() { + return "target"; +} +======= +function process() { + return "updated"; +} +>>>>>>> REPLACE` + + // Specify wrong line numbers (3-5), but content exists at 6-8 + // Should still find and replace it since it's within the expanded range + const result = await strategy.applyDiff(originalContent, diffContent, 3, 5) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function one() { + return 1; +} + +function process() { + return "updated"; +} + +function two() { + return 2; +}`) + } + }) + }) + + describe("getToolDescription", () => { + let strategy: MultiSearchReplaceDiffStrategy + + beforeEach(() => { + strategy = new MultiSearchReplaceDiffStrategy() + }) + + it("should include the current working directory", async () => { + const cwd = "/test/dir" + const description = await strategy.getToolDescription({ cwd }) + expect(description).toContain(`relative to the current working directory ${cwd}`) + }) + + it("should include required format elements", async () => { + const description = await strategy.getToolDescription({ cwd: "/test" }) + expect(description).toContain("<<<<<<< SEARCH") + expect(description).toContain("=======") + expect(description).toContain(">>>>>>> REPLACE") + expect(description).toContain("") + expect(description).toContain("") + }) + }) +}) diff --git a/src/core/diff/strategies/multi-search-replace.ts b/src/core/diff/strategies/multi-search-replace.ts new file mode 100644 index 0000000..b95850d --- /dev/null +++ b/src/core/diff/strategies/multi-search-replace.ts @@ -0,0 +1,395 @@ +import { distance } from "fastest-levenshtein" + +import { addLineNumbers, everyLineHasLineNumbers, stripLineNumbers } from "../../../utils/extract-text" +// import { ToolProgressStatus } from "../../../shared/ExtensionMessage" +// import { ToolUse } from "../../assistant-message" +import { DiffResult, DiffStrategy } from "../types" + +const BUFFER_LINES = 40 // Number of extra context lines to show before and after matches + +function getSimilarity(original: string, search: string): number { + if (search === "") { + return 1 + } + + // Normalize strings by removing extra whitespace but preserve case + const normalizeStr = (str: string) => str.replace(/\s+/g, " ").trim() + + const normalizedOriginal = normalizeStr(original) + const normalizedSearch = normalizeStr(search) + + if (normalizedOriginal === normalizedSearch) { + return 1 + } + + // Calculate Levenshtein distance using fastest-levenshtein's distance function + const dist = distance(normalizedOriginal, normalizedSearch) + + // Calculate similarity ratio (0 to 1, where 1 is an exact match) + const maxLength = Math.max(normalizedOriginal.length, normalizedSearch.length) + return 1 - dist / maxLength +} + +export class MultiSearchReplaceDiffStrategy implements DiffStrategy { + private fuzzyThreshold: number + private bufferLines: number + + getName(): string { + return "MultiSearchReplace" + } + + constructor(fuzzyThreshold?: number, bufferLines?: number) { + // Use provided threshold or default to exact matching (1.0) + // Note: fuzzyThreshold is inverted in UI (0% = 1.0, 10% = 0.9) + // so we use it directly here + this.fuzzyThreshold = fuzzyThreshold ?? 1.0 + this.bufferLines = bufferLines ?? BUFFER_LINES + } + + getToolDescription(args: { cwd: string; toolOptions?: { [key: string]: string } }): string { + return `## apply_diff +Description: Request to replace existing content in Markdown documents using a search and replace block. +This tool allows for precise modifications to Markdown files by specifying exactly what content to search for and what to replace it with. +The tool will maintain proper formatting while making changes to your Markdown documents. +Only a single operation is allowed per tool use. +The SEARCH section must exactly match existing content including whitespace and indentation. +If you're not confident in the exact content to search for, use the read_file tool first to get the exact content. +When applying changes to Markdown, be careful about maintaining list structures, heading levels, and other Markdown formatting. +ALWAYS make as many changes in a single 'apply_diff' request as possible using multiple SEARCH/REPLACE blocks + +Parameters: +- path: (required) The path of the file to modify (relative to the current working directory ${args.cwd}) +- diff: (required) The search/replace block defining the changes. + +Diff format: +\`\`\` +<<<<<<< SEARCH +:start_line: (required) The line number of original content where the search block starts. +:end_line: (required) The line number of original content where the search block ends. +------- +[exact content to find including whitespace] +======= +[new content to replace with] +>>>>>>> REPLACE + +\`\`\` + +Example: + +Original Markdown file: +\`\`\` +1 | # Project Notes +2 | +3 | ## Tasks +4 | - [ ] Review documentation +5 | - [ ] Update examples +6 | - [ ] Add new section +\`\`\` + +Search/Replace content: +\`\`\` +<<<<<<< SEARCH +:start_line:3 +:end_line:6 +------- +## Tasks +- [ ] Review documentation +- [ ] Update examples +- [ ] Add new section +======= +## Current Tasks +- [ ] Review documentation +- [x] Update examples +- [ ] Add new section +- [ ] Schedule team meeting +>>>>>>> REPLACE + +\`\`\` + +Search/Replace content with multi edits: +\`\`\` +<<<<<<< SEARCH +:start_line:1 +:end_line:1 +------- +# Project Notes +======= +# Project Notes (Updated) +>>>>>>> REPLACE + +<<<<<<< SEARCH +:start_line:4 +:end_line:5 +------- +- [ ] Review documentation +- [ ] Update examples +======= +- [ ] Review documentation (priority) +- [x] Update examples +>>>>>>> REPLACE +\`\`\` + +Usage: + +File path here + +Your search/replace content here +You can use multi search/replace block in one diff block, but make sure to include the line numbers for each block. +Only use a single line of '=======' between search and replacement content, because multiple '=======' will corrupt the file. + +` + } + + async applyDiff( + originalContent: string, + diffContent: string, + _paramStartLine?: number, + _paramEndLine?: number, + ): Promise { + let matches = [ + ...diffContent.matchAll( + /<<<<<<< SEARCH\n(:start_line:\s*(\d+)\n){0,1}(:end_line:\s*(\d+)\n){0,1}(-------\n){0,1}([\s\S]*?)\n?=======\n([\s\S]*?)\n?>>>>>>> REPLACE/g, + ), + ] + + if (matches.length === 0) { + return { + success: false, + error: `Invalid diff format - missing required sections\n\nDebug Info:\n- Expected Format: <<<<<<< SEARCH\\n:start_line: start line\\n:end_line: end line\\n-------\\n[search content]\\n=======\\n[replace content]\\n>>>>>>> REPLACE\n- Tip: Make sure to include start_line/end_line/SEARCH/REPLACE sections with correct markers`, + } + } + // Detect line ending from original content + const lineEnding = originalContent.includes("\r\n") ? "\r\n" : "\n" + let resultLines = originalContent.split(/\r?\n/) + let delta = 0 + let diffResults: DiffResult[] = [] + let appliedCount = 0 + const replacements = matches + .map((match) => ({ + startLine: Number(match[2] ?? 0), + endLine: Number(match[4] ?? resultLines.length), + searchContent: match[6], + replaceContent: match[7], + })) + .sort((a, b) => a.startLine - b.startLine) + + for (let { searchContent, replaceContent, startLine, endLine } of replacements) { + startLine += startLine === 0 ? 0 : delta + endLine += delta + + // Strip line numbers from search and replace content if every line starts with a line number + if (everyLineHasLineNumbers(searchContent) && everyLineHasLineNumbers(replaceContent)) { + searchContent = stripLineNumbers(searchContent) + replaceContent = stripLineNumbers(replaceContent) + } + + // Split content into lines, handling both \n and \r\n + const searchLines = searchContent === "" ? [] : searchContent.split(/\r?\n/) + const replaceLines = replaceContent === "" ? [] : replaceContent.split(/\r?\n/) + + // Validate that empty search requires start line + if (searchLines.length === 0 && !startLine) { + diffResults.push({ + success: false, + error: `Empty search content requires start_line to be specified\n\nDebug Info:\n- Empty search content is only valid for insertions at a specific line\n- For insertions, specify the line number where content should be inserted`, + }) + continue + } + + // Validate that empty search requires same start and end line + if (searchLines.length === 0 && startLine && endLine && startLine !== endLine) { + diffResults.push({ + success: false, + error: `Empty search content requires start_line and end_line to be the same (got ${startLine}-${endLine})\n\nDebug Info:\n- Empty search content is only valid for insertions at a specific line\n- For insertions, use the same line number for both start_line and end_line`, + }) + continue + } + + // Initialize search variables + let matchIndex = -1 + let bestMatchScore = 0 + let bestMatchContent = "" + const searchChunk = searchLines.join("\n") + + // Determine search bounds + let searchStartIndex = 0 + let searchEndIndex = resultLines.length + + // Validate and handle line range if provided + if (startLine && endLine) { + // Convert to 0-based index + const exactStartIndex = startLine - 1 + const exactEndIndex = endLine - 1 + + if (exactStartIndex < 0 || exactEndIndex > resultLines.length || exactStartIndex > exactEndIndex) { + diffResults.push({ + success: false, + error: `Line range ${startLine}-${endLine} is invalid (file has ${resultLines.length} lines)\n\nDebug Info:\n- Requested Range: lines ${startLine}-${endLine}\n- File Bounds: lines 1-${resultLines.length}`, + }) + continue + } + + // Try exact match first + const originalChunk = resultLines.slice(exactStartIndex, exactEndIndex + 1).join("\n") + const similarity = getSimilarity(originalChunk, searchChunk) + if (similarity >= this.fuzzyThreshold) { + matchIndex = exactStartIndex + bestMatchScore = similarity + bestMatchContent = originalChunk + } else { + // Set bounds for buffered search + searchStartIndex = Math.max(0, startLine - (this.bufferLines + 1)) + searchEndIndex = Math.min(resultLines.length, endLine + this.bufferLines) + } + } + + // If no match found yet, try middle-out search within bounds + if (matchIndex === -1) { + const midPoint = Math.floor((searchStartIndex + searchEndIndex) / 2) + let leftIndex = midPoint + let rightIndex = midPoint + 1 + + // Search outward from the middle within bounds + while (leftIndex >= searchStartIndex || rightIndex <= searchEndIndex - searchLines.length) { + // Check left side if still in range + if (leftIndex >= searchStartIndex) { + const originalChunk = resultLines.slice(leftIndex, leftIndex + searchLines.length).join("\n") + const similarity = getSimilarity(originalChunk, searchChunk) + if (similarity > bestMatchScore) { + bestMatchScore = similarity + matchIndex = leftIndex + bestMatchContent = originalChunk + } + leftIndex-- + } + + // Check right side if still in range + if (rightIndex <= searchEndIndex - searchLines.length) { + const originalChunk = resultLines.slice(rightIndex, rightIndex + searchLines.length).join("\n") + const similarity = getSimilarity(originalChunk, searchChunk) + if (similarity > bestMatchScore) { + bestMatchScore = similarity + matchIndex = rightIndex + bestMatchContent = originalChunk + } + rightIndex++ + } + } + } + + // Require similarity to meet threshold + if (matchIndex === -1 || bestMatchScore < this.fuzzyThreshold) { + const searchChunk = searchLines.join("\n") + const originalContentSection = + startLine !== undefined && endLine !== undefined + ? `\n\nOriginal Content:\n${addLineNumbers( + resultLines + .slice( + Math.max(0, startLine - 1 - this.bufferLines), + Math.min(resultLines.length, endLine + this.bufferLines), + ) + .join("\n"), + Math.max(1, startLine - this.bufferLines), + )}` + : `\n\nOriginal Content:\n${addLineNumbers(resultLines.join("\n"))}` + + const bestMatchSection = bestMatchContent + ? `\n\nBest Match Found:\n${addLineNumbers(bestMatchContent, matchIndex + 1)}` + : `\n\nBest Match Found:\n(no match)` + + const lineRange = + startLine || endLine + ? ` at ${startLine ? `start: ${startLine}` : "start"} to ${endLine ? `end: ${endLine}` : "end"}` + : "" + + diffResults.push({ + success: false, + error: `No sufficiently similar match found${lineRange} (${Math.floor(bestMatchScore * 100)}% similar, needs ${Math.floor(this.fuzzyThreshold * 100)}%)\n\nDebug Info:\n- Similarity Score: ${Math.floor(bestMatchScore * 100)}%\n- Required Threshold: ${Math.floor(this.fuzzyThreshold * 100)}%\n- Search Range: ${startLine && endLine ? `lines ${startLine}-${endLine}` : "start to end"}\n- Tip: Use read_file to get the latest content of the file before attempting the diff again, as the file content may have changed\n\nSearch Content:\n${searchChunk}${bestMatchSection}${originalContentSection}`, + }) + continue + } + + // Get the matched lines from the original content + const matchedLines = resultLines.slice(matchIndex, matchIndex + searchLines.length) + + // Get the exact indentation (preserving tabs/spaces) of each line + const originalIndents = matchedLines.map((line) => { + const match = line.match(/^[\t ]*/) + return match ? match[0] : "" + }) + + // Get the exact indentation of each line in the search block + const searchIndents = searchLines.map((line) => { + const match = line.match(/^[\t ]*/) + return match ? match[0] : "" + }) + + // Apply the replacement while preserving exact indentation + const indentedReplaceLines = replaceLines.map((line, i) => { + // Get the matched line's exact indentation + const matchedIndent = originalIndents[0] || "" + + // Get the current line's indentation relative to the search content + const currentIndentMatch = line.match(/^[\t ]*/) + const currentIndent = currentIndentMatch ? currentIndentMatch[0] : "" + const searchBaseIndent = searchIndents[0] || "" + + // Calculate the relative indentation level + const searchBaseLevel = searchBaseIndent.length + const currentLevel = currentIndent.length + const relativeLevel = currentLevel - searchBaseLevel + + // If relative level is negative, remove indentation from matched indent + // If positive, add to matched indent + const finalIndent = + relativeLevel < 0 + ? matchedIndent.slice(0, Math.max(0, matchedIndent.length + relativeLevel)) + : matchedIndent + currentIndent.slice(searchBaseLevel) + + return finalIndent + line.trim() + }) + + // Construct the final content + const beforeMatch = resultLines.slice(0, matchIndex) + const afterMatch = resultLines.slice(matchIndex + searchLines.length) + resultLines = [...beforeMatch, ...indentedReplaceLines, ...afterMatch] + delta = delta - matchedLines.length + replaceLines.length + appliedCount++ + } + const finalContent = resultLines.join(lineEnding) + if (appliedCount === 0) { + return { + success: false, + failParts: diffResults, + } + } + return { + success: true, + content: finalContent, + failParts: diffResults, + } + } + + getProgressStatus(toolUse: ToolUse, result?: DiffResult): ToolProgressStatus { + const diffContent = toolUse.params.diff + if (diffContent) { + const icon = "diff-multiple" + const searchBlockCount = (diffContent.match(/SEARCH/g) || []).length + if (toolUse.partial) { + if (diffContent.length < 1000 || (diffContent.length / 50) % 10 === 0) { + return { icon, text: `${searchBlockCount}` } + } + } else if (result) { + if (result.failParts?.length) { + return { + icon, + text: `${searchBlockCount - result.failParts.length}/${searchBlockCount}`, + } + } else { + return { icon, text: `${searchBlockCount}` } + } + } + } + return {} + } +} diff --git a/src/core/diff/strategies/new-unified/edit-strategies.ts b/src/core/diff/strategies/new-unified/edit-strategies.ts index 81922b1..18c00b3 100644 --- a/src/core/diff/strategies/new-unified/edit-strategies.ts +++ b/src/core/diff/strategies/new-unified/edit-strategies.ts @@ -1,31 +1,35 @@ -import { diff_match_patch } from "diff-match-patch" -import { EditResult, Hunk } from "./types" -import { getDMPSimilarity, validateEditResult } from "./search-strategies" -import * as path from "path" -import simpleGit, { SimpleGit } from "simple-git" -import * as tmp from "tmp" +import { App, FileSystemAdapter, normalizePath } from "obsidian" + import * as fs from "fs" +import * as path from "path" -// Helper function to infer indentation - simplified version -function inferIndentation(line: string, contextLines: string[], previousIndent: string = ""): string { - // If the line has explicit indentation in the change, use it exactly - const lineMatch = line.match(/^(\s+)/) - if (lineMatch) { - return lineMatch[1] - } +import { diff_match_patch } from "diff-match-patch" +import simpleGit, { SimpleGit } from "simple-git" - // If we have context lines, use the indentation from the first context line - const contextLine = contextLines[0] - if (contextLine) { - const contextMatch = contextLine.match(/^(\s+)/) - if (contextMatch) { - return contextMatch[1] - } - } +import { validateEditResult } from "./search-strategies" +import { EditResult, Hunk } from "./types" - // Fallback to previous indent - return previousIndent -} + +// // Helper function to infer indentation - simplified version +// function inferIndentation(line: string, contextLines: string[], previousIndent: string = ""): string { +// // If the line has explicit indentation in the change, use it exactly +// const lineMatch = line.match(/^(\s+)/) +// if (lineMatch) { +// return lineMatch[1] +// } + +// // If we have context lines, use the indentation from the first context line +// const contextLine = contextLines[0] +// if (contextLine) { +// const contextMatch = contextLine.match(/^(\s+)/) +// if (contextMatch) { +// return contextMatch[1] +// } +// } + +// // Fallback to previous indent +// return previousIndent +// } // Context matching edit strategy export function applyContextMatching(hunk: Hunk, content: string[], matchPosition: number): EditResult { @@ -147,18 +151,28 @@ export function applyDMP(hunk: Hunk, content: string[], matchPosition: number): } // Git fallback strategy that works with full content -export async function applyGitFallback(hunk: Hunk, content: string[]): Promise { - let tmpDir: tmp.DirResult | undefined +export async function applyGitFallback(app: App, hunk: Hunk, content: string[]): Promise { + // let tmpDir: tmp.DirResult | undefined + const adapter = app.vault.adapter as FileSystemAdapter; + const vaultBasePath = adapter.getBasePath(); + const tmpGitPath = normalizePath(path.join(vaultBasePath, ".tmp_git")); + + console.log("tmpGitPath", tmpGitPath) try { - tmpDir = tmp.dirSync({ unsafeCleanup: true }) - const git: SimpleGit = simpleGit(tmpDir.name) + const exists = await adapter.exists(tmpGitPath); + if (exists) { + await adapter.rmdir(tmpGitPath, true); + } + await adapter.mkdir(tmpGitPath); + // tmpDir = tmp.dirSync({ unsafeCleanup: true }) + const git: SimpleGit = simpleGit(tmpGitPath) await git.init() await git.addConfig("user.name", "Temp") await git.addConfig("user.email", "temp@example.com") - const filePath = path.join(tmpDir.name, "file.txt") + const filePath = path.join(tmpGitPath, "file.txt") const searchLines = hunk.changes .filter((change) => change.type === "context" || change.type === "remove") @@ -256,14 +270,15 @@ export async function applyGitFallback(hunk: Hunk, content: string[]): Promise applyDMP(hunk, content, matchPosition) }, { name: "context", apply: () => applyContextMatching(hunk, content, matchPosition) }, - { name: "git-fallback", apply: () => applyGitFallback(hunk, content) }, + { name: "git-fallback", apply: () => applyGitFallback(app, hunk, content) }, ] // Try strategies sequentially until one succeeds diff --git a/src/core/diff/strategies/new-unified/index.ts b/src/core/diff/strategies/new-unified/index.ts index d82a05a..16fe733 100644 --- a/src/core/diff/strategies/new-unified/index.ts +++ b/src/core/diff/strategies/new-unified/index.ts @@ -1,18 +1,33 @@ -import { Diff, Hunk, Change } from "./types" -import { findBestMatch, prepareSearchString } from "./search-strategies" -import { applyEdit } from "./edit-strategies" +import { App } from 'obsidian' + import { DiffResult, DiffStrategy } from "../../types" +import { applyEdit } from "./edit-strategies" +import { findBestMatch, prepareSearchString } from "./search-strategies" +import { Change, Diff, Hunk } from "./types" + +// 中文引号转英文引号 +export function convertQuotes(str: string) { + return str.replace(/[“”]/g, '"'); +} + export class NewUnifiedDiffStrategy implements DiffStrategy { private readonly confidenceThreshold: number + private app: App - constructor(confidenceThreshold: number = 1) { + getName(): string { + return "NewUnified" + } + + constructor(app: App, confidenceThreshold: number = 1) { + this.app = app this.confidenceThreshold = Math.max(confidenceThreshold, 0.8) } private parseUnifiedDiff(diff: string): Diff { const MAX_CONTEXT_LINES = 6 // Number of context lines to keep before/after changes const lines = diff.split("\n") + // console.log("lines: ", lines) const hunks: Hunk[] = [] let currentHunk: Hunk | null = null @@ -60,7 +75,7 @@ export class NewUnifiedDiffStrategy implements DiffStrategy { } const content = line.slice(1) - const indentMatch = content.match(/^(\s*)/) + const indentMatch = /^(\s*)/.exec(content) const indent = indentMatch ? indentMatch[0] : "" const trimmedContent = content.slice(indent.length) @@ -85,6 +100,8 @@ export class NewUnifiedDiffStrategy implements DiffStrategy { indent, originalLine: content, }) + } else if (line.startsWith("reason: ")) { + // ignore reason } else { const finalContent = trimmedContent ? " " + trimmedContent : " " currentHunk.changes.push({ @@ -108,9 +125,9 @@ export class NewUnifiedDiffStrategy implements DiffStrategy { } getToolDescription(args: { cwd: string; toolOptions?: { [key: string]: string } }): string { - return `# apply_diff Tool - Generate Precise Code Changes + return `# apply_diff Tool - Generate Precise Markdown Changes -Generate a unified diff that can be cleanly applied to modify code files. +Generate a unified diff that can be cleanly applied to modify markdown files. ## Step-by-Step Instructions: @@ -120,47 +137,45 @@ Generate a unified diff that can be cleanly applied to modify code files. 2. For each change section: - Begin with "@@ ... @@" separator line without line numbers - - Include 2-3 lines of context before and after changes - - Mark removed lines with "-" - - Mark added lines with "+" - - Preserve exact indentation + - Mark added lines with "+" prefix (without line numbers) + - Mark removed lines with "-" prefix (without line numbers) + - Mark reason with "reason: " + - Preserve exact spacing and formatting 3. Group related changes: - Keep related modifications in the same hunk - Start new hunks for logically separate changes - - When modifying functions/methods, include the entire block ## Requirements: -1. MUST include exact indentation -2. MUST include sufficient context for unique matching -3. MUST group related changes together -4. MUST use proper unified diff format -5. MUST NOT include timestamps in file headers -6. MUST NOT include line numbers in the @@ header +1. MUST include reason, avoid unnecessary modifications +2. MUST include exact spacing and formatting +3. MUST include sufficient context for unique matching +4. MUST group related changes together +5. MUST use proper unified diff format +6. MUST NOT include timestamps in file headers +7. MUST NOT include line numbers in the @@ header +8. MUST NOT include line numbers in the added lines and removed lines ## Examples: ✅ Good diff (follows all requirements): \`\`\`diff ---- src/utils.ts -+++ src/utils.ts +--- docs/example.md ++++ docs/example.md @@ ... @@ - def calculate_total(items): -- total = 0 -- for item in items: -- total += item.price -+ return sum(item.price for item in items) +-old content ++new content +reason: change reason \`\`\` -❌ Bad diff (violates requirements #1 and #2): +❌ Bad diff (violates requirements #8) \`\`\`diff ---- src/utils.ts -+++ src/utils.ts +--- docs/example.md ++++ docs/example.md @@ ... @@ --total = 0 --for item in items: -+return sum(item.price for item in items) +- 6 | old content ++ 6 | new content \`\`\` Parameters: @@ -169,7 +184,7 @@ Parameters: Usage: -path/to/file.ext +path/to/file.md Your diff here @@ -236,7 +251,7 @@ Your diff here endLine?: number, ): Promise { const parsedDiff = this.parseUnifiedDiff(diffContent) - const originalLines = originalContent.split("\n") + const originalLines = convertQuotes(originalContent).split("\n") let result = [...originalLines] if (!parsedDiff.hunks.length) { @@ -247,13 +262,12 @@ Your diff here } for (const hunk of parsedDiff.hunks) { - const contextStr = prepareSearchString(hunk.changes) + const contextStr = convertQuotes(prepareSearchString(hunk.changes)) const { index: matchPosition, confidence, strategy, } = findBestMatch(contextStr, result, 0, this.confidenceThreshold) - if (confidence < this.confidenceThreshold) { console.log("Full hunk application failed, trying sub-hunks strategy") // Try splitting the hunk into smaller hunks @@ -267,6 +281,7 @@ Your diff here if (subSearchResult.confidence >= this.confidenceThreshold) { const subEditResult = await applyEdit( + this.app, subHunk, subHunkResult, subSearchResult.index, @@ -324,7 +339,14 @@ Your diff here return { success: false, error: errorMsg } } - const editResult = await applyEdit(hunk, result, matchPosition, confidence, this.confidenceThreshold) + const editResult = await applyEdit( + this.app, + hunk, + result, + matchPosition, + confidence, + this.confidenceThreshold, + ) if (editResult.confidence >= this.confidenceThreshold) { result = editResult.result } else { diff --git a/src/core/diff/strategies/new-unified/search-strategies.ts b/src/core/diff/strategies/new-unified/search-strategies.ts index 97fd499..4aafa8b 100644 --- a/src/core/diff/strategies/new-unified/search-strategies.ts +++ b/src/core/diff/strategies/new-unified/search-strategies.ts @@ -1,6 +1,7 @@ -import { compareTwoStrings } from "string-similarity" -import { closest } from "fastest-levenshtein" import { diff_match_patch } from "diff-match-patch" +import { closest } from "fastest-levenshtein" +import { compareTwoStrings } from "string-similarity" + import { Change, Hunk } from "./types" export type SearchResult = { @@ -44,7 +45,7 @@ function evaluateContentUniqueness(searchStr: string, content: string[]): number // Helper function to prepare search string from context export function prepareSearchString(changes: Change[]): string { - const lines = changes.filter((c) => c.type === "context" || c.type === "remove").map((c) => c.originalLine) + const lines = changes.filter((c) => c.type === "remove").map((c) => c.content) return lines.join("\n") } @@ -198,12 +199,16 @@ export function findExactMatch( startIndex: number = 0, confidenceThreshold: number = 0.97, ): SearchResult { + // console.log("searchStr: ", searchStr) + // console.log("content: ", content) const searchLines = searchStr.split("\n") const windows = createOverlappingWindows(content.slice(startIndex), searchLines.length) const matches: (SearchResult & { windowIndex: number })[] = [] windows.forEach((windowData, windowIndex) => { const windowStr = windowData.window.join("\n") + // console.log("searchStr: ", searchStr) + // console.log("windowStr:", windowStr) const exactMatch = windowStr.indexOf(searchStr) if (exactMatch !== -1) { @@ -399,10 +404,18 @@ export function findBestMatch( for (const strategy of strategies) { const result = strategy(searchStr, content, startIndex, confidenceThreshold) + if (searchStr === "由于年久失修,街区路面坑洼不平,污水横流,垃圾遍地,甚至可见弹痕血迹。") { + console.log("findBestMatch result: ", strategy.name, result) + } if (result.confidence > bestResult.confidence) { bestResult = result } } + // if (bestResult.confidence < 0.97) { + // console.log("searchStr: ", searchStr) + // console.log("content: ", content) + // console.log("findBestMatch result: ", bestResult) + // } return bestResult } diff --git a/src/core/diff/strategies/search-replace.ts b/src/core/diff/strategies/search-replace.ts index a9bf467..b85f374 100644 --- a/src/core/diff/strategies/search-replace.ts +++ b/src/core/diff/strategies/search-replace.ts @@ -1,7 +1,8 @@ -import { DiffStrategy, DiffResult } from "../types" -import { addLineNumbers, everyLineHasLineNumbers, stripLineNumbers } from "../../../integrations/misc/extract-text" import { distance } from "fastest-levenshtein" +import { addLineNumbers, everyLineHasLineNumbers, stripLineNumbers } from "../../../utils/extract-text" +import { DiffResult, DiffStrategy } from "../types" + const BUFFER_LINES = 20 // Number of extra context lines to show before and after matches function getSimilarity(original: string, search: string): number { @@ -31,6 +32,10 @@ export class SearchReplaceDiffStrategy implements DiffStrategy { private fuzzyThreshold: number private bufferLines: number + getName(): string { + return "SearchReplace" + } + constructor(fuzzyThreshold?: number, bufferLines?: number) { // Use provided threshold or default to exact matching (1.0) // Note: fuzzyThreshold is inverted in UI (0% = 1.0, 10% = 0.9) @@ -225,14 +230,14 @@ Your search/replace content here const originalContentSection = startLine !== undefined && endLine !== undefined ? `\n\nOriginal Content:\n${addLineNumbers( - originalLines - .slice( - Math.max(0, startLine - 1 - this.bufferLines), - Math.min(originalLines.length, endLine + this.bufferLines), - ) - .join("\n"), - Math.max(1, startLine - this.bufferLines), - )}` + originalLines + .slice( + Math.max(0, startLine - 1 - this.bufferLines), + Math.min(originalLines.length, endLine + this.bufferLines), + ) + .join("\n"), + Math.max(1, startLine - this.bufferLines), + )}` : `\n\nOriginal Content:\n${addLineNumbers(originalLines.join("\n"))}` const bestMatchSection = bestMatchContent diff --git a/src/core/diff/types.ts b/src/core/diff/types.ts index 61275de..9c70fcb 100644 --- a/src/core/diff/types.ts +++ b/src/core/diff/types.ts @@ -3,20 +3,28 @@ */ export type DiffResult = - | { success: true; content: string } - | { - success: false - error: string - details?: { - similarity?: number - threshold?: number - matchedRange?: { start: number; end: number } - searchContent?: string - bestMatch?: string - } - } + | { success: true; content: string; failParts?: DiffResult[] } + | ({ + success: false + error?: string + details?: { + similarity?: number + threshold?: number + matchedRange?: { start: number; end: number } + searchContent?: string + bestMatch?: string + } + failParts?: DiffResult[] + } & ({ error: string } | { failParts: DiffResult[] })) export interface DiffStrategy { + + /** + * Get the name of this diff strategy for analytics and debugging + * @returns The name of the diff strategy + */ + getName(): string + /** * Get the tool description for this diff strategy * @param args The tool arguments including cwd and toolOptions diff --git a/src/core/prompts/sections/rules.ts b/src/core/prompts/sections/rules.ts index 5cb1acb..21e8754 100644 --- a/src/core/prompts/sections/rules.ts +++ b/src/core/prompts/sections/rules.ts @@ -1,9 +1,14 @@ import { DiffStrategy } from "../../diff/DiffStrategy" -function getEditingInstructions(diffStrategy?: DiffStrategy, experiments?: Record): string { +function getEditingInstructions(diffStrategy?: DiffStrategy): string { const instructions: string[] = [] const availableTools: string[] = [] + const experiments = { + insert_content: true, + search_and_replace: true, + } + // Collect available editing tools if (diffStrategy) { availableTools.push( @@ -90,7 +95,7 @@ RULES - Your current obsidian directory is: ${cwd.toPosix()} ${getSearchInstructions(searchTool)} - When creating new notes in Obsidian, organize them according to the existing vault structure unless the user specifies otherwise. Use appropriate file paths when writing files, as the write_to_file tool will automatically create any necessary directories. Structure the content logically, adhering to Obsidian conventions with appropriate frontmatter, headings, lists, and formatting. Unless otherwise specified, new notes should follow Markdown syntax with appropriate use of links ([[note name]]), tags (#tag), callouts, and other Obsidian-specific formatting. -${getEditingInstructions(diffStrategy, experiments)} +${getEditingInstructions(diffStrategy)} - Be sure to consider the structure of the Obsidian vault (folders, naming conventions, note organization) when determining the appropriate format and content for new or modified notes. Also consider what files may be most relevant to accomplishing the task, for example examining backlinks, linked mentions, or tags would help you understand the relationships between notes, which you could incorporate into any content you write. - When making changes to content, always consider the context within the broader vault. Ensure that your changes maintain existing links, tags, and references, and that they follow the user's established formatting standards and organization. - Do not ask for more information than necessary. Use the tools provided to accomplish the user's request efficiently and effectively. When you've completed your task, you must use the attempt_completion tool to present the result to the user. The user may provide feedback, which you can use to make improvements and try again. @@ -100,7 +105,7 @@ ${getEditingInstructions(diffStrategy, experiments)} - NEVER end attempt_completion result with a question or request to engage in further conversation! Formulate the end of your result in a way that is final and does not require further input from the user. - You are STRICTLY FORBIDDEN from starting your messages with "Great", "Certainly", "Okay", "Sure". You should NOT be conversational in your responses, but rather direct and to the point. For example you should NOT say "Great, I've updated the markdown" but instead something like "I've updated the markdown". It is important you be clear and technical in your messages. - When presented with images, utilize your vision capabilities to thoroughly examine them and extract meaningful information. Incorporate these insights into your thought process as you accomplish the user's task. -- At the end of each user message, you will automatically receive environment_details. This information is not written by the user themselves, but is auto-generated to provide potentially relevant context about the Obsidian environment. This includes the current file being edited, open tabs, and the vault structure. While this information can be valuable for understanding the context, do not treat it as a direct part of the user's request or response. Use it to inform your actions and decisions, but don't assume the user is explicitly asking about or referring to this information unless they clearly do so in their message. When using environment_details, explain your actions clearly to ensure the user understands, as they may not be aware of these details. +- At the end of the first user message, you will automatically receive environment_details. This information is not written by the user themselves, but is auto-generated to provide potentially relevant context about the Obsidian environment. This includes the current file being edited, open tabs, and the vault structure. While this information can be valuable for understanding the context, do not treat it as a direct part of the user's request or response. Use it to inform your actions and decisions, but don't assume the user is explicitly asking about or referring to this information unless they clearly do so in their message. When using environment_details, explain your actions clearly to ensure the user understands, as they may not be aware of these details. - Pay special attention to the open tabs in environment_details, as they indicate which notes the user is currently working with and may be most relevant to their task. Similarly, the current file information shows which note is currently in focus and likely the primary subject of the user's request. - It is critical you wait for the user's response after each tool use, in order to confirm the success of the tool use. For example, if asked to create a structured note, you would create a file, wait for the user's response it was created successfully, then create another file if needed, wait for the user's response it was created successfully, etc.` } diff --git a/src/core/prompts/system.ts b/src/core/prompts/system.ts index 56d5389..212bead 100644 --- a/src/core/prompts/system.ts +++ b/src/core/prompts/system.ts @@ -44,8 +44,8 @@ async function generatePrompt( // throw new Error("Extension context is required for generating system prompt") // } - // If diff is disabled, don't pass the diffStrategy - const effectiveDiffStrategy = diffEnabled ? diffStrategy : undefined + // // If diff is disabled, don't pass the diffStrategy + // const effectiveDiffStrategy = diffEnabled ? diffStrategy : undefined // Get the full mode config to ensure we have the role definition const modeConfig = getModeBySlug(mode, customModeConfigs) || modes.find((m) => m.slug === mode) || modes[0] @@ -54,7 +54,7 @@ async function generatePrompt( const [modesSection, mcpServersSection] = await Promise.all([ getModesSection(), modeConfig.groups.some((groupEntry) => getGroupName(groupEntry) === "mcp") - ? getMcpServersSection(mcpHub, effectiveDiffStrategy, enableMcpServerCreation) + ? getMcpServersSection(mcpHub, diffStrategy, enableMcpServerCreation) : Promise.resolve(""), ]) @@ -67,7 +67,7 @@ ${getToolDescriptionsForMode( cwd, filesSearchMethod, supportsComputerUse, - effectiveDiffStrategy, + diffStrategy, browserViewportSize, mcpHub, customModeConfigs, @@ -91,7 +91,7 @@ ${getRulesSection( cwd, filesSearchMethod, supportsComputerUse, - effectiveDiffStrategy, + diffStrategy, experiments, )} @@ -110,8 +110,8 @@ export const SYSTEM_PROMPT = async ( mode: Mode = defaultModeSlug, filesSearchMethod: string = 'regex', preferredLanguage?: string, - mcpHub?: McpHub, diffStrategy?: DiffStrategy, + mcpHub?: McpHub, browserViewportSize?: string, customModePrompts?: CustomModePrompts, customModes?: ModeConfig[], @@ -150,8 +150,8 @@ export const SYSTEM_PROMPT = async ( // ${await addCustomInstructions(promptComponent?.customInstructions || currentMode.customInstructions || "", globalCustomInstructions || "", cwd, mode, { preferredLanguage })}` // } - // If diff is disabled, don't pass the diffStrategy - const effectiveDiffStrategy = diffEnabled ? diffStrategy : undefined + // // If diff is disabled, don't pass the diffStrategy + // const effectiveDiffStrategy = diffEnabled ? diffStrategy : undefined return generatePrompt( // context, @@ -160,7 +160,7 @@ export const SYSTEM_PROMPT = async ( currentMode.slug, filesSearchMethod, mcpHub, - effectiveDiffStrategy, + diffStrategy, browserViewportSize, promptComponent, customModes, diff --git a/src/core/prompts/tools/ask-followup-question.ts b/src/core/prompts/tools/ask-followup-question.ts index 81c3aa8..265447f 100644 --- a/src/core/prompts/tools/ask-followup-question.ts +++ b/src/core/prompts/tools/ask-followup-question.ts @@ -1,4 +1,4 @@ -export function getAskFollowupQuestionDescription(userLanguage: string): string { +export function getAskFollowupQuestionDescription(): string { return `## ask_followup_question Description: Ask the user a question to gather additional information needed to complete the task. This tool should be used when you encounter ambiguities, need clarification, or require more details to proceed effectively. It allows for interactive problem-solving by enabling direct communication with the user. Use this tool judiciously to maintain a balance between gathering necessary information and avoiding excessive back-and-forth. Parameters: @@ -10,5 +10,6 @@ Usage: Example: Requesting to ask the user for their preferred citation style for an academic document -Which citation style would you like to use for your academic paper (APA, MLA, Chicago, etc.)?` +Which citation style would you like to use for your academic paper (APA, MLA, Chicago, etc.)? +` } diff --git a/src/core/prompts/tools/insert-content.ts b/src/core/prompts/tools/insert-content.ts index 4259c39..7a4befd 100644 --- a/src/core/prompts/tools/insert-content.ts +++ b/src/core/prompts/tools/insert-content.ts @@ -18,6 +18,7 @@ Usage: } ] + Example: Insert a new section heading and paragraph chapter1.md diff --git a/src/core/prompts/tools/search-and-replace.ts b/src/core/prompts/tools/search-and-replace.ts index 971525c..ea564d4 100644 --- a/src/core/prompts/tools/search-and-replace.ts +++ b/src/core/prompts/tools/search-and-replace.ts @@ -26,7 +26,7 @@ Usage: ] -Example: Replace "climate change" with "climate crisis" in lines 1-10 of an essay +Example 1: Replace "climate change" with "climate crisis" in lines 1-10 of an essay essays/environmental-impact.md [ @@ -38,7 +38,8 @@ Example: Replace "climate change" with "climate crisis" in lines 1-10 of an essa } ] -Example: Update citation format throughout a document using regex + +Example 2: Update citation format throughout a document using regex research-paper.md [ diff --git a/src/core/prompts/tools/search-web.ts b/src/core/prompts/tools/search-web.ts index c603b8e..2e6bf8d 100644 --- a/src/core/prompts/tools/search-web.ts +++ b/src/core/prompts/tools/search-web.ts @@ -16,17 +16,17 @@ Usage: Your search query here -Examples1: +Example 1: capital of France population statistics 2023 -Examples2: +Example 2: "renewable energy" growth statistics Europe -Examples3: +Example 3: react vs angular vs vue.js comparison ` diff --git a/src/database/database-manager.ts b/src/database/database-manager.ts index 454cc03..c6ae5b8 100644 --- a/src/database/database-manager.ts +++ b/src/database/database-manager.ts @@ -12,7 +12,7 @@ import { VectorManager } from './modules/vector/vector-manager' // import { migrations } from './sql' export class DBManager { - // private app: App + private app: App // private dbPath: string private db: PGliteWithLive | null = null // private db: PgliteDatabase | null = null @@ -65,34 +65,34 @@ export class DBManager { // }) // } - // private async loadExistingDatabase() { - // try { - // const databaseFileExists = await this.app.vault.adapter.exists( - // this.dbPath, - // ) - // if (!databaseFileExists) { - // return null - // } - // const fileBuffer = await this.app.vault.adapter.readBinary(this.dbPath) - // const fileBlob = new Blob([fileBuffer], { type: 'application/x-gzip' }) - // const { fsBundle, wasmModule, vectorExtensionBundlePath } = - // await this.loadPGliteResources() - // this.db = await PGlite.create({ - // loadDataDir: fileBlob, - // fsBundle: fsBundle, - // wasmModule: wasmModule, - // extensions: { - // vector: vectorExtensionBundlePath, - // live - // }, - // }) - // // return drizzle(this.pgClient) - // } catch (error) { - // console.error('Error loading database:', error) - // console.log(this.dbPath) - // return null - // } - // } + private async loadExistingDatabase() { + try { + const databaseFileExists = await this.app.vault.adapter.exists( + this.dbPath, + ) + if (!databaseFileExists) { + return null + } + const fileBuffer = await this.app.vault.adapter.readBinary(this.dbPath) + const fileBlob = new Blob([fileBuffer], { type: 'application/x-gzip' }) + const { fsBundle, wasmModule, vectorExtensionBundlePath } = + await this.loadPGliteResources() + this.db = await PGlite.create({ + loadDataDir: fileBlob, + fsBundle: fsBundle, + wasmModule: wasmModule, + extensions: { + vector: vectorExtensionBundlePath, + live + }, + }) + // return drizzle(this.pgClient) + } catch (error) { + console.error('Error loading database:', error) + console.log(this.dbPath) + return null + } + } // private async migrateDatabase(): Promise { // if (!this.db) { diff --git a/src/database/modules/conversation/conversation-manager.ts b/src/database/modules/conversation/conversation-manager.ts index 2009086..5010b4f 100644 --- a/src/database/modules/conversation/conversation-manager.ts +++ b/src/database/modules/conversation/conversation-manager.ts @@ -1,4 +1,5 @@ import { App } from 'obsidian' +import { Transaction } from '@electric-sql/pglite' import { editorStateToPlainText } from '../../../components/chat-view/chat-input/utils/editor-state-to-plain-text' import { ChatAssistantMessage, ChatConversationMeta, ChatMessage, ChatUserMessage } from '../../../types/chat' @@ -22,42 +23,44 @@ export class ConversationManager { this.repository = new ConversationRepository(app, db) } - async createConversation(id: string, title = 'New chat'): Promise { + async createConversation(id: string, title = 'New chat', tx?: Transaction): Promise { const conversation = { id, title, createdAt: new Date(), updatedAt: new Date(), } - await this.repository.create(conversation) + await this.repository.create(conversation, tx) } - async saveConversation(id: string, messages: ChatMessage[]): Promise { - const conversation = await this.repository.findById(id) - if (!conversation) { - let title = 'New chat' - if (messages.length > 0 && messages[0].role === 'user') { - const query = editorStateToPlainText(messages[0].content) - if (query.length > 20) { - title = `${query.slice(0, 20)}...` - } else { - title = query + async txCreateOrUpdateConversation(id: string, messages: ChatMessage[]): Promise { + await this.repository.tx(async (tx) => { + const conversation = await this.repository.findById(id, tx) + if (!conversation) { + let title = 'New chat' + if (messages.length > 0 && messages[0].role === 'user') { + const query = editorStateToPlainText(messages[0].content) + if (query.length > 20) { + title = `${query.slice(0, 20)}...` + } else { + title = query + } } + await this.createConversation(id, title, tx) } - await this.createConversation(id, title) - } - // Delete existing messages - await this.repository.deleteAllMessagesFromConversation(id) + // Delete existing messages + await this.repository.deleteAllMessagesFromConversation(id, tx) - // Insert new messages - for (const message of messages) { - const insertMessage = this.serializeMessage(message, id) - await this.repository.createMessage(insertMessage) - } + // Insert new messages + for (const message of messages) { + const insertMessage = this.serializeMessage(message, id) + await this.repository.createMessage(insertMessage, tx) + } - // Update conversation timestamp - await this.repository.update(id, { updatedAt: new Date() }) + // Update conversation timestamp + await this.repository.update(id, { updatedAt: new Date() }, tx) + }) } async findConversation(id: string): Promise { diff --git a/src/database/modules/conversation/conversation-repository.ts b/src/database/modules/conversation/conversation-repository.ts index 8e820ec..c47c7ef 100644 --- a/src/database/modules/conversation/conversation-repository.ts +++ b/src/database/modules/conversation/conversation-repository.ts @@ -1,129 +1,139 @@ -import { PGliteInterface } from '@electric-sql/pglite' +import { PGliteInterface, Transaction } from '@electric-sql/pglite' import { App } from 'obsidian' import { - InsertConversation, - InsertMessage, - SelectConversation, - SelectMessage, + InsertConversation, + InsertMessage, + SelectConversation, + SelectMessage, } from '../../schema' + export class ConversationRepository { - private app: App - private db: PGliteInterface + private app: App + private db: PGliteInterface - constructor(app: App, db: PGliteInterface) { - this.app = app - this.db = db - } + constructor(app: App, db: PGliteInterface) { + this.app = app + this.db = db + } - async create(conversation: InsertConversation): Promise { - const result = await this.db.query( - `INSERT INTO conversations (id, title, created_at, updated_at) + async tx(callback: (tx: Transaction) => Promise) { + await this.db.transaction(async (tx) => { + await callback(tx) + }); + } + + async create(conversation: InsertConversation, tx?: Transaction): Promise { + const result = await (tx ?? this.db).query( + `INSERT INTO conversations (id, title, created_at, updated_at) VALUES ($1, $2, $3, $4) RETURNING *`, - [ - conversation.id, - conversation.title, - conversation.createdAt || new Date(), - conversation.updatedAt || new Date() - ] - ) - return result.rows[0] - } + [ + conversation.id, + conversation.title, + conversation.createdAt || new Date(), + conversation.updatedAt || new Date() + ] + ) + return result.rows[0] + } - async createMessage(message: InsertMessage): Promise { - const result = await this.db.query( - `INSERT INTO messages ( + async createMessage(message: InsertMessage, tx?: Transaction): Promise { + const result = await (tx ?? this.db).query( + `INSERT INTO messages ( id, conversation_id, apply_status, role, content, reasoning_content, prompt_content, metadata, mentionables, similarity_search_results, created_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`, - [ - message.id, - message.conversationId, - message.apply_status, - message.role, - message.content, - message.reasoningContent, - message.promptContent, - message.metadata, - message.mentionables, - message.similaritySearchResults, - message.createdAt || new Date() - ] + [ + message.id, + message.conversationId, + message.apply_status, + message.role, + message.content, + message.reasoningContent, + message.promptContent, + message.metadata, + message.mentionables, + message.similaritySearchResults, + message.createdAt || new Date() + ] ) - return result.rows[0] - } + console.log('createMessage: ', message.id, result) + return result.rows[0] + } - async findById(id: string): Promise { - const result = await this.db.query( - `SELECT * FROM conversations WHERE id = $1 LIMIT 1`, - [id] - ) - return result.rows[0] - } + async findById(id: string, tx?: Transaction): Promise { + const result = await (tx ?? this.db).query( + `SELECT * FROM conversations WHERE id = $1 LIMIT 1`, + [id] + ) + return result.rows[0] + } - async findMessagesByConversationId(conversationId: string): Promise { - const result = await this.db.query( - `SELECT * FROM messages + async findMessagesByConversationId(conversationId: string, tx?: Transaction): Promise { + const result = await (tx ?? this.db).query( + `SELECT * FROM messages WHERE conversation_id = $1 ORDER BY created_at`, - [conversationId] + [conversationId] ) - return result.rows - } + return result.rows + } - async findAll(): Promise { - const result = await this.db.query( - `SELECT * FROM conversations ORDER BY created_at DESC` - ) - return result.rows - } + async findAll(tx?: Transaction): Promise { + const result = await (tx ?? this.db).query( + `SELECT * FROM conversations ORDER BY created_at DESC` + ) + return result.rows + } - async update(id: string, data: Partial): Promise { - const setClauses: string[] = [] - const values: (string | Date)[] = [] - let paramIndex = 1 + async update(id: string, data: Partial, tx?: Transaction): Promise { + const setClauses: string[] = [] + const values: (string | Date)[] = [] + let paramIndex = 1 - if (data.title !== undefined) { - setClauses.push(`title = $${paramIndex}`) - values.push(data.title) - paramIndex++ - } + if (data.title !== undefined) { + setClauses.push(`title = $${paramIndex}`) + values.push(data.title) + paramIndex++ + } - // Always update updated_at - setClauses.push(`updated_at = $${paramIndex}`) - values.push(new Date()) - paramIndex++ + // Always update updated_at + setClauses.push(`updated_at = $${paramIndex}`) + values.push(new Date()) + paramIndex++ - // Add id as the last parameter - values.push(id) + // Add id as the last parameter + values.push(id) - const result = await this.db.query( - `UPDATE conversations + const result = await (tx ?? this.db).query( + `UPDATE conversations SET ${setClauses.join(', ')} WHERE id = $${paramIndex} RETURNING *`, - values - ) - return result.rows[0] - } + values + ) + return result.rows[0] + } - async delete(id: string): Promise { - const result = await this.db.query( - `DELETE FROM conversations WHERE id = $1 RETURNING *`, - [id] - ) - return result.rows.length > 0 - } + async delete(id: string, tx?: Transaction): Promise { + const result = await (tx ?? this.db).query( + `DELETE FROM conversations WHERE id = $1 RETURNING *`, + [id] + ) + return result.rows.length > 0 + } - async deleteAllMessagesFromConversation(conversationId: string): Promise { - await this.db.query( - `DELETE FROM messages WHERE conversation_id = $1`, - [conversationId] - ) - } + async deleteAllMessagesFromConversation(conversationId: string, tx?: Transaction): Promise { + const result = await (tx ?? this.db).query( + `DELETE FROM messages WHERE conversation_id = $1`, + [conversationId] + ) + console.log('deleteAllMessagesFromConversation', conversationId, result) + return + } } diff --git a/src/hooks/use-chat-history.ts b/src/hooks/use-chat-history.ts index 33fd2bf..3b4c36f 100644 --- a/src/hooks/use-chat-history.ts +++ b/src/hooks/use-chat-history.ts @@ -36,12 +36,11 @@ export function useChatHistory(): UseChatHistory { void fetchChatList() }, [fetchChatList]) - // 只新增消息 - const createConversation = useCallback( + const createOrUpdateConversation = useCallback( async (id: string, messages: ChatMessage[]): Promise => { const dbManager = await getManager() const conversationManager = dbManager.getConversationManager() - await conversationManager.saveConversation(id, messages) + await conversationManager.txCreateOrUpdateConversation(id, messages) }, [getManager], ) @@ -74,7 +73,7 @@ export function useChatHistory(): UseChatHistory { ) return { - createOrUpdateConversation: createConversation, + createOrUpdateConversation, deleteConversation, getChatMessagesById, updateConversationTitle, diff --git a/src/main.ts b/src/main.ts index d9abcbe..41ce227 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,6 +7,7 @@ import { ApplyView } from './ApplyView' import { ChatView } from './ChatView' import { ChatProps } from './components/chat-view/Chat' import { APPLY_VIEW_TYPE, CHAT_VIEW_TYPE } from './constants' +import { getDiffStrategy } from "./core/diff/DiffStrategy" import { InlineEdit } from './core/edit/inline-edit-processor' import { RAGEngine } from './core/rag/rag-engine' import { DBManager } from './database/database-manager' @@ -29,61 +30,77 @@ import { import { getMentionableBlockData } from './utils/obsidian' import './utils/path' -// Remember to rename these classes and interfaces! export default class InfioPlugin extends Plugin { + private metadataCacheUnloadFn: (() => void) | null = null + private activeLeafChangeUnloadFn: (() => void) | null = null + private dbManagerInitPromise: Promise | null = null + private ragEngineInitPromise: Promise | null = null settings: InfioSettings settingTab: InfioSettingTab settingsListeners: ((newSettings: InfioSettings) => void)[] = [] - private activeLeafChangeUnloadFn: (() => void) | null = null - private metadataCacheUnloadFn: (() => void) | null = null initChatProps?: ChatProps dbManager: DBManager | null = null ragEngine: RAGEngine | null = null inlineEdit: InlineEdit | null = null - private dbManagerInitPromise: Promise | null = null - private ragEngineInitPromise: Promise | null = null - // private pg: PGlite | null = null + diffStrategy?: DiffStrategy + async onload() { + // load settings await this.loadSettings() - // Add settings tab + // add settings tab this.settingTab = new InfioSettingTab(this.app, this) this.addSettingTab(this.settingTab) - // create and init pglite db - // this.pg = await createAndInitDb() - - // This creates an icon in the left ribbon. + // add icon to ribbon this.addRibbonIcon('wand-sparkles', 'Open infio copilot', () => this.openChatView(), ) + // register views this.registerView(CHAT_VIEW_TYPE, (leaf) => new ChatView(leaf, this)) this.registerView(APPLY_VIEW_TYPE, (leaf) => new ApplyView(leaf)) - // Register markdown processor for ai blocks + // register markdown processor for Inline Edit this.inlineEdit = new InlineEdit(this, this.settings); this.registerMarkdownCodeBlockProcessor("infioedit", (source, el, ctx) => { this.inlineEdit?.Processor(source, el, ctx); }); - // Update inlineEdit when settings change - this.addSettingsListener((newSettings) => { - this.inlineEdit = new InlineEdit(this, newSettings); - }); - - // Setup event listener + // setup autocomplete event listener const statusBar = StatusBar.fromApp(this); const eventListener = EventListener.fromSettings( this.settings, statusBar, this.app ); + + // initialize diff strategy + this.diffStrategy = getDiffStrategy( + this.settings.chatModelId || "", + this.app, + this.settings.fuzzyMatchThreshold, + this.settings.experimentalDiffStrategy, + this.settings.multiSearchReplaceDiffStrategy, + ) + + // add settings change listener this.addSettingsListener((newSettings) => { + // Update inlineEdit when settings change + this.inlineEdit = new InlineEdit(this, newSettings); + // Update autocomplete event listener when settings change eventListener.handleSettingChanged(newSettings) + // Update diff strategy when settings change + this.diffStrategy = getDiffStrategy( + this.settings.chatModelId || "", + this.app, + this.settings.fuzzyMatchThreshold, + this.settings.experimentalDiffStrategy, + this.settings.multiSearchReplaceDiffStrategy, + ) }); - // Setup render plugin + // setup autocomplete render plugin this.registerEditorExtension([ InlineSuggestionState, CompletionKeyWatcher( @@ -107,6 +124,7 @@ export default class InfioPlugin extends Plugin { } }); + /// *** Event Listeners *** this.registerEvent( this.app.workspace.on("active-leaf-change", (leaf) => { if (leaf?.view instanceof MarkdownView) { @@ -139,7 +157,7 @@ export default class InfioPlugin extends Plugin { }) ); - // This adds a simple command that can be triggered anywhere + /// *** Commands *** this.addCommand({ id: 'open-new-chat', name: 'Open new chat', @@ -337,7 +355,6 @@ export default class InfioPlugin extends Plugin { } onunload() { - // this.dbManager?.cleanup() this.dbManager = null } diff --git a/src/types/apply.ts b/src/types/apply.ts index 1a078eb..62f45fe 100644 --- a/src/types/apply.ts +++ b/src/types/apply.ts @@ -64,6 +64,13 @@ export type SearchAndReplaceToolArgs = { }[]; } +export type ApplyDiffToolArgs = { + type: 'apply_diff'; + filepath: string; + diff: string; + finish?: boolean; +} + export type SearchWebToolArgs = { type: 'search_web'; query: string; @@ -83,4 +90,4 @@ export type SwitchModeToolArgs = { finish?: boolean; } -export type ToolArgs = ReadFileToolArgs | WriteToFileToolArgs | InsertContentToolArgs | SearchAndReplaceToolArgs | ListFilesToolArgs | RegexSearchFilesToolArgs | SemanticSearchFilesToolArgs | SearchWebToolArgs | FetchUrlsContentToolArgs | SwitchModeToolArgs; +export type ToolArgs = ReadFileToolArgs | WriteToFileToolArgs | InsertContentToolArgs | SearchAndReplaceToolArgs | ListFilesToolArgs | RegexSearchFilesToolArgs | SemanticSearchFilesToolArgs | SearchWebToolArgs | FetchUrlsContentToolArgs | SwitchModeToolArgs | ApplyDiffToolArgs; diff --git a/src/types/settings.ts b/src/types/settings.ts index f6f1a53..4ef4407 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -219,6 +219,15 @@ export const InfioSettingsSchema = z.object({ embeddingModelProvider: z.nativeEnum(ApiProvider).catch(ApiProvider.Google), embeddingModelId: z.string().catch(''), + // fuzzyMatchThreshold + fuzzyMatchThreshold: z.number().catch(0.85), + + // experimentalDiffStrategy + experimentalDiffStrategy: z.boolean().catch(false), + + // multiSearchReplaceDiffStrategy + multiSearchReplaceDiffStrategy: z.boolean().catch(true), + // Mode mode: z.string().catch('ask'), diff --git a/src/utils/extract-text.ts b/src/utils/extract-text.ts new file mode 100644 index 0000000..2497ac0 --- /dev/null +++ b/src/utils/extract-text.ts @@ -0,0 +1,215 @@ +// import * as path from "path" +// // @ts-ignore-next-line +// import pdf from "pdf-parse/lib/pdf-parse" +// import mammoth from "mammoth" +// import fs from "fs/promises" +// import { isBinaryFile } from "isbinaryfile" + +// export async function extractTextFromFile(filePath: string): Promise { +// try { +// await fs.access(filePath) +// } catch (error) { +// throw new Error(`File not found: ${filePath}`) +// } +// const fileExtension = path.extname(filePath).toLowerCase() +// switch (fileExtension) { +// case ".pdf": +// return extractTextFromPDF(filePath) +// case ".docx": +// return extractTextFromDOCX(filePath) +// case ".ipynb": +// return extractTextFromIPYNB(filePath) +// default: +// const isBinary = await isBinaryFile(filePath).catch(() => false) +// if (!isBinary) { +// return addLineNumbers(await fs.readFile(filePath, "utf8")) +// } else { +// throw new Error(`Cannot read text for file type: ${fileExtension}`) +// } +// } +// } + +// async function extractTextFromPDF(filePath: string): Promise { +// const dataBuffer = await fs.readFile(filePath) +// const data = await pdf(dataBuffer) +// return addLineNumbers(data.text) +// } + +// async function extractTextFromDOCX(filePath: string): Promise { +// const result = await mammoth.extractRawText({ path: filePath }) +// return addLineNumbers(result.value) +// } + +// async function extractTextFromIPYNB(filePath: string): Promise { +// const data = await fs.readFile(filePath, "utf8") +// const notebook = JSON.parse(data) +// let extractedText = "" + +// for (const cell of notebook.cells) { +// if ((cell.cell_type === "markdown" || cell.cell_type === "code") && cell.source) { +// extractedText += cell.source.join("\n") + "\n" +// } +// } + +// return addLineNumbers(extractedText) +// } + +export function addLineNumbers(content: string, startLine: number = 1): string { + const lines = content.split("\n") + const maxLineNumberWidth = String(startLine + lines.length - 1).length + return lines + .map((line, index) => { + const lineNumber = String(startLine + index).padStart(maxLineNumberWidth, " ") + return `${lineNumber} | ${line}` + }) + .join("\n") +} + + +// Checks if every line in the content has line numbers prefixed (e.g., "1 | content" or "123 | content") +// Line numbers must be followed by a single pipe character (not double pipes) +export function everyLineHasLineNumbers(content: string): boolean { + const lines = content.split(/\r?\n/) + return lines.length > 0 && lines.every((line) => /^\s*\d+\s+\|(?!\|)/.test(line)) +} + +// Strips line numbers from content while preserving the actual content +// Handles formats like "1 | content", " 12 | content", "123 | content" +// Preserves content that naturally starts with pipe characters +export function stripLineNumbers(content: string): string { + // Split into lines to handle each line individually + const lines = content.split(/\r?\n/) + + // Process each line + const processedLines = lines.map((line) => { + // Match line number pattern and capture everything after the pipe + const match = line.match(/^\s*\d+\s+\|(?!\|)\s?(.*)$/) + return match ? match[1] : line + }) + + // Join back with original line endings + const lineEnding = content.includes("\r\n") ? "\r\n" : "\n" + return processedLines.join(lineEnding) +} + +// /** +// * Truncates multi-line output while preserving context from both the beginning and end. +// * When truncation is needed, it keeps 20% of the lines from the start and 80% from the end, +// * with a clear indicator of how many lines were omitted in between. +// * +// * @param content The multi-line string to truncate +// * @param lineLimit Optional maximum number of lines to keep. If not provided or 0, returns the original content +// * @returns The truncated string with an indicator of omitted lines, or the original content if no truncation needed +// * +// * @example +// * // With 10 line limit on 25 lines of content: +// * // - Keeps first 2 lines (20% of 10) +// * // - Keeps last 8 lines (80% of 10) +// * // - Adds "[...15 lines omitted...]" in between +// */ +// export function truncateOutput(content: string, lineLimit?: number): string { +// if (!lineLimit) { +// return content +// } + +// // Count total lines +// let totalLines = 0 +// let pos = -1 +// while ((pos = content.indexOf("\n", pos + 1)) !== -1) { +// totalLines++ +// } +// totalLines++ // Account for last line without newline + +// if (totalLines <= lineLimit) { +// return content +// } + +// const beforeLimit = Math.floor(lineLimit * 0.2) // 20% of lines before +// const afterLimit = lineLimit - beforeLimit // remaining 80% after + +// // Find start section end position +// let startEndPos = -1 +// let lineCount = 0 +// pos = 0 +// while (lineCount < beforeLimit && (pos = content.indexOf("\n", pos)) !== -1) { +// startEndPos = pos +// lineCount++ +// pos++ +// } + +// // Find end section start position +// let endStartPos = content.length +// lineCount = 0 +// pos = content.length +// while (lineCount < afterLimit && (pos = content.lastIndexOf("\n", pos - 1)) !== -1) { +// endStartPos = pos + 1 // Start after the newline +// lineCount++ +// } + +// const omittedLines = totalLines - lineLimit +// const startSection = content.slice(0, startEndPos + 1) +// const endSection = content.slice(endStartPos) +// return startSection + `\n[...${omittedLines} lines omitted...]\n\n` + endSection +// } + +// /** +// * Applies run-length encoding to compress repeated lines in text. +// * Only compresses when the compression description is shorter than the repeated content. +// * +// * @param content The text content to compress +// * @returns The compressed text with run-length encoding applied +// */ +// export function applyRunLengthEncoding(content: string): string { +// if (!content) { +// return content +// } + +// let result = "" +// let pos = 0 +// let repeatCount = 0 +// let prevLine = null +// let firstOccurrence = true + +// while (pos < content.length) { +// const nextNewlineIdx = content.indexOf("\n", pos) +// const currentLine = nextNewlineIdx === -1 ? content.slice(pos) : content.slice(pos, nextNewlineIdx + 1) + +// if (prevLine === null) { +// prevLine = currentLine +// } else if (currentLine === prevLine) { +// repeatCount++ +// } else { +// if (repeatCount > 0) { +// const compressionDesc = `\n` +// if (compressionDesc.length < prevLine.length * (repeatCount + 1)) { +// result += prevLine + compressionDesc +// } else { +// for (let i = 0; i <= repeatCount; i++) { +// result += prevLine +// } +// } +// repeatCount = 0 +// } else { +// result += prevLine +// } +// prevLine = currentLine +// } + +// pos = nextNewlineIdx === -1 ? content.length : nextNewlineIdx + 1 +// } + +// if (repeatCount > 0 && prevLine !== null) { +// const compressionDesc = `\n` +// if (compressionDesc.length < prevLine.length * repeatCount) { +// result += prevLine + compressionDesc +// } else { +// for (let i = 0; i <= repeatCount; i++) { +// result += prevLine +// } +// } +// } else if (prevLine !== null) { +// result += prevLine +// } + +// return result +// } diff --git a/src/utils/parse-infio-block.ts b/src/utils/parse-infio-block.ts index 39dbe2c..9530f1a 100644 --- a/src/utils/parse-infio-block.ts +++ b/src/utils/parse-infio-block.ts @@ -33,6 +33,7 @@ export type ParsedMsgBlock = } | { type: 'search_and_replace' path: string + content: string operations: { search: string replace: string @@ -43,6 +44,11 @@ export type ParsedMsgBlock = regex_flags?: string }[] finish: boolean + } | { + type: 'apply_diff' + path: string + diff: string + finish: boolean } | { type: 'ask_followup_question' question: string, @@ -224,7 +230,7 @@ export function parseMsgBlocks( } let path: string | undefined let regex: string | undefined - + for (const childNode of node.childNodes) { if (childNode.nodeName === 'path' && childNode.childNodes.length > 0) { path = childNode.childNodes[0].value @@ -361,6 +367,7 @@ export function parseMsgBlocks( } let path: string | undefined let operations = [] + let content: string = '' // 处理子标签 for (const childNode of node.childNodes) { @@ -368,8 +375,8 @@ export function parseMsgBlocks( path = childNode.childNodes[0].value } else if (childNode.nodeName === 'operations' && childNode.childNodes.length > 0) { try { - const operationsJson = childNode.childNodes[0].value - operations = JSON5.parse(operationsJson) + content = childNode.childNodes[0].value + operations = JSON5.parse(content) } catch (error) { console.error('Failed to parse operations JSON', error) } @@ -379,10 +386,41 @@ export function parseMsgBlocks( parsedResult.push({ type: 'search_and_replace', path, + content, operations, finish: node.sourceCodeLocation.endTag !== undefined }) lastEndOffset = endOffset + } else if (node.nodeName === 'apply_diff') { + if (!node.sourceCodeLocation) { + throw new Error('sourceCodeLocation is undefined') + } + const startOffset = node.sourceCodeLocation.startOffset + const endOffset = node.sourceCodeLocation.endOffset + if (startOffset > lastEndOffset) { + parsedResult.push({ + type: 'string', + content: input.slice(lastEndOffset, startOffset), + }) + } + let path: string | undefined + let diff: string | undefined + + for (const childNode of node.childNodes) { + if (childNode.nodeName === 'path' && childNode.childNodes.length > 0) { + path = childNode.childNodes[0].value + } else if (childNode.nodeName === 'diff' && childNode.childNodes.length > 0) { + diff = childNode.childNodes[0].value + } + } + + parsedResult.push({ + type: 'apply_diff', + path, + diff, + finish: node.sourceCodeLocation.endTag !== undefined + }) + lastEndOffset = endOffset } else if (node.nodeName === 'attempt_completion') { if (!node.sourceCodeLocation) { throw new Error('sourceCodeLocation is undefined') @@ -443,10 +481,10 @@ export function parseMsgBlocks( content: input.slice(lastEndOffset, startOffset), }) } - + let mode: string = '' let reason: string = '' - + for (const childNode of node.childNodes) { if (childNode.nodeName === 'mode_slug' && childNode.childNodes.length > 0) { // @ts-ignore - 忽略 value 属性的类型错误 @@ -456,7 +494,7 @@ export function parseMsgBlocks( reason = childNode.childNodes[0].value } } - + parsedResult.push({ type: 'switch_mode', mode, @@ -500,9 +538,9 @@ export function parseMsgBlocks( content: input.slice(lastEndOffset, startOffset), }) } - + let urls: string[] = [] - + for (const childNode of node.childNodes) { if (childNode.nodeName === 'urls' && childNode.childNodes.length > 0) { try { @@ -516,7 +554,7 @@ export function parseMsgBlocks( } } } - + parsedResult.push({ type: 'fetch_urls_content', urls, diff --git a/src/utils/prompt-generator.ts b/src/utils/prompt-generator.ts index 0469b92..88a4ae5 100644 --- a/src/utils/prompt-generator.ts +++ b/src/utils/prompt-generator.ts @@ -1,7 +1,8 @@ -import { App, MarkdownView, TAbstractFile, TFile, TFolder, Vault, htmlToMarkdown, requestUrl, getLanguage } from 'obsidian' +import { App, MarkdownView, TAbstractFile, TFile, TFolder, Vault, getLanguage, htmlToMarkdown, requestUrl } from 'obsidian' import { editorStateToPlainText } from '../components/chat-view/chat-input/utils/editor-state-to-plain-text' import { QueryProgressState } from '../components/chat-view/QueryProgress' +import { DiffStrategy } from '../core/diff/DiffStrategy' import { SYSTEM_PROMPT } from '../core/prompts/system' import { RAGEngine } from '../core/rag/rag-engine' import { SelectVector } from '../database/schema' @@ -113,7 +114,7 @@ export class PromptGenerator { private getRagEngine: () => Promise private app: App private settings: InfioSettings - + private diffStrategy: DiffStrategy private static readonly EMPTY_ASSISTANT_MESSAGE: RequestMessage = { role: 'assistant', content: '', @@ -123,10 +124,12 @@ export class PromptGenerator { getRagEngine: () => Promise, app: App, settings: InfioSettings, + diffStrategy?: DiffStrategy, ) { this.getRagEngine = getRagEngine this.app = app this.settings = settings + this.diffStrategy = diffStrategy } public async generateRequestMessages({ @@ -165,7 +168,7 @@ export class PromptGenerator { similaritySearchResults, }, ] - console.log('this.settings.mode', this.settings.mode) + let filesSearchMethod = this.settings.filesSearchMethod if (filesSearchMethod === 'auto' && this.settings.embeddingModelId && this.settings.embeddingModelId !== '') { filesSearchMethod = 'semantic' @@ -173,10 +176,8 @@ export class PromptGenerator { filesSearchMethod = 'regex' } - console.log('filesSearchMethod: ', filesSearchMethod) - const userLanguage = getFullLanguageName(getLanguage()) - console.log(' current user language: ', userLanguage) + const systemMessage = await this.getSystemMessageNew(this.settings.mode, filesSearchMethod, userLanguage) const requestMessages: RequestMessage[] = [ @@ -466,7 +467,7 @@ export class PromptGenerator { } private async getSystemMessageNew(mode: Mode, filesSearchMethod: string, preferredLanguage: string): Promise { - const systemPrompt = await SYSTEM_PROMPT(this.app.vault.getRoot().path, false, mode, filesSearchMethod, preferredLanguage) + const systemPrompt = await SYSTEM_PROMPT(this.app.vault.getRoot().path, false, mode, filesSearchMethod, preferredLanguage, this.diffStrategy) return { role: 'system',