mirror of
https://github.com/anomalyco/opencode.git
synced 2026-02-09 18:34:21 +00:00
Compare commits
1297 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4c7042c17 | ||
|
|
6965787b33 | ||
|
|
ce064b8b0e | ||
|
|
0fc546fc6b | ||
|
|
77ac9e5ec2 | ||
|
|
af2c0b3695 | ||
|
|
811b22367d | ||
|
|
933d50e25a | ||
|
|
800bee2722 | ||
|
|
5b4fb96c2e | ||
|
|
1d20bf343d | ||
|
|
79d9bf57f7 | ||
|
|
7b63db6a13 | ||
|
|
0e1565449e | ||
|
|
f9a47fe5a3 | ||
|
|
2bf9d5d4ec | ||
|
|
c18f9ece69 | ||
|
|
4e3c73c4f5 | ||
|
|
8bf2eeccd0 | ||
|
|
6232e0fc58 | ||
|
|
a8b4aed446 | ||
|
|
03de0c406d | ||
|
|
faf8da8743 | ||
|
|
3386908fd6 | ||
|
|
5a8847952a | ||
|
|
87d21ebf2b | ||
|
|
a524fc545c | ||
|
|
4316edaf43 | ||
|
|
d845924e8b | ||
|
|
a29b322bdd | ||
|
|
9723ffa7a6 | ||
|
|
f06cd88773 | ||
|
|
9af92b6914 | ||
|
|
8f64c4b312 | ||
|
|
a32877e908 | ||
|
|
6465c9c44a | ||
|
|
4699739814 | ||
|
|
c1d87c32a2 | ||
|
|
9c5d9be33a | ||
|
|
97d9c851e6 | ||
|
|
76bd702992 | ||
|
|
50c453e577 | ||
|
|
86d5b25d18 | ||
|
|
2b44dbdbf1 | ||
|
|
4bbbbac5f6 | ||
|
|
3c3a997d2a | ||
|
|
1676f8b5dd | ||
|
|
c87a7469a0 | ||
|
|
132e26ddbf | ||
|
|
f1da70b1de | ||
|
|
5c9d1910af | ||
|
|
18abcab208 | ||
|
|
01e7dc2d02 | ||
|
|
611854e4b6 | ||
|
|
d56dec4ba7 | ||
|
|
c952e9ae3d | ||
|
|
6470243095 | ||
|
|
c8321cfbd9 | ||
|
|
46c246e01f | ||
|
|
9964d8e6c0 | ||
|
|
df33143396 | ||
|
|
571aeaaea2 | ||
|
|
edfea03917 | ||
|
|
81c88cc742 | ||
|
|
99b9390d80 | ||
|
|
23c30521d8 | ||
|
|
e681d610de | ||
|
|
a1fdeded3e | ||
|
|
2051312d12 | ||
|
|
20cb7a76af | ||
|
|
a493aec174 | ||
|
|
3ce3ac8e61 | ||
|
|
91ad64feda | ||
|
|
60b55f9d92 | ||
|
|
3c6c2bf13b | ||
|
|
d4f9375548 | ||
|
|
28b39f547e | ||
|
|
7520f5efa8 | ||
|
|
eb4cdf4b20 | ||
|
|
9f6fc1c3c5 | ||
|
|
dfede9ae6e | ||
|
|
fc45c0c944 | ||
|
|
9d869f784c | ||
|
|
bd244f73af | ||
|
|
dd34556e9c | ||
|
|
f7dd48e60d | ||
|
|
93c779cf48 | ||
|
|
360c04c542 | ||
|
|
529fd57e75 | ||
|
|
faea3777e1 | ||
|
|
a4664e2344 | ||
|
|
cdc1d8a94d | ||
|
|
fdd6d6600f | ||
|
|
9f44cfd595 | ||
|
|
70229b150c | ||
|
|
050ff943a6 | ||
|
|
88b58fd6a0 | ||
|
|
5d67e13df5 | ||
|
|
57d1a60efc | ||
|
|
add81b9739 | ||
|
|
81bdb8e269 | ||
|
|
a563fdd287 | ||
|
|
7c93bf5993 | ||
|
|
6a5a4247c6 | ||
|
|
a39136a2a0 | ||
|
|
9f5b59f336 | ||
|
|
01c125b058 | ||
|
|
d41aa2bc72 | ||
|
|
f45deb37f0 | ||
|
|
e89972a396 | ||
|
|
c3c647a21a | ||
|
|
b79167ce66 | ||
|
|
7ac0a2bc65 | ||
|
|
cb032cff2b | ||
|
|
867a69a751 | ||
|
|
20b8efcc50 | ||
|
|
a86d42149f | ||
|
|
82a36acfe3 | ||
|
|
0793c3f2a3 | ||
|
|
5c860b0d69 | ||
|
|
05bb127a8e | ||
|
|
1bbd84008f | ||
|
|
fdfd4d69d3 | ||
|
|
7f659cce36 | ||
|
|
48fcaa83be | ||
|
|
70c16c4c95 | ||
|
|
c1e1ef6eb5 | ||
|
|
bb155db8b2 | ||
|
|
7c91f668d1 | ||
|
|
1af103d29e | ||
|
|
8a3e581edc | ||
|
|
749e7838a4 | ||
|
|
73b46c2bf9 | ||
|
|
8bd250fb15 | ||
|
|
b1ab641905 | ||
|
|
76e256ed64 | ||
|
|
4f955f2127 | ||
|
|
bbeb579d3a | ||
|
|
f707fb3f8d | ||
|
|
6b98acb7be | ||
|
|
2487b18f62 | ||
|
|
533f64fe26 | ||
|
|
b5c85d3806 | ||
|
|
bcf952bc8a | ||
|
|
a6dc75a44c | ||
|
|
416daca9c6 | ||
|
|
636fe0fb64 | ||
|
|
95e0957d64 | ||
|
|
2eefdae6a9 | ||
|
|
d62746ceb7 | ||
|
|
4b2ce14ff3 | ||
|
|
294a11752e | ||
|
|
1cf1d1f634 | ||
|
|
2ce694d41f | ||
|
|
d6eff3b3a3 | ||
|
|
e63a6d45c1 | ||
|
|
93686519ba | ||
|
|
f593792fb5 | ||
|
|
2cdb37c32b | ||
|
|
535d79b64c | ||
|
|
b4e4c3f662 | ||
|
|
ba676e7ae0 | ||
|
|
a1c8e5af45 | ||
|
|
f1e7e7c138 | ||
|
|
80b77caec0 | ||
|
|
86a2ea44b5 | ||
|
|
a2002c88c6 | ||
|
|
d8bcf4f4e7 | ||
|
|
31e0326f78 | ||
|
|
a53d2ea356 | ||
|
|
229a280652 | ||
|
|
8d0350d923 | ||
|
|
4192d7eacc | ||
|
|
7b8b4cf8c7 | ||
|
|
1f4de75348 | ||
|
|
457755c690 | ||
|
|
052a1e7514 | ||
|
|
139d6e2818 | ||
|
|
06554efdf4 | ||
|
|
67e9bda94f | ||
|
|
53bb6b4c4f | ||
|
|
73d54c7068 | ||
|
|
90d6c4ab41 | ||
|
|
736396fc70 | ||
|
|
177bfed93e | ||
|
|
91f8477ef5 | ||
|
|
f04a5e50ee | ||
|
|
bb28b70700 | ||
|
|
7361a02ef3 | ||
|
|
d465f150fc | ||
|
|
17fa8c117b | ||
|
|
9aa0c40a00 | ||
|
|
fd4648da17 | ||
|
|
aadca5013a | ||
|
|
5c3d490e59 | ||
|
|
1254f48135 | ||
|
|
1729c310d9 | ||
|
|
0130190bbd | ||
|
|
97a31ddffc | ||
|
|
3249420ad1 | ||
|
|
4bb8536d34 | ||
|
|
c73d4a137e | ||
|
|
57ac8f2741 | ||
|
|
2f1acee5a1 | ||
|
|
9ca54020ac | ||
|
|
f7d44b178b | ||
|
|
b4950a157c | ||
|
|
dfbef066c7 | ||
|
|
26fd76fbee | ||
|
|
04769d8a26 | ||
|
|
34b576d9b5 | ||
|
|
22b244f847 | ||
|
|
7e1fc275e7 | ||
|
|
3b9b391320 | ||
|
|
766bfd025c | ||
|
|
c7f30e1065 | ||
|
|
1c4fd7f28f | ||
|
|
85805d2c38 | ||
|
|
982cb3e71a | ||
|
|
294d0e7ee3 | ||
|
|
8be1ca836c | ||
|
|
2e5f96fa41 | ||
|
|
c056b0add9 | ||
|
|
b00bb3c083 | ||
|
|
d9befd3aa6 | ||
|
|
49de703ba1 | ||
|
|
22988894c8 | ||
|
|
34b1754f25 | ||
|
|
54fe3504ba | ||
|
|
d2c862e32d | ||
|
|
afc53afb35 | ||
|
|
b56e49c5dc | ||
|
|
8b2a909e1f | ||
|
|
e9c954d45e | ||
|
|
6f449d13af | ||
|
|
6e375bef0d | ||
|
|
67106a6967 | ||
|
|
b5d690620d | ||
|
|
9db3ce1d0b | ||
|
|
1cc55b68ef | ||
|
|
469f667774 | ||
|
|
6603d9a9f0 | ||
|
|
5dc1920a4c | ||
|
|
d3e5f3f3a8 | ||
|
|
ce4cb820f7 | ||
|
|
ba5be6b625 | ||
|
|
f95c3f4177 | ||
|
|
d2b1307bff | ||
|
|
b40ba32adc | ||
|
|
ce0cebb7d7 | ||
|
|
f478f89a68 | ||
|
|
85d95f0f2b | ||
|
|
1515efc77c | ||
|
|
6d393759e1 | ||
|
|
a1701678cd | ||
|
|
c411a26d6f | ||
|
|
85dbfeb314 | ||
|
|
085c0e4e2b | ||
|
|
8404a97c3e | ||
|
|
0ee3b1ede2 | ||
|
|
a826936702 | ||
|
|
fd4a5d5a63 | ||
|
|
69cf1d7b7e | ||
|
|
8e0a1d1167 | ||
|
|
f22021187d | ||
|
|
febecc348a | ||
|
|
c5ccfc3e94 | ||
|
|
1f6efc6b94 | ||
|
|
727fe6f942 | ||
|
|
a91e79382e | ||
|
|
5c626e0a2f | ||
|
|
8e9e383219 | ||
|
|
f383008cc1 | ||
|
|
303ade25ed | ||
|
|
53f8e7850e | ||
|
|
ca8ce88354 | ||
|
|
37a86439c4 | ||
|
|
269b43f4de | ||
|
|
3f25e5bf86 | ||
|
|
67765fa47c | ||
|
|
58b1c58bc5 | ||
|
|
d80badc50f | ||
|
|
75279e5ccf | ||
|
|
7893b84614 | ||
|
|
cfc715bd48 | ||
|
|
39bcba85a9 | ||
|
|
da3df51316 | ||
|
|
12190e4efc | ||
|
|
d2a9b2f64a | ||
|
|
aacadd8a8a | ||
|
|
969154a473 | ||
|
|
4d6ca3fab1 | ||
|
|
00ea5082e7 | ||
|
|
4a878b88c0 | ||
|
|
6de955847c | ||
|
|
3ba5d528b4 | ||
|
|
f99e2b3429 | ||
|
|
7e4e6f6e51 | ||
|
|
0514f3f43b | ||
|
|
1e07384364 | ||
|
|
4c4739c422 | ||
|
|
2d8b90a6ff | ||
|
|
a2fa7ffa42 | ||
|
|
f7d6175283 | ||
|
|
9ed187ee52 | ||
|
|
14d81e574b | ||
|
|
6efe8cc8df | ||
|
|
daa5fc916a | ||
|
|
c659496b96 | ||
|
|
21fbf21cb6 | ||
|
|
f31cbf2744 | ||
|
|
8322f18e03 | ||
|
|
562bdb95e2 | ||
|
|
a57ce8365d | ||
|
|
0da83ae67e | ||
|
|
662d022a48 | ||
|
|
9efef03919 | ||
|
|
7a9fb3fa92 | ||
|
|
ea96ead346 | ||
|
|
6100a77b85 | ||
|
|
c7a59ee2b1 | ||
|
|
a272b58fe9 | ||
|
|
9948fcf1b6 | ||
|
|
0d50c867ff | ||
|
|
27f7e02f12 | ||
|
|
0f93ecd564 | ||
|
|
da909d9684 | ||
|
|
facd851b11 | ||
|
|
c51de945a5 | ||
|
|
9253a3ca9e | ||
|
|
7cfa297a78 | ||
|
|
661b74def6 | ||
|
|
b478e5655c | ||
|
|
f884766445 | ||
|
|
76b2e4539c | ||
|
|
d87922c0eb | ||
|
|
2446483df5 | ||
|
|
f4c453155d | ||
|
|
969ad80ed2 | ||
|
|
af064b41d7 | ||
|
|
ea6bfef21a | ||
|
|
107363b1d9 | ||
|
|
85214d7c59 | ||
|
|
997cb2d945 | ||
|
|
45b139390c | ||
|
|
994368de15 | ||
|
|
143fd8e076 | ||
|
|
06dba28bd6 | ||
|
|
b8d276a049 | ||
|
|
ee01f01271 | ||
|
|
32d5db4f0a | ||
|
|
f6108b7be8 | ||
|
|
94ef341c9d | ||
|
|
f9abc7c84f | ||
|
|
891ed6ebc0 | ||
|
|
163e23a68b | ||
|
|
f13b0af491 | ||
|
|
4a0be45d3d | ||
|
|
23788674c8 | ||
|
|
121eb24e73 | ||
|
|
571d60182a | ||
|
|
167a9dcaf3 | ||
|
|
37327259cb | ||
|
|
cdb25656d5 | ||
|
|
25c876caa2 | ||
|
|
cf83e31f23 | ||
|
|
3bc238b58b | ||
|
|
b8de69dced | ||
|
|
e7fcb692a4 | ||
|
|
dae38574ab | ||
|
|
ed4f862b49 | ||
|
|
fce59db94a | ||
|
|
3e2a0c7281 | ||
|
|
5a0910ea79 | ||
|
|
1dffabcfda | ||
|
|
c389e0ed43 | ||
|
|
204801052a | ||
|
|
2528d8cb88 | ||
|
|
6b73ffd1c1 | ||
|
|
0eadc50a33 | ||
|
|
aeea84a877 | ||
|
|
a54c5c6298 | ||
|
|
8825cd3811 | ||
|
|
3d9a5d9970 | ||
|
|
1f9e195fa6 | ||
|
|
73c012c76c | ||
|
|
2ace57404b | ||
|
|
8c4b5e088b | ||
|
|
69920a73d7 | ||
|
|
ae76a3467a | ||
|
|
701107cda4 | ||
|
|
b99565959b | ||
|
|
67aa7ce04d | ||
|
|
c663fbc3ee | ||
|
|
2090bab537 | ||
|
|
64d5fff9a3 | ||
|
|
925f695503 | ||
|
|
f1c925795d | ||
|
|
c82a060eca | ||
|
|
63e783ef79 | ||
|
|
35d6273fb3 | ||
|
|
b89d4a16fd | ||
|
|
2799a96032 | ||
|
|
8f4b79227c | ||
|
|
c810b6d206 | ||
|
|
fa35407572 | ||
|
|
8bbbc07aff | ||
|
|
75a21ba3ce | ||
|
|
0d6fb68a88 | ||
|
|
242b886434 | ||
|
|
caf465a9da | ||
|
|
bbf77c6139 | ||
|
|
53b7e04b86 | ||
|
|
9e75e3ed18 | ||
|
|
6389858d41 | ||
|
|
7e5941e14b | ||
|
|
c68aeed8d9 | ||
|
|
b199a609a8 | ||
|
|
4a5a93b3f8 | ||
|
|
e99bdcefac | ||
|
|
26dcb85de1 | ||
|
|
11d042be25 | ||
|
|
33b5fe236a | ||
|
|
d56991006c | ||
|
|
739a9f71c3 | ||
|
|
aef81fce0b | ||
|
|
8f3d7b4038 | ||
|
|
de15e67834 | ||
|
|
fea56d8de6 | ||
|
|
3d71be2b45 | ||
|
|
58baca2a5b | ||
|
|
ef73926db6 | ||
|
|
9ad1687f04 | ||
|
|
c573270e66 | ||
|
|
9ebad68274 | ||
|
|
03664ba588 | ||
|
|
5a107b275c | ||
|
|
dd5736fe5f | ||
|
|
9f3ba03965 | ||
|
|
d090c08ef0 | ||
|
|
68e82e4d94 | ||
|
|
a4aa0e6f8d | ||
|
|
8c1ae2717c | ||
|
|
72d48759d7 | ||
|
|
986144b377 | ||
|
|
1fdb326aa7 | ||
|
|
463257e7e4 | ||
|
|
0f41e60bd6 | ||
|
|
7df81f7b3e | ||
|
|
dd22cb2bb0 | ||
|
|
248325925f | ||
|
|
ca48a4f0fb | ||
|
|
98ee5a3d87 | ||
|
|
67480e5a1c | ||
|
|
2581a9b54c | ||
|
|
14a293e124 | ||
|
|
780419ecae | ||
|
|
f0962e2d9c | ||
|
|
3a9584a419 | ||
|
|
196f42cbff | ||
|
|
322385f6b1 | ||
|
|
b7446cd7b9 | ||
|
|
f618e569ab | ||
|
|
7b394b91e2 | ||
|
|
6a7983a4ea | ||
|
|
737146fca1 | ||
|
|
688f3fd12f | ||
|
|
145df08444 | ||
|
|
8b400515ea | ||
|
|
289797f56d | ||
|
|
be0811ecc3 | ||
|
|
0676bcd4fd | ||
|
|
d076def561 | ||
|
|
e0807d7317 | ||
|
|
fa2723f2d0 | ||
|
|
87d62514db | ||
|
|
2f8cf9146b | ||
|
|
8e0ec6b037 | ||
|
|
6dc434cb83 | ||
|
|
d972c27f03 | ||
|
|
9e2bb63688 | ||
|
|
49053b66a9 | ||
|
|
47497aef07 | ||
|
|
8455029de1 | ||
|
|
9f07f89384 | ||
|
|
d840d43e8f | ||
|
|
9ead2f3dfb | ||
|
|
f3742ddbb8 | ||
|
|
b61a841aa8 | ||
|
|
ebcf11e574 | ||
|
|
065f0aaddf | ||
|
|
c0773dc7c5 | ||
|
|
1c3c74bd36 | ||
|
|
79bbf90b72 | ||
|
|
226a4a7f36 | ||
|
|
df3b424830 | ||
|
|
3cfd9d80bc | ||
|
|
e0553b8d2c | ||
|
|
391c837b37 | ||
|
|
5773d9d1a3 | ||
|
|
ce611963c3 | ||
|
|
f865cacfb8 | ||
|
|
2ec0611f42 | ||
|
|
334161a30e | ||
|
|
dbb6e55226 | ||
|
|
d0f9260559 | ||
|
|
d2176064e1 | ||
|
|
ed8d277e49 | ||
|
|
59b3268c64 | ||
|
|
d043f67761 | ||
|
|
51bf193889 | ||
|
|
f8b78f08b4 | ||
|
|
a4f32d602b | ||
|
|
dc3dd21cf3 | ||
|
|
b4c2fcccf5 | ||
|
|
e950ad5306 | ||
|
|
8ca713b737 | ||
|
|
5b54554fd5 | ||
|
|
4bc651f958 | ||
|
|
3b6976a9c8 | ||
|
|
863d5c1e8e | ||
|
|
97e19e9677 | ||
|
|
b27851461f | ||
|
|
209687377a | ||
|
|
90face1c09 | ||
|
|
936e2ce48b | ||
|
|
16ee8ee379 | ||
|
|
ac39308dad | ||
|
|
346b49219d | ||
|
|
d84c1f20c7 | ||
|
|
dfb8777555 | ||
|
|
008af18156 | ||
|
|
ab23167f80 | ||
|
|
b17ec46463 | ||
|
|
2e26b58d16 | ||
|
|
31b56e5a05 | ||
|
|
47c401cf25 | ||
|
|
fab8dc9e6f | ||
|
|
f39a2b1f16 | ||
|
|
66830ced4e | ||
|
|
9d3fad754d | ||
|
|
dcd3131f58 | ||
|
|
3d02e07161 | ||
|
|
4dbc6a43a6 | ||
|
|
5394b5188b | ||
|
|
8e680b3957 | ||
|
|
1b8cd796d6 | ||
|
|
35fba793d0 | ||
|
|
5358d43b74 | ||
|
|
f777347bac | ||
|
|
17c8b914df | ||
|
|
43b467dd12 | ||
|
|
0e0770921e | ||
|
|
8edbb74352 | ||
|
|
e6bfa95758 | ||
|
|
e4120b6287 | ||
|
|
ccbc9e00f2 | ||
|
|
7d13baadc8 | ||
|
|
9acc83697f | ||
|
|
db24bf87c0 | ||
|
|
f4c0d2d2fd | ||
|
|
d240f4c676 | ||
|
|
9c90cdbe08 | ||
|
|
fc7af31fe5 | ||
|
|
2f8d23ec66 | ||
|
|
77ae3fb9b9 | ||
|
|
4e7f6c47fd | ||
|
|
50469ed750 | ||
|
|
aaab785493 | ||
|
|
9751937894 | ||
|
|
0fc8dfc77e | ||
|
|
81b7df61ec | ||
|
|
8217b96d4a | ||
|
|
7dd0918d32 | ||
|
|
4b26b43855 | ||
|
|
9d7cfda9fe | ||
|
|
a3cf18c905 | ||
|
|
0b1a8ae699 | ||
|
|
eb70b1e5c8 | ||
|
|
00a3d818b6 | ||
|
|
2384c7e734 | ||
|
|
1bad3d9894 | ||
|
|
4f715e66dc | ||
|
|
ec001ca02f | ||
|
|
a2d3b9f0c8 | ||
|
|
9cfb6ff964 | ||
|
|
6ed661c140 | ||
|
|
9dc00edfc9 | ||
|
|
e063bf888e | ||
|
|
6f18475428 | ||
|
|
3664b09812 | ||
|
|
7050cc0ac3 | ||
|
|
4d3d63294d | ||
|
|
6bc61cbc2d | ||
|
|
01d351bebe | ||
|
|
dbba4a97aa | ||
|
|
0dc586faef | ||
|
|
f19c6b05f2 | ||
|
|
bc34f08333 | ||
|
|
b7ee16aabd | ||
|
|
ed1b0d97bf | ||
|
|
8d3b2fb821 | ||
|
|
fa991920bc | ||
|
|
5e79e3d7a5 | ||
|
|
966015c9ae | ||
|
|
61f057337a | ||
|
|
0b261054a2 | ||
|
|
e2e481cbb5 | ||
|
|
5140e83012 | ||
|
|
100d6212be | ||
|
|
f0e19a6542 | ||
|
|
00c4d4f9f8 | ||
|
|
6e6fe6e013 | ||
|
|
d05b60291e | ||
|
|
5162361372 | ||
|
|
d271b9f75b | ||
|
|
333569bed3 | ||
|
|
09b89fdb23 | ||
|
|
0e8c3359d1 | ||
|
|
37e0a7050f | ||
|
|
774dcb6980 | ||
|
|
28bc49ad17 | ||
|
|
dc1947838c | ||
|
|
3ea2daaa4c | ||
|
|
137e964131 | ||
|
|
8efbe497fd | ||
|
|
119d2d966c | ||
|
|
194415e785 | ||
|
|
1684042fb6 | ||
|
|
59f0004d34 | ||
|
|
da35a64fa1 | ||
|
|
460338ca53 | ||
|
|
53c18a64b4 | ||
|
|
b8144c5654 | ||
|
|
9081e17fcc | ||
|
|
ef3fd5900f | ||
|
|
453d690c11 | ||
|
|
c45be6a645 | ||
|
|
7b9b177088 | ||
|
|
3cee5b0470 | ||
|
|
9246d1c901 | ||
|
|
cc12abc83e | ||
|
|
4f7e4a9436 | ||
|
|
eee396f903 | ||
|
|
0d2f8e175a | ||
|
|
4df40e0d9b | ||
|
|
b72e17a8b7 | ||
|
|
61160dc220 | ||
|
|
98734ff28c | ||
|
|
9991352663 | ||
|
|
91c4da5dbd | ||
|
|
2fd0e7dd6b | ||
|
|
d50b7ad481 | ||
|
|
df95c49401 | ||
|
|
8b73c52f00 | ||
|
|
5603098d17 | ||
|
|
f436a50125 | ||
|
|
e19e977591 | ||
|
|
addbe295b1 | ||
|
|
9a573dedc6 | ||
|
|
9ea0d71e8d | ||
|
|
b1a3599017 | ||
|
|
7b0329f67f | ||
|
|
311b9c74dd | ||
|
|
f7e8dd2ff8 | ||
|
|
40b1dd7ef2 | ||
|
|
261e76e0a3 | ||
|
|
a300bfaccb | ||
|
|
41dba0db08 | ||
|
|
6674c6083a | ||
|
|
f6afa2c6bb | ||
|
|
b2fb0508ea | ||
|
|
93f4252bb1 | ||
|
|
46ab9c16dd | ||
|
|
d869df4fee | ||
|
|
b99d4650ec | ||
|
|
261bb7f110 | ||
|
|
0515fbb260 | ||
|
|
88211d8c5b | ||
|
|
a812f95b9d | ||
|
|
3728a12bee | ||
|
|
af07e51213 | ||
|
|
3113788c92 | ||
|
|
efb5fe6d4e | ||
|
|
54dd6c644d | ||
|
|
39ad8f2667 | ||
|
|
c4a2c84e53 | ||
|
|
44fe012812 | ||
|
|
f5e7f079ea | ||
|
|
15a8936806 | ||
|
|
4e4cff49c0 | ||
|
|
5540503bee | ||
|
|
193718034b | ||
|
|
72108c0296 | ||
|
|
ec1c9f8cd1 | ||
|
|
a85b0a370e | ||
|
|
e7784d2864 | ||
|
|
97c4815444 | ||
|
|
7d1a1663c8 | ||
|
|
24c0ce6e53 | ||
|
|
4cdc86612c | ||
|
|
f1f3f8d12c | ||
|
|
e78d3b54bf | ||
|
|
f8a7cd372d | ||
|
|
f48eac638d | ||
|
|
e1f12f93eb | ||
|
|
7ca8334a8b | ||
|
|
f1a2b2eba4 | ||
|
|
4b132656df | ||
|
|
26bab00dab | ||
|
|
568c04753e | ||
|
|
4a06e164d2 | ||
|
|
c57b52c300 | ||
|
|
0b8f48f17f | ||
|
|
3862184ccb | ||
|
|
8619c50976 | ||
|
|
bb6b56b72a | ||
|
|
1252b65166 | ||
|
|
6840276dad | ||
|
|
bd8c3cd0f1 | ||
|
|
e5e9b3e3c0 | ||
|
|
1e8a681de9 | ||
|
|
a834bedc17 | ||
|
|
6a3392385e | ||
|
|
6a00e063c4 | ||
|
|
73a0ce2b7d | ||
|
|
4d1afd01fa | ||
|
|
801d5f47bd | ||
|
|
b6caae9708 | ||
|
|
183ca64ef9 | ||
|
|
8c32cfe829 | ||
|
|
73dcc88da1 | ||
|
|
14bded65dc | ||
|
|
87d1d3fb62 | ||
|
|
e054454109 | ||
|
|
a6142cf975 | ||
|
|
69332e5fa3 | ||
|
|
20201ba3c4 | ||
|
|
658067186a | ||
|
|
ac777b77cf | ||
|
|
5944ae2023 | ||
|
|
2f10961ba8 | ||
|
|
fae97978a3 | ||
|
|
3423415e49 | ||
|
|
1d0bfc2b2a | ||
|
|
bd46cf0f86 | ||
|
|
d4157d9a96 | ||
|
|
6e4ef585d8 | ||
|
|
e05c3b7a76 | ||
|
|
f99904bc1c | ||
|
|
b796d6763f | ||
|
|
c1250abdf8 | ||
|
|
ebe51534a1 | ||
|
|
b8bbee4718 | ||
|
|
8f852b396f | ||
|
|
ae4d089c06 | ||
|
|
5110fbdaf9 | ||
|
|
e6ddb474fc | ||
|
|
0dc71774ce | ||
|
|
b470466e30 | ||
|
|
d1f9311931 | ||
|
|
1c58023df9 | ||
|
|
4e0aa58b7e | ||
|
|
23ee34b35f | ||
|
|
674c9a5220 | ||
|
|
54c86ed43a | ||
|
|
676d75ee75 | ||
|
|
70dc0a12f2 | ||
|
|
d579c5e8aa | ||
|
|
ee91f31313 | ||
|
|
57b3051024 | ||
|
|
ae5cf3cc23 | ||
|
|
68e1b3c46c | ||
|
|
2d68814abc | ||
|
|
a5da5127fa | ||
|
|
b5a4439704 | ||
|
|
9c5616521d | ||
|
|
3fe163416d | ||
|
|
d054f88130 | ||
|
|
b929b4f4b9 | ||
|
|
4c0c83b02d | ||
|
|
d6d45bdc63 | ||
|
|
13a83721b0 | ||
|
|
f0edffbae9 | ||
|
|
8131bee49a | ||
|
|
b5f44ae13f | ||
|
|
0d23f2a7fd | ||
|
|
ac096d84ad | ||
|
|
fcaf0e6dbf | ||
|
|
19e259d90d | ||
|
|
2c9fd1e776 | ||
|
|
63996c4189 | ||
|
|
c7bb7ce4de | ||
|
|
c8eb1b24c3 | ||
|
|
b9f894f1e9 | ||
|
|
7c0d10a4ce | ||
|
|
06af406146 | ||
|
|
0e3458b112 | ||
|
|
2d15c683e0 | ||
|
|
3c94d26570 | ||
|
|
1a553e525f | ||
|
|
3c4e966216 | ||
|
|
0721620ed8 | ||
|
|
9fc6734f32 | ||
|
|
e1733a423d | ||
|
|
d42e3db7e0 | ||
|
|
cdb26f6d83 | ||
|
|
fe05edaa79 | ||
|
|
7d174767b0 | ||
|
|
c5eefd1752 | ||
|
|
77a6b3bdd6 | ||
|
|
7effff56c0 | ||
|
|
e30fba0d3c | ||
|
|
7fbb2ca9a6 | ||
|
|
230d0a1510 | ||
|
|
46ff2c0ae0 | ||
|
|
b8a89dab0f | ||
|
|
7351e12886 | ||
|
|
38879dee2d | ||
|
|
c4ff8dd205 | ||
|
|
0e035b3115 | ||
|
|
b855511d9a | ||
|
|
783faf554d | ||
|
|
bfd4269d7d | ||
|
|
25f78b053b | ||
|
|
87f260ee17 | ||
|
|
12931a869d | ||
|
|
f759e1804d | ||
|
|
c9b4564d36 | ||
|
|
d097c546db | ||
|
|
adb54521b4 | ||
|
|
2ea0399aa7 | ||
|
|
fa1266263d | ||
|
|
fe109c921e | ||
|
|
37bb8895fe | ||
|
|
89b95be4de | ||
|
|
eaf295bac7 | ||
|
|
27d3cec477 | ||
|
|
574d494c3c | ||
|
|
0239761f31 | ||
|
|
a53f9165e9 | ||
|
|
ffc231bd8b | ||
|
|
3cf4ef56fb | ||
|
|
c738e26438 | ||
|
|
9c6aa82ac1 | ||
|
|
ef74d97491 | ||
|
|
af892e5432 | ||
|
|
d7aca6230d | ||
|
|
0f9c2c5c27 | ||
|
|
6a261dedb4 | ||
|
|
ec928d88b5 | ||
|
|
59a5f120c0 | ||
|
|
ce07f80b19 | ||
|
|
168fd9b2e3 | ||
|
|
df13b155f9 | ||
|
|
eeed5b8718 | ||
|
|
148ef90210 | ||
|
|
67023bb007 | ||
|
|
a316aed4fe | ||
|
|
9f7c0bd599 | ||
|
|
c7e1068f90 | ||
|
|
e2052d790b | ||
|
|
d3b2763c14 | ||
|
|
c6492de7ac | ||
|
|
d8fa0fb50c | ||
|
|
18ab8faa1d | ||
|
|
f35ce180e2 | ||
|
|
2bee48a9bc | ||
|
|
10ddd654cf | ||
|
|
61396b93ed | ||
|
|
62b9a30a9c | ||
|
|
5706c6ad3a | ||
|
|
e8e03c895a | ||
|
|
38667682a7 | ||
|
|
d7d5fc39fb | ||
|
|
0caf25adee | ||
|
|
37febc6873 | ||
|
|
4169f0c412 | ||
|
|
b7f06bbc1f | ||
|
|
1b8cfe9e99 | ||
|
|
97837d2d23 | ||
|
|
9abc2a0cf8 | ||
|
|
9fb47bc855 | ||
|
|
73e9fb53d5 | ||
|
|
f03637b1fc | ||
|
|
2c376c5abc | ||
|
|
442e1b52ad | ||
|
|
e8c3abc369 | ||
|
|
c8648baba2 | ||
|
|
7b3a799856 | ||
|
|
9356b6c35a | ||
|
|
29a6603a89 | ||
|
|
a454ba8895 | ||
|
|
5eae7aef0e | ||
|
|
1031bceef7 | ||
|
|
653965ef59 | ||
|
|
ca0ea3f94d | ||
|
|
98bd5109c2 | ||
|
|
78f65e4789 | ||
|
|
75dd2f75aa | ||
|
|
fe86e58bbb | ||
|
|
ae339015fc | ||
|
|
cce2e4ad75 | ||
|
|
a1ce35c208 | ||
|
|
69d6709a19 | ||
|
|
52ec134b2d | ||
|
|
db88bede05 | ||
|
|
d4d218d7d6 | ||
|
|
3e086e3ab9 | ||
|
|
2f5faae34b | ||
|
|
e3ad6a0698 | ||
|
|
b536b45536 | ||
|
|
81c245035f | ||
|
|
dda7059e57 | ||
|
|
0cca75ef48 | ||
|
|
ee1f55dbe2 | ||
|
|
2fa50190e5 | ||
|
|
662b6b1258 | ||
|
|
f0dbe40522 | ||
|
|
41c54f629c | ||
|
|
4503201b15 | ||
|
|
120151ee38 | ||
|
|
4d2e556713 | ||
|
|
54a5d3a9eb | ||
|
|
22dc6b6ec9 | ||
|
|
b5c6ddcd04 | ||
|
|
e03ad6c42e | ||
|
|
33457d8472 | ||
|
|
888105e60f | ||
|
|
b7b490f67c | ||
|
|
f6ed59bf45 | ||
|
|
83991bee88 | ||
|
|
29142eb940 | ||
|
|
aab47714c9 | ||
|
|
9b2b610920 | ||
|
|
468cec545a | ||
|
|
3c82fb6818 | ||
|
|
8a2f370eda | ||
|
|
636133e6cb | ||
|
|
6cf8784ecf | ||
|
|
95d5e1f231 | ||
|
|
979bad3e64 | ||
|
|
300d0474a3 | ||
|
|
d4379c8c93 | ||
|
|
a9b230f419 | ||
|
|
07cffebc8f | ||
|
|
f1de1634d6 | ||
|
|
8d8663399d | ||
|
|
83eb61fd5f | ||
|
|
b8e7d06356 | ||
|
|
4543765e3a | ||
|
|
28f5cbbfe9 | ||
|
|
ca3c22dc12 | ||
|
|
49110f7412 | ||
|
|
14dcf43246 | ||
|
|
84e4afc0bd | ||
|
|
1cc8e9a36d | ||
|
|
43b429db93 | ||
|
|
6248c1e720 | ||
|
|
772e1851c0 | ||
|
|
5ab2ff9589 | ||
|
|
a0062d4661 | ||
|
|
ef7f1f0761 | ||
|
|
96b5a079ff | ||
|
|
0e58f488df | ||
|
|
fa7416687b | ||
|
|
c3ab370344 | ||
|
|
bb60aa3060 | ||
|
|
34fa8cadd6 | ||
|
|
edd459ec00 | ||
|
|
177875f624 | ||
|
|
fdaa7f287c | ||
|
|
fed659c582 | ||
|
|
bffc612a4e | ||
|
|
b97b15e0fe | ||
|
|
fcb972de19 | ||
|
|
4478195ea8 | ||
|
|
54c4a783b3 | ||
|
|
c091cbb624 | ||
|
|
d4f8fd867a | ||
|
|
a2884b08cc | ||
|
|
60faa26a15 | ||
|
|
d8510ab452 | ||
|
|
3c23b92bea | ||
|
|
021fd3fcb5 | ||
|
|
fa3253d1b6 | ||
|
|
a1cc8f6cdb | ||
|
|
1bcc02442a | ||
|
|
1d782dc19a | ||
|
|
879d02f86c | ||
|
|
028d589ea0 | ||
|
|
cdbdb96218 | ||
|
|
f22c93ba1b | ||
|
|
b34d5c959b | ||
|
|
32e6a552c0 | ||
|
|
d6afebf22a | ||
|
|
b32cb2b932 | ||
|
|
265f427d2a | ||
|
|
16520261f4 | ||
|
|
65b2cf73d7 | ||
|
|
95069af03f | ||
|
|
3e4ebb6e5d | ||
|
|
9147108675 | ||
|
|
ea9dd4e9e2 | ||
|
|
4a6e36a404 | ||
|
|
db2bb32bcf | ||
|
|
1384a5e3e6 | ||
|
|
167aea6aaf | ||
|
|
142056e9af | ||
|
|
241c366164 | ||
|
|
3cd7ae0807 | ||
|
|
299a74061a | ||
|
|
e3d5af2855 | ||
|
|
bbfa72552a | ||
|
|
8dfdd3927e | ||
|
|
02e326f87f | ||
|
|
35b03e4cb3 | ||
|
|
b3555cda30 | ||
|
|
04bd98cf4d | ||
|
|
f3e31130ba | ||
|
|
671e91f201 | ||
|
|
d334ead84a | ||
|
|
160428d2d4 | ||
|
|
0a1f9accd7 | ||
|
|
a74f27e59a | ||
|
|
f76cdfff9b | ||
|
|
05974ea109 | ||
|
|
6cfce1e4da | ||
|
|
e20093678f | ||
|
|
7f8f46f9fe | ||
|
|
2dbdecb0f7 | ||
|
|
53b0a25085 | ||
|
|
093e64eb54 | ||
|
|
f34a3b6f67 | ||
|
|
484c90ed00 | ||
|
|
f49694a543 | ||
|
|
fb88705bdc | ||
|
|
90d85e6393 | ||
|
|
d13822d26e | ||
|
|
a890288900 | ||
|
|
31d6e303a6 | ||
|
|
199c42f726 | ||
|
|
3211594821 | ||
|
|
01050a430f | ||
|
|
0b565b18c4 | ||
|
|
0791b077d7 | ||
|
|
2fb59fee8e | ||
|
|
8206da4d9e | ||
|
|
0c6bda8255 | ||
|
|
1e063e7937 | ||
|
|
37c34fd39c | ||
|
|
3a60ae98f3 | ||
|
|
feeb49a42b | ||
|
|
ad84355ebc | ||
|
|
e2397a343d | ||
|
|
8e769dcac0 | ||
|
|
9ad0477af6 | ||
|
|
c5eafd5722 | ||
|
|
caf9fdc893 | ||
|
|
be4155a838 | ||
|
|
b00326a75a | ||
|
|
4cf0aebb2e | ||
|
|
a5a39dada7 | ||
|
|
866f22b077 | ||
|
|
ec7ab16ce4 | ||
|
|
e5b06a2d95 | ||
|
|
fae49aaf88 | ||
|
|
274c8baa34 | ||
|
|
bfb36a8566 | ||
|
|
8487346d3f | ||
|
|
54b99cd88a | ||
|
|
786db364d2 | ||
|
|
863e7a093e | ||
|
|
ca87b2806f | ||
|
|
2958c6b53c | ||
|
|
8b5394e031 | ||
|
|
da92ee5f09 | ||
|
|
80de5d489f | ||
|
|
ab17578516 | ||
|
|
e764525578 | ||
|
|
a1c4f345a8 | ||
|
|
526a8ea19a | ||
|
|
4be9f7ab9c | ||
|
|
8e40160934 | ||
|
|
e97ed735d9 | ||
|
|
6d21525e71 | ||
|
|
b4f809559e | ||
|
|
33109bac4d | ||
|
|
f072ab3276 | ||
|
|
3b746162d2 | ||
|
|
6df19f1828 | ||
|
|
fba56d6871 | ||
|
|
1472efcbfe | ||
|
|
56a5d58945 | ||
|
|
f50a57041f | ||
|
|
f3da73553c | ||
|
|
9a26b3058f | ||
|
|
a09be7cf74 | ||
|
|
91a9e455e2 | ||
|
|
c391c6d3f3 | ||
|
|
ca562266b7 | ||
|
|
c69c9327da | ||
|
|
f5e2c596d4 | ||
|
|
e9bad39a7e | ||
|
|
42c7880858 | ||
|
|
017a440a70 | ||
|
|
1ab9547bb2 | ||
|
|
a4e46e6e18 | ||
|
|
680d52016c | ||
|
|
6ebbcb3179 | ||
|
|
437de4ee36 | ||
|
|
a5b28b5cef | ||
|
|
9bf024f8be | ||
|
|
189d0e5fb2 | ||
|
|
b1b402faa7 | ||
|
|
c5413c8c8d | ||
|
|
da1e8484a9 | ||
|
|
4818bc5426 | ||
|
|
8a8c6b14af | ||
|
|
0e31bbcd93 | ||
|
|
913b3434d8 | ||
|
|
1c01ee4834 | ||
|
|
005d6e0bde | ||
|
|
37c0c1f358 | ||
|
|
2a132f86d6 | ||
|
|
50ba0b380b | ||
|
|
6cccbdccd3 | ||
|
|
d0ad09d798 | ||
|
|
4fa4246c10 | ||
|
|
0fe72864f2 | ||
|
|
ce5b3126d3 | ||
|
|
26606ccbf7 | ||
|
|
fce9e79d38 | ||
|
|
6759674c0f | ||
|
|
a9799136fe | ||
|
|
7a29af4e30 | ||
|
|
d398001f96 | ||
|
|
e68747a64a | ||
|
|
d62ce482da | ||
|
|
f9f41e205d | ||
|
|
80597cd3fd | ||
|
|
48f81fe4d3 | ||
|
|
a96c2ce65c | ||
|
|
6f604bd0f9 | ||
|
|
42c1cd6a85 | ||
|
|
33a831d2be | ||
|
|
d70201cd93 | ||
|
|
9f1a75e938 | ||
|
|
2b77a7f714 | ||
|
|
5974a53071 | ||
|
|
3d61cc5d2b | ||
|
|
a22a2f0f37 | ||
|
|
be65ed6f88 | ||
|
|
e88264075a | ||
|
|
041a080a13 | ||
|
|
9d7c5efb9b | ||
|
|
8863a499a9 | ||
|
|
15d21bf04a | ||
|
|
5e738ce7d3 | ||
|
|
641e9ff664 | ||
|
|
d249766777 | ||
|
|
6cf4b7f00b | ||
|
|
6183398543 | ||
|
|
ff786d9139 | ||
|
|
4767276a0e | ||
|
|
71bab45065 | ||
|
|
cb48813c95 | ||
|
|
520cd02dd5 | ||
|
|
afe741b63e | ||
|
|
f3b224090c | ||
|
|
3b7b7f4bea | ||
|
|
3a4d3b249f | ||
|
|
55a6fcdd3f | ||
|
|
4132fcc1b2 | ||
|
|
37082b2176 | ||
|
|
b9f009c529 | ||
|
|
601f610eb7 | ||
|
|
2e2bdd46b4 | ||
|
|
3a28ce9b0a | ||
|
|
bb6fc2a1fd | ||
|
|
ad76fa8616 | ||
|
|
bdac7d10dd | ||
|
|
0ecfdd7501 | ||
|
|
a9758e0db5 | ||
|
|
e98f915fd5 | ||
|
|
07f0fea4bf | ||
|
|
6a43afc4e7 | ||
|
|
c01eefc729 | ||
|
|
5d4ccc8883 | ||
|
|
98b5390a22 | ||
|
|
c040baae11 | ||
|
|
754cc66741 | ||
|
|
6ef0b991ec | ||
|
|
f6ca06b8ea | ||
|
|
4c198940d5 | ||
|
|
2e938d9da1 | ||
|
|
b840a40759 | ||
|
|
a1d40f8f28 | ||
|
|
575d76fa06 | ||
|
|
b75456f5dd | ||
|
|
eb69cc3943 | ||
|
|
e524209352 | ||
|
|
e1c897c1ae | ||
|
|
39f54e83e1 | ||
|
|
d34c974996 | ||
|
|
c203891b84 | ||
|
|
591bd2a4e3 | ||
|
|
94f35130f7 | ||
|
|
f26873f5de | ||
|
|
66b18959eb | ||
|
|
deacf5991a | ||
|
|
25623d1f84 | ||
|
|
de9f144858 | ||
|
|
0ad8738933 | ||
|
|
db5744bbc4 | ||
|
|
b87ba57819 | ||
|
|
802389a90e | ||
|
|
4880b08b8a | ||
|
|
80555f13e0 | ||
|
|
113c49457f | ||
|
|
e1ec815d1b | ||
|
|
2ed17f4877 | ||
|
|
80118212da | ||
|
|
9b3760247a | ||
|
|
a2d652b13d | ||
|
|
5c491758f5 | ||
|
|
5f750b7368 | ||
|
|
e2dc5a8faf | ||
|
|
72d10a0823 | ||
|
|
7623b33f31 | ||
|
|
9b331a917e | ||
|
|
d51b4263ab | ||
|
|
34a2dcb80a | ||
|
|
8cbd59296e | ||
|
|
83974e0c95 | ||
|
|
59d43fa5da | ||
|
|
e01afb407c | ||
|
|
f0f55bc75f | ||
|
|
2860a2bb1a | ||
|
|
9b564f0b73 | ||
|
|
2437ce3f8b | ||
|
|
fa8a46326a | ||
|
|
652429377b | ||
|
|
99af6146d5 | ||
|
|
020e0ca039 | ||
|
|
0439072420 | ||
|
|
49ad2efef6 | ||
|
|
0e303e6508 | ||
|
|
bcd2fd68b7 | ||
|
|
d0d67029f4 | ||
|
|
a34d020bc6 | ||
|
|
96fbc37f01 | ||
|
|
89e3a72ae1 | ||
|
|
b9ebcea82c | ||
|
|
f31f92119d | ||
|
|
da9b2a18b9 | ||
|
|
2b258b1473 | ||
|
|
6f894950a6 | ||
|
|
9049295cc9 | ||
|
|
4526b14b17 | ||
|
|
f768313c4f | ||
|
|
d9c1b2cc90 | ||
|
|
6cfcf51752 | ||
|
|
dff8e77eb6 | ||
|
|
6e854a4df4 | ||
|
|
2f8984fadb | ||
|
|
c84918cb47 | ||
|
|
05bb065d00 | ||
|
|
3742997889 | ||
|
|
daf0305203 | ||
|
|
307982a099 | ||
|
|
ba416e787b | ||
|
|
b71cae63f1 | ||
|
|
c92f7c6630 | ||
|
|
4a444e9c9b | ||
|
|
623d132772 | ||
|
|
d127a1c4eb | ||
|
|
c9cca48d08 | ||
|
|
3944930fc0 | ||
|
|
825c0b64af | ||
|
|
d7af7dd3fe | ||
|
|
b112216241 | ||
|
|
87237b6462 | ||
|
|
5f5f9dad87 | ||
|
|
aa8b3ce1ee | ||
|
|
a65e593ab4 | ||
|
|
5d9058eb74 | ||
|
|
a850320fad | ||
|
|
ddbb217d0d | ||
|
|
ab150be7c3 | ||
|
|
a203fb8ccc | ||
|
|
acc084c9ea | ||
|
|
3ee213081e | ||
|
|
15bf40bc10 | ||
|
|
a33e3e25b6 | ||
|
|
658faab2bf | ||
|
|
797045ee29 |
9
.editorconfig
Normal file
9
.editorconfig
Normal file
@@ -0,0 +1,9 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
insert_final_newline = true
|
||||
end_of_line = lf
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
max_line_length = 80
|
||||
37
.github/workflows/build.yml
vendored
37
.github/workflows/build.yml
vendored
@@ -1,37 +0,0 @@
|
||||
name: build
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- run: git fetch --force --tags
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ">=1.23.2"
|
||||
cache: true
|
||||
cache-dependency-path: go.sum
|
||||
|
||||
- run: go mod download
|
||||
|
||||
- uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: latest
|
||||
args: build --snapshot --clean
|
||||
26
.github/workflows/deploy.yml
vendored
Normal file
26
.github/workflows/deploy.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
name: deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- production
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: 1.2.17
|
||||
|
||||
- run: bun install
|
||||
|
||||
- run: bun sst deploy --stage=${{ github.ref_name }}
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
14
.github/workflows/notify-discord.yml
vendored
Normal file
14
.github/workflows/notify-discord.yml
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
name: discord
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published] # fires only when a release is published
|
||||
|
||||
jobs:
|
||||
notify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Send nicely-formatted embed to Discord
|
||||
uses: SethCohen/github-releases-to-discord@v1
|
||||
with:
|
||||
webhook_url: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
24
.github/workflows/opencode.yml
vendored
Normal file
24
.github/workflows/opencode.yml
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
name: opencode
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
opencode:
|
||||
if: startsWith(github.event.comment.body, 'hey opencode')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run opencode
|
||||
uses: sst/opencode/sdks/github@github-v1
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
with:
|
||||
model: anthropic/claude-sonnet-4-20250514
|
||||
30
.github/workflows/publish-github-action.yml
vendored
Normal file
30
.github/workflows/publish-github-action.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
name: publish-github-action
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- "github-v*.*.*"
|
||||
- "!github-v1"
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- run: git fetch --force --tags
|
||||
|
||||
- name: Publish
|
||||
run: |
|
||||
git config --global user.email "opencode@sst.dev"
|
||||
git config --global user.name "opencode"
|
||||
./script/publish
|
||||
working-directory: ./sdks/github
|
||||
36
.github/workflows/publish-vscode.yml
vendored
Normal file
36
.github/workflows/publish-vscode.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
name: publish-vscode
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- "vscode-v*.*.*"
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.2.17
|
||||
|
||||
- run: git fetch --force --tags
|
||||
- run: bun install -g @vscode/vsce
|
||||
|
||||
- name: Publish
|
||||
run: |
|
||||
bun install
|
||||
./script/publish
|
||||
working-directory: ./sdks/vscode
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
OPENVSX_TOKEN: ${{ secrets.OPENVSX_TOKEN }}
|
||||
65
.github/workflows/publish.yml
vendored
Normal file
65
.github/workflows/publish.yml
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
name: publish
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
tags:
|
||||
- "*"
|
||||
- "!vscode-v*"
|
||||
- "!github-v*"
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- run: git fetch --force --tags
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ">=1.24.0"
|
||||
cache: true
|
||||
cache-dependency-path: go.sum
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.2.17
|
||||
|
||||
- name: Install makepkg
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y pacman-package-manager
|
||||
|
||||
- name: Setup SSH for AUR
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.AUR_KEY }}" > ~/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts
|
||||
git config --global user.email "opencode@sst.dev"
|
||||
git config --global user.name "opencode"
|
||||
|
||||
- name: Publish
|
||||
run: |
|
||||
bun install
|
||||
if [ "${{ startsWith(github.ref, 'refs/tags/') }}" = "true" ]; then
|
||||
./script/publish.ts
|
||||
else
|
||||
./script/publish.ts --snapshot
|
||||
fi
|
||||
working-directory: ./packages/opencode
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }}
|
||||
AUR_KEY: ${{ secrets.AUR_KEY }}
|
||||
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
40
.github/workflows/release.yml
vendored
40
.github/workflows/release.yml
vendored
@@ -1,40 +0,0 @@
|
||||
name: release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
goreleaser:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- run: git fetch --force --tags
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ">=1.23.2"
|
||||
cache: true
|
||||
cache-dependency-path: go.sum
|
||||
|
||||
- run: go mod download
|
||||
|
||||
- uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: latest
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }}
|
||||
AUR_KEY: ${{ secrets.AUR_KEY }}
|
||||
32
.github/workflows/stats.yml
vendored
Normal file
32
.github/workflows/stats.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
name: stats
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 12 * * *" # Run daily at 12:00 UTC
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
jobs:
|
||||
stats:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Run stats script
|
||||
run: bun scripts/stats.ts
|
||||
|
||||
- name: Commit stats
|
||||
run: |
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
git add STATS.md
|
||||
git diff --staged --quiet || git commit -m "ignore: update download stats $(date -I)"
|
||||
git push
|
||||
49
.gitignore
vendored
49
.gitignore
vendored
@@ -1,45 +1,8 @@
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
# Dependency directories (remove the comment below to include it)
|
||||
# vendor/
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
|
||||
# IDE specific files
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS specific files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
*.log
|
||||
|
||||
# Binary output directory
|
||||
/bin/
|
||||
/dist/
|
||||
|
||||
# Local environment variables
|
||||
node_modules
|
||||
.opencode
|
||||
.sst
|
||||
.env
|
||||
.env.local
|
||||
|
||||
.opencode/
|
||||
|
||||
.idea
|
||||
.vscode
|
||||
openapi.json
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"$schema": "./opencode-schema.json"
|
||||
}
|
||||
15
AGENTS.md
Normal file
15
AGENTS.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# TUI Agent Guidelines
|
||||
|
||||
## Style
|
||||
|
||||
- prefer single word variable/function names
|
||||
- avoid try catch where possible - prefer to let exceptions bubble up
|
||||
- avoid else statements where possible
|
||||
- do not make useless helper functions - inline functionality unless the
|
||||
function is reusable or composable
|
||||
- prefer Bun apis
|
||||
|
||||
## Workflow
|
||||
|
||||
- you can regenerate the golang sdk by calling ./scripts/stainless.ts
|
||||
- we use bun for everything
|
||||
24
CONTEXT.md
24
CONTEXT.md
@@ -1,24 +0,0 @@
|
||||
# OpenCode Development Context
|
||||
|
||||
## Build Commands
|
||||
- Build: `go build`
|
||||
- Run: `go run main.go`
|
||||
- Test: `go test ./...`
|
||||
- Test single package: `go test ./internal/package/...`
|
||||
- Test single test: `go test ./internal/package -run TestName`
|
||||
- Verbose test: `go test -v ./...`
|
||||
- Coverage: `go test -cover ./...`
|
||||
- Lint: `go vet ./...`
|
||||
- Format: `go fmt ./...`
|
||||
- Build snapshot: `./scripts/snapshot`
|
||||
|
||||
## Code Style
|
||||
- Use Go 1.24+ features
|
||||
- Follow standard Go formatting (gofmt)
|
||||
- Use table-driven tests with t.Parallel() when possible
|
||||
- Error handling: check errors immediately, return early
|
||||
- Naming: CamelCase for exported, camelCase for unexported
|
||||
- Imports: standard library first, then external, then internal
|
||||
- Use context.Context for cancellation and timeouts
|
||||
- Prefer interfaces for dependencies to enable testing
|
||||
- Use testify for assertions in tests
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Kujtim Hoxha
|
||||
Copyright (c) 2025 opencode
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
638
README.md
638
README.md
@@ -1,580 +1,110 @@
|
||||
# ⌬ OpenCode
|
||||
<p align="center">
|
||||
<a href="https://opencode.ai">
|
||||
<picture>
|
||||
<source srcset="packages/web/src/assets/logo-ornate-dark.svg" media="(prefers-color-scheme: dark)">
|
||||
<source srcset="packages/web/src/assets/logo-ornate-light.svg" media="(prefers-color-scheme: light)">
|
||||
<img src="packages/web/src/assets/logo-ornate-light.svg" alt="opencode logo">
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
<p align="center">AI coding agent, built for the terminal.</p>
|
||||
<p align="center">
|
||||
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
|
||||
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
|
||||
<a href="https://github.com/sst/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/sst/opencode/publish.yml?style=flat-square&branch=dev" /></a>
|
||||
</p>
|
||||
|
||||

|
||||
[](https://opencode.ai)
|
||||
|
||||
> **⚠️ Early Development Notice:** This project is in early development and is not yet ready for production use. Features may change, break, or be incomplete. Use at your own risk.
|
||||
---
|
||||
|
||||
A powerful terminal-based AI assistant for developers, providing intelligent coding assistance directly in your terminal.
|
||||
|
||||
## Overview
|
||||
|
||||
OpenCode is a Go-based CLI application that brings AI assistance to your terminal. It provides a TUI (Terminal User Interface) for interacting with various AI models to help with coding tasks, debugging, and more.
|
||||
|
||||
## Features
|
||||
|
||||
- **Interactive TUI**: Built with [Bubble Tea](https://github.com/charmbracelet/bubbletea) for a smooth terminal experience
|
||||
- **Multiple AI Providers**: Support for OpenAI, Anthropic Claude, Google Gemini, AWS Bedrock, Groq, Azure OpenAI, and OpenRouter
|
||||
- **Session Management**: Save and manage multiple conversation sessions
|
||||
- **Tool Integration**: AI can execute commands, search files, and modify code
|
||||
- **Vim-like Editor**: Integrated editor with text input capabilities
|
||||
- **Persistent Storage**: SQLite database for storing conversations and sessions
|
||||
- **LSP Integration**: Language Server Protocol support for code intelligence
|
||||
- **File Change Tracking**: Track and visualize file changes during sessions
|
||||
- **External Editor Support**: Open your preferred editor for composing messages
|
||||
|
||||
## Installation
|
||||
|
||||
### Using the Install Script
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Install the latest version
|
||||
# YOLO
|
||||
curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
# Install a specific version
|
||||
curl -fsSL https://opencode.ai/install | VERSION=0.1.0 bash
|
||||
# Package managers
|
||||
npm i -g opencode-ai@latest # or bun/pnpm/yarn
|
||||
brew install sst/tap/opencode # macOS
|
||||
paru -S opencode-bin # Arch Linux
|
||||
```
|
||||
|
||||
### Using Homebrew (macOS and Linux)
|
||||
> [!TIP]
|
||||
> Remove versions older than 0.1.x before installing.
|
||||
|
||||
#### Installation Directory
|
||||
|
||||
The install script respects the following priority order for the installation path:
|
||||
|
||||
1. `$OPENCODE_INSTALL_DIR` - Custom installation directory
|
||||
2. `$XDG_BIN_DIR` - XDG Base Directory Specification compliant path
|
||||
3. `$HOME/bin` - Standard user binary directory (if exists or can be created)
|
||||
4. `$HOME/.opencode/bin` - Default fallback
|
||||
|
||||
```bash
|
||||
brew install sst/tap/opencode
|
||||
# Examples
|
||||
OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash
|
||||
XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
|
||||
```
|
||||
|
||||
### Using AUR (Arch Linux)
|
||||
### Documentation
|
||||
|
||||
For more info on how to configure opencode [**head over to our docs**](https://opencode.ai/docs).
|
||||
|
||||
### Contributing
|
||||
|
||||
opencode is an opinionated tool so any fundamental feature needs to go through a
|
||||
design process with the core team.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> We do not accept PRs for core features.
|
||||
|
||||
However we still merge a ton of PRs - you can contribute:
|
||||
|
||||
- Bug fixes
|
||||
- Improvements to LLM performance
|
||||
- Support for new providers
|
||||
- Fixes for env specific quirks
|
||||
- Missing standard behavior
|
||||
- Documentation
|
||||
|
||||
Take a look at the git history to see what kind of PRs we end up merging.
|
||||
|
||||
> [!NOTE]
|
||||
> If you do not follow the above guidelines we might close your PR.
|
||||
|
||||
To run opencode locally you need.
|
||||
|
||||
- Bun
|
||||
- Golang 1.24.x
|
||||
|
||||
And run.
|
||||
|
||||
```bash
|
||||
# Using yay
|
||||
yay -S opencode-bin
|
||||
|
||||
# Using paru
|
||||
paru -S opencode-bin
|
||||
$ bun install
|
||||
$ bun run packages/opencode/src/index.ts
|
||||
```
|
||||
|
||||
### Using Go
|
||||
#### Development Notes
|
||||
|
||||
```bash
|
||||
go install github.com/sst/opencode@latest
|
||||
```
|
||||
**API Client**: After making changes to the TypeScript API endpoints in `packages/opencode/src/server/server.ts`, you will need the opencode team to generate a new stainless sdk for the clients.
|
||||
|
||||
## Configuration
|
||||
### FAQ
|
||||
|
||||
OpenCode looks for configuration in the following locations:
|
||||
#### How is this different than Claude Code?
|
||||
|
||||
- `$HOME/.opencode.json`
|
||||
- `$XDG_CONFIG_HOME/opencode/.opencode.json`
|
||||
- `./.opencode.json` (local directory)
|
||||
It's very similar to Claude Code in terms of capability. Here are the key differences:
|
||||
|
||||
### Environment Variables
|
||||
- 100% open source
|
||||
- Not coupled to any provider. Although Anthropic is recommended, opencode can be used with OpenAI, Google or even local models. As models evolve the gaps between them will close and pricing will drop so being provider agnostic is important.
|
||||
- A focus on TUI. opencode is built by neovim users and the creators of [terminal.shop](https://terminal.shop); we are going to push the limits of what's possible in the terminal.
|
||||
- A client/server architecture. This for example can allow opencode to run on your computer, while you can drive it remotely from a mobile app. Meaning that the TUI frontend is just one of the possible clients.
|
||||
|
||||
You can configure OpenCode using environment variables:
|
||||
#### What's the other repo?
|
||||
|
||||
| Environment Variable | Purpose |
|
||||
| -------------------------- | ------------------------------------------------------ |
|
||||
| `ANTHROPIC_API_KEY` | For Claude models |
|
||||
| `OPENAI_API_KEY` | For OpenAI models |
|
||||
| `GEMINI_API_KEY` | For Google Gemini models |
|
||||
| `GROQ_API_KEY` | For Groq models |
|
||||
| `AWS_ACCESS_KEY_ID` | For AWS Bedrock (Claude) |
|
||||
| `AWS_SECRET_ACCESS_KEY` | For AWS Bedrock (Claude) |
|
||||
| `AWS_REGION` | For AWS Bedrock (Claude) |
|
||||
| `AZURE_OPENAI_ENDPOINT` | For Azure OpenAI models |
|
||||
| `AZURE_OPENAI_API_KEY` | For Azure OpenAI models (optional when using Entra ID) |
|
||||
| `AZURE_OPENAI_API_VERSION` | For Azure OpenAI models |
|
||||
The other confusingly named repo has no relation to this one. You can [read the story behind it here](https://x.com/thdxr/status/1933561254481666466).
|
||||
|
||||
### Configuration File Structure
|
||||
---
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"directory": ".opencode"
|
||||
},
|
||||
"providers": {
|
||||
"openai": {
|
||||
"apiKey": "your-api-key",
|
||||
"disabled": false
|
||||
},
|
||||
"anthropic": {
|
||||
"apiKey": "your-api-key",
|
||||
"disabled": false
|
||||
},
|
||||
"groq": {
|
||||
"apiKey": "your-api-key",
|
||||
"disabled": false
|
||||
},
|
||||
"openrouter": {
|
||||
"apiKey": "your-api-key",
|
||||
"disabled": false
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"primary": {
|
||||
"model": "claude-3.7-sonnet",
|
||||
"maxTokens": 5000
|
||||
},
|
||||
"task": {
|
||||
"model": "claude-3.7-sonnet",
|
||||
"maxTokens": 5000
|
||||
},
|
||||
"title": {
|
||||
"model": "claude-3.7-sonnet",
|
||||
"maxTokens": 80
|
||||
}
|
||||
},
|
||||
"mcpServers": {
|
||||
"example": {
|
||||
"type": "stdio",
|
||||
"command": "path/to/mcp-server",
|
||||
"env": [],
|
||||
"args": []
|
||||
}
|
||||
},
|
||||
"lsp": {
|
||||
"go": {
|
||||
"disabled": false,
|
||||
"command": "gopls"
|
||||
}
|
||||
},
|
||||
"debug": false,
|
||||
"debugLSP": false
|
||||
}
|
||||
```
|
||||
|
||||
## Supported AI Models
|
||||
|
||||
OpenCode supports a variety of AI models from different providers:
|
||||
|
||||
### OpenAI
|
||||
|
||||
- GPT-4.1 family (gpt-4.1, gpt-4.1-mini, gpt-4.1-nano)
|
||||
- GPT-4.5 Preview
|
||||
- GPT-4o family (gpt-4o, gpt-4o-mini)
|
||||
- O1 family (o1, o1-pro, o1-mini)
|
||||
- O3 family (o3, o3-mini)
|
||||
- O4 Mini
|
||||
|
||||
### Anthropic
|
||||
|
||||
- Claude 3.5 Sonnet
|
||||
- Claude 3.5 Haiku
|
||||
- Claude 3.7 Sonnet
|
||||
- Claude 3 Haiku
|
||||
- Claude 3 Opus
|
||||
|
||||
### Google
|
||||
|
||||
- Gemini 2.5
|
||||
- Gemini 2.5 Flash
|
||||
- Gemini 2.0 Flash
|
||||
- Gemini 2.0 Flash Lite
|
||||
|
||||
### AWS Bedrock
|
||||
|
||||
- Claude 3.7 Sonnet
|
||||
|
||||
### Groq
|
||||
|
||||
- Llama 4 Maverick (17b-128e-instruct)
|
||||
- Llama 4 Scout (17b-16e-instruct)
|
||||
- QWEN QWQ-32b
|
||||
- Deepseek R1 distill Llama 70b
|
||||
- Llama 3.3 70b Versatile
|
||||
|
||||
### Azure OpenAI
|
||||
|
||||
- GPT-4.1 family (gpt-4.1, gpt-4.1-mini, gpt-4.1-nano)
|
||||
- GPT-4.5 Preview
|
||||
- GPT-4o family (gpt-4o, gpt-4o-mini)
|
||||
- O1 family (o1, o1-mini)
|
||||
- O3 family (o3, o3-mini)
|
||||
- O4 Mini
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Start OpenCode
|
||||
opencode
|
||||
|
||||
# Start with debug logging
|
||||
opencode -d
|
||||
|
||||
# Start with a specific working directory
|
||||
opencode -c /path/to/project
|
||||
```
|
||||
|
||||
## Command-line Flags
|
||||
|
||||
| Flag | Short | Description |
|
||||
| --------- | ----- | ----------------------------- |
|
||||
| `--help` | `-h` | Display help information |
|
||||
| `--debug` | `-d` | Enable debug mode |
|
||||
| `--cwd` | `-c` | Set current working directory |
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
### Global Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
| -------- | ------------------------------------------------------- |
|
||||
| `Ctrl+C` | Quit application |
|
||||
| `Ctrl+?` | Toggle help dialog |
|
||||
| `?` | Toggle help dialog (when not in editing mode) |
|
||||
| `Ctrl+L` | View logs |
|
||||
| `Ctrl+A` | Switch session |
|
||||
| `Ctrl+K` | Command dialog |
|
||||
| `Ctrl+O` | Toggle model selection dialog |
|
||||
| `Esc` | Close current overlay/dialog or return to previous mode |
|
||||
|
||||
### Chat Page Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
| -------- | --------------------------------------- |
|
||||
| `Ctrl+N` | Create new session |
|
||||
| `Ctrl+X` | Cancel current operation/generation |
|
||||
| `i` | Focus editor (when not in writing mode) |
|
||||
| `Esc` | Exit writing mode and focus messages |
|
||||
|
||||
### Editor Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
| ------------------- | ----------------------------------------- |
|
||||
| `Ctrl+S` | Send message (when editor is focused) |
|
||||
| `Enter` or `Ctrl+S` | Send message (when editor is not focused) |
|
||||
| `Ctrl+E` | Open external editor |
|
||||
| `Esc` | Blur editor and focus messages |
|
||||
|
||||
### Session Dialog Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
| ---------- | ---------------- |
|
||||
| `↑` or `k` | Previous session |
|
||||
| `↓` or `j` | Next session |
|
||||
| `Enter` | Select session |
|
||||
| `Esc` | Close dialog |
|
||||
|
||||
### Model Dialog Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
| ---------- | ----------------- |
|
||||
| `↑` or `k` | Move up |
|
||||
| `↓` or `j` | Move down |
|
||||
| `←` or `h` | Previous provider |
|
||||
| `→` or `l` | Next provider |
|
||||
| `Esc` | Close dialog |
|
||||
|
||||
### Permission Dialog Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
| ----------------------- | ---------------------------- |
|
||||
| `←` or `left` | Switch options left |
|
||||
| `→` or `right` or `tab` | Switch options right |
|
||||
| `Enter` or `space` | Confirm selection |
|
||||
| `a` | Allow permission |
|
||||
| `A` | Allow permission for session |
|
||||
| `d` | Deny permission |
|
||||
|
||||
### Logs Page Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
| ------------------ | ------------------- |
|
||||
| `Backspace` or `q` | Return to chat page |
|
||||
|
||||
## AI Assistant Tools
|
||||
|
||||
OpenCode's AI assistant has access to various tools to help with coding tasks:
|
||||
|
||||
### File and Code Tools
|
||||
|
||||
| Tool | Description | Parameters |
|
||||
| ------------- | --------------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| `glob` | Find files by pattern | `pattern` (required), `path` (optional) |
|
||||
| `grep` | Search file contents | `pattern` (required), `path` (optional), `include` (optional), `literal_text` (optional) |
|
||||
| `ls` | List directory contents | `path` (optional), `ignore` (optional array of patterns) |
|
||||
| `view` | View file contents | `file_path` (required), `offset` (optional), `limit` (optional) |
|
||||
| `write` | Write to files | `file_path` (required), `content` (required) |
|
||||
| `edit` | Edit files | Various parameters for file editing |
|
||||
| `patch` | Apply patches to files | `file_path` (required), `diff` (required) |
|
||||
| `diagnostics` | Get diagnostics information | `file_path` (optional) |
|
||||
|
||||
### Other Tools
|
||||
|
||||
| Tool | Description | Parameters |
|
||||
| ------- | ------------------------------- | ----------------------------------------------------------- |
|
||||
| `bash` | Execute shell commands | `command` (required), `timeout` (optional) |
|
||||
| `fetch` | Fetch data from URLs | `url` (required), `format` (required), `timeout` (optional) |
|
||||
| `agent` | Run sub-tasks with the AI agent | `prompt` (required) |
|
||||
|
||||
## Theming
|
||||
|
||||
OpenCode supports multiple themes for customizing the appearance of the terminal interface.
|
||||
|
||||
### Available Themes
|
||||
|
||||
The following predefined themes are available:
|
||||
|
||||
- `opencode` (default)
|
||||
- `catppuccin`
|
||||
- `dracula`
|
||||
- `flexoki`
|
||||
- `gruvbox`
|
||||
- `monokai`
|
||||
- `onedark`
|
||||
- `tokyonight`
|
||||
- `tron`
|
||||
- `custom` (user-defined)
|
||||
|
||||
### Setting a Theme
|
||||
|
||||
You can set a theme in your `.opencode.json` configuration file:
|
||||
|
||||
```json
|
||||
{
|
||||
"tui": {
|
||||
"theme": "monokai"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Themes
|
||||
|
||||
You can define your own custom theme by setting the `theme` to `"custom"` and providing color definitions in the `customTheme` map:
|
||||
|
||||
```json
|
||||
{
|
||||
"tui": {
|
||||
"theme": "custom",
|
||||
"customTheme": {
|
||||
"primary": "#ffcc00",
|
||||
"secondary": "#00ccff",
|
||||
"accent": { "dark": "#aa00ff", "light": "#ddccff" },
|
||||
"error": "#ff0000"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Color Definition Formats
|
||||
|
||||
Custom theme colors support two formats:
|
||||
|
||||
1. **Simple Hex String**: A single hex color string (e.g., `"#aabbcc"`) that will be used for both light and dark terminal backgrounds.
|
||||
|
||||
2. **Adaptive Object**: An object with `dark` and `light` keys, each holding a hex color string. This allows for adaptive colors based on the terminal's background.
|
||||
|
||||
#### Available Color Keys
|
||||
|
||||
You can define any of the following color keys in your `customTheme`:
|
||||
|
||||
- Base colors: `primary`, `secondary`, `accent`
|
||||
- Status colors: `error`, `warning`, `success`, `info`
|
||||
- Text colors: `text`, `textMuted`, `textEmphasized`
|
||||
- Background colors: `background`, `backgroundSecondary`, `backgroundDarker`
|
||||
- Border colors: `borderNormal`, `borderFocused`, `borderDim`
|
||||
- Diff view colors: `diffAdded`, `diffRemoved`, `diffContext`, etc.
|
||||
|
||||
You don't need to define all colors. Any undefined colors will fall back to the default "opencode" theme colors.
|
||||
|
||||
## Architecture
|
||||
|
||||
OpenCode is built with a modular architecture:
|
||||
|
||||
- **cmd**: Command-line interface using Cobra
|
||||
- **internal/app**: Core application services
|
||||
- **internal/config**: Configuration management
|
||||
- **internal/db**: Database operations and migrations
|
||||
- **internal/llm**: LLM providers and tools integration
|
||||
- **internal/tui**: Terminal UI components and layouts
|
||||
- **internal/logging**: Logging infrastructure
|
||||
- **internal/message**: Message handling
|
||||
- **internal/session**: Session management
|
||||
- **internal/lsp**: Language Server Protocol integration
|
||||
|
||||
## Custom Commands
|
||||
|
||||
OpenCode supports custom commands that can be created by users to quickly send predefined prompts to the AI assistant.
|
||||
|
||||
### Creating Custom Commands
|
||||
|
||||
Custom commands are predefined prompts stored as Markdown files in one of three locations:
|
||||
|
||||
1. **User Commands** (prefixed with `user:`):
|
||||
|
||||
```
|
||||
$XDG_CONFIG_HOME/opencode/commands/
|
||||
```
|
||||
|
||||
(typically `~/.config/opencode/commands/` on Linux/macOS)
|
||||
|
||||
or
|
||||
|
||||
```
|
||||
$HOME/.opencode/commands/
|
||||
```
|
||||
|
||||
2. **Project Commands** (prefixed with `project:`):
|
||||
```
|
||||
<PROJECT DIR>/.opencode/commands/
|
||||
```
|
||||
|
||||
Each `.md` file in these directories becomes a custom command. The file name (without extension) becomes the command ID.
|
||||
|
||||
For example, creating a file at `~/.config/opencode/commands/prime-context.md` with content:
|
||||
|
||||
```markdown
|
||||
RUN git ls-files
|
||||
READ README.md
|
||||
```
|
||||
|
||||
This creates a command called `user:prime-context`.
|
||||
|
||||
### Command Arguments
|
||||
|
||||
You can create commands that accept arguments by including the `$ARGUMENTS` placeholder in your command file:
|
||||
|
||||
```markdown
|
||||
RUN git show $ARGUMENTS
|
||||
```
|
||||
|
||||
When you run this command, OpenCode will prompt you to enter the text that should replace `$ARGUMENTS`.
|
||||
|
||||
### Organizing Commands
|
||||
|
||||
You can organize commands in subdirectories:
|
||||
|
||||
```
|
||||
~/.config/opencode/commands/git/commit.md
|
||||
```
|
||||
|
||||
This creates a command with ID `user:git:commit`.
|
||||
|
||||
### Using Custom Commands
|
||||
|
||||
1. Press `Ctrl+K` to open the command dialog
|
||||
2. Select your custom command (prefixed with either `user:` or `project:`)
|
||||
3. Press Enter to execute the command
|
||||
|
||||
The content of the command file will be sent as a message to the AI assistant.
|
||||
|
||||
## MCP (Model Context Protocol)
|
||||
|
||||
OpenCode implements the Model Context Protocol (MCP) to extend its capabilities through external tools. MCP provides a standardized way for the AI assistant to interact with external services and tools.
|
||||
|
||||
### MCP Features
|
||||
|
||||
- **External Tool Integration**: Connect to external tools and services via a standardized protocol
|
||||
- **Tool Discovery**: Automatically discover available tools from MCP servers
|
||||
- **Multiple Connection Types**:
|
||||
- **Stdio**: Communicate with tools via standard input/output
|
||||
- **SSE**: Communicate with tools via Server-Sent Events
|
||||
- **Security**: Permission system for controlling access to MCP tools
|
||||
|
||||
### Configuring MCP Servers
|
||||
|
||||
MCP servers are defined in the configuration file under the `mcpServers` section:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"example": {
|
||||
"type": "stdio",
|
||||
"command": "path/to/mcp-server",
|
||||
"env": [],
|
||||
"args": []
|
||||
},
|
||||
"web-example": {
|
||||
"type": "sse",
|
||||
"url": "https://example.com/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer token"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### MCP Tool Usage
|
||||
|
||||
Once configured, MCP tools are automatically available to the AI assistant alongside built-in tools. They follow the same permission model as other tools, requiring user approval before execution.
|
||||
|
||||
## LSP (Language Server Protocol)
|
||||
|
||||
OpenCode integrates with Language Server Protocol to provide code intelligence features across multiple programming languages.
|
||||
|
||||
### LSP Features
|
||||
|
||||
- **Multi-language Support**: Connect to language servers for different programming languages
|
||||
- **Diagnostics**: Receive error checking and linting information
|
||||
- **File Watching**: Automatically notify language servers of file changes
|
||||
|
||||
### Configuring LSP
|
||||
|
||||
Language servers are configured in the configuration file under the `lsp` section:
|
||||
|
||||
```json
|
||||
{
|
||||
"lsp": {
|
||||
"go": {
|
||||
"disabled": false,
|
||||
"command": "gopls"
|
||||
},
|
||||
"typescript": {
|
||||
"disabled": false,
|
||||
"command": "typescript-language-server",
|
||||
"args": ["--stdio"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### LSP Integration with AI
|
||||
|
||||
The AI assistant can access LSP features through the `diagnostics` tool, allowing it to:
|
||||
|
||||
- Check for errors in your code
|
||||
- Suggest fixes based on diagnostics
|
||||
|
||||
While the LSP client implementation supports the full LSP protocol (including completions, hover, definition, etc.), currently only diagnostics are exposed to the AI assistant.
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Go 1.24.0 or higher
|
||||
|
||||
### Building from Source
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/sst/opencode.git
|
||||
cd opencode
|
||||
|
||||
# Build
|
||||
go build -o opencode
|
||||
|
||||
# Run
|
||||
./opencode
|
||||
```
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
OpenCode gratefully acknowledges the contributions and support from these key individuals:
|
||||
|
||||
- [@isaacphi](https://github.com/isaacphi) - For the [mcp-language-server](https://github.com/isaacphi/mcp-language-server) project which provided the foundation for our LSP client implementation
|
||||
- [@adamdottv](https://github.com/adamdottv) - For the design direction and UI/UX architecture
|
||||
|
||||
Special thanks to the broader open source community whose tools and libraries have made this project possible.
|
||||
|
||||
## License
|
||||
|
||||
OpenCode is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Here's how you can contribute:
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
|
||||
4. Push to the branch (`git push origin feature/amazing-feature`)
|
||||
5. Open a Pull Request
|
||||
|
||||
Please make sure to update tests as appropriate and follow the existing code style.
|
||||
**Join our community** [Discord](https://discord.gg/opencode) | [YouTube](https://www.youtube.com/c/sst-dev) | [X.com](https://x.com/SST_dev)
|
||||
|
||||
25
STATS.md
Normal file
25
STATS.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Download Stats
|
||||
|
||||
| Date | GitHub Downloads | npm Downloads | Total |
|
||||
| ---------- | ---------------- | ---------------- | ----------------- |
|
||||
| 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) |
|
||||
| 2025-06-30 | 20,127 (+1,338) | 41,059 (+1,639) | 61,186 (+2,977) |
|
||||
| 2025-07-01 | 22,108 (+1,981) | 43,745 (+2,686) | 65,853 (+4,667) |
|
||||
| 2025-07-02 | 24,814 (+2,706) | 46,168 (+2,423) | 70,982 (+5,129) |
|
||||
| 2025-07-03 | 27,834 (+3,020) | 49,955 (+3,787) | 77,789 (+6,807) |
|
||||
| 2025-07-04 | 30,608 (+2,774) | 54,758 (+4,803) | 85,366 (+7,577) |
|
||||
| 2025-07-05 | 32,524 (+1,916) | 58,371 (+3,613) | 90,895 (+5,529) |
|
||||
| 2025-07-06 | 33,766 (+1,242) | 59,694 (+1,323) | 93,460 (+2,565) |
|
||||
| 2025-07-08 | 38,052 (+4,286) | 64,468 (+4,774) | 102,520 (+9,060) |
|
||||
| 2025-07-10 | 43,796 (+5,744) | 71,402 (+6,934) | 115,198 (+12,678) |
|
||||
| 2025-07-11 | 46,982 (+3,186) | 77,462 (+6,060) | 124,444 (+9,246) |
|
||||
| 2025-07-12 | 49,302 (+2,320) | 82,177 (+4,715) | 131,479 (+7,035) |
|
||||
| 2025-07-13 | 50,803 (+1,501) | 86,394 (+4,217) | 137,197 (+5,718) |
|
||||
| 2025-07-14 | 53,283 (+2,480) | 87,860 (+1,466) | 141,143 (+3,946) |
|
||||
| 2025-07-15 | 57,590 (+4,307) | 91,036 (+3,176) | 148,626 (+7,483) |
|
||||
| 2025-07-16 | 62,313 (+4,723) | 95,258 (+4,222) | 157,571 (+8,945) |
|
||||
| 2025-07-17 | 66,684 (+4,371) | 100,048 (+4,790) | 166,732 (+9,161) |
|
||||
| 2025-07-18 | 70,379 (+3,695) | 102,587 (+2,539) | 172,966 (+6,234) |
|
||||
| 2025-07-18 | 70,380 (+1) | 102,587 (+0) | 172,967 (+1) |
|
||||
| 2025-07-19 | 73,497 (+3,117) | 105,904 (+3,317) | 179,401 (+6,434) |
|
||||
| 2025-07-20 | 76,453 (+2,956) | 109,044 (+3,140) | 185,497 (+6,096) |
|
||||
2
bunfig.toml
Normal file
2
bunfig.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[install]
|
||||
exact = true
|
||||
299
cmd/root.go
299
cmd/root.go
@@ -1,299 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"log/slog"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
zone "github.com/lrstanley/bubblezone"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/config"
|
||||
"github.com/sst/opencode/internal/db"
|
||||
"github.com/sst/opencode/internal/llm/agent"
|
||||
"github.com/sst/opencode/internal/logging"
|
||||
"github.com/sst/opencode/internal/lsp/discovery"
|
||||
"github.com/sst/opencode/internal/pubsub"
|
||||
"github.com/sst/opencode/internal/tui"
|
||||
"github.com/sst/opencode/internal/version"
|
||||
)
|
||||
|
||||
type SessionIDHandler struct {
|
||||
slog.Handler
|
||||
app *app.App
|
||||
}
|
||||
|
||||
func (h *SessionIDHandler) Handle(ctx context.Context, r slog.Record) error {
|
||||
if h.app != nil {
|
||||
sessionID := h.app.CurrentSession.ID
|
||||
if sessionID != "" {
|
||||
r.AddAttrs(slog.String("session_id", sessionID))
|
||||
}
|
||||
}
|
||||
return h.Handler.Handle(ctx, r)
|
||||
}
|
||||
|
||||
func (h *SessionIDHandler) WithApp(app *app.App) *SessionIDHandler {
|
||||
h.app = app
|
||||
return h
|
||||
}
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "OpenCode",
|
||||
Short: "A terminal AI assistant for software development",
|
||||
Long: `OpenCode is a powerful terminal-based AI assistant that helps with software development tasks.
|
||||
It provides an interactive chat interface with AI capabilities, code analysis, and LSP integration
|
||||
to assist developers in writing, debugging, and understanding code directly from the terminal.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// If the help flag is set, show the help message
|
||||
if cmd.Flag("help").Changed {
|
||||
cmd.Help()
|
||||
return nil
|
||||
}
|
||||
if cmd.Flag("version").Changed {
|
||||
fmt.Println(version.Version)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Setup logging
|
||||
lvl := new(slog.LevelVar)
|
||||
textHandler := slog.NewTextHandler(logging.NewSlogWriter(), &slog.HandlerOptions{Level: lvl})
|
||||
sessionAwareHandler := &SessionIDHandler{Handler: textHandler}
|
||||
logger := slog.New(sessionAwareHandler)
|
||||
slog.SetDefault(logger)
|
||||
|
||||
// Load the config
|
||||
debug, _ := cmd.Flags().GetBool("debug")
|
||||
cwd, _ := cmd.Flags().GetString("cwd")
|
||||
if cwd != "" {
|
||||
err := os.Chdir(cwd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to change directory: %v", err)
|
||||
}
|
||||
}
|
||||
if cwd == "" {
|
||||
c, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current working directory: %v", err)
|
||||
}
|
||||
cwd = c
|
||||
}
|
||||
_, err := config.Load(cwd, debug, lvl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Run LSP auto-discovery
|
||||
if err := discovery.IntegrateLSPServers(cwd); err != nil {
|
||||
slog.Warn("Failed to auto-discover LSP servers", "error", err)
|
||||
// Continue anyway, this is not a fatal error
|
||||
}
|
||||
|
||||
// Connect DB, this will also run migrations
|
||||
conn, err := db.Connect()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create main context for the application
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
app, err := app.New(ctx, conn)
|
||||
if err != nil {
|
||||
slog.Error("Failed to create app", "error", err)
|
||||
return err
|
||||
}
|
||||
sessionAwareHandler.WithApp(app)
|
||||
|
||||
// Set up the TUI
|
||||
zone.NewGlobal()
|
||||
program := tea.NewProgram(
|
||||
tui.New(app),
|
||||
tea.WithAltScreen(),
|
||||
)
|
||||
|
||||
// Initialize MCP tools in the background
|
||||
initMCPTools(ctx, app)
|
||||
|
||||
// Setup the subscriptions, this will send services events to the TUI
|
||||
ch, cancelSubs := setupSubscriptions(app, ctx)
|
||||
|
||||
// Create a context for the TUI message handler
|
||||
tuiCtx, tuiCancel := context.WithCancel(ctx)
|
||||
var tuiWg sync.WaitGroup
|
||||
tuiWg.Add(1)
|
||||
|
||||
// Set up message handling for the TUI
|
||||
go func() {
|
||||
defer tuiWg.Done()
|
||||
defer logging.RecoverPanic("TUI-message-handler", func() {
|
||||
attemptTUIRecovery(program)
|
||||
})
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-tuiCtx.Done():
|
||||
slog.Info("TUI message handler shutting down")
|
||||
return
|
||||
case msg, ok := <-ch:
|
||||
if !ok {
|
||||
slog.Info("TUI message channel closed")
|
||||
return
|
||||
}
|
||||
program.Send(msg)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Cleanup function for when the program exits
|
||||
cleanup := func() {
|
||||
// Cancel subscriptions first
|
||||
cancelSubs()
|
||||
|
||||
// Then shutdown the app
|
||||
app.Shutdown()
|
||||
|
||||
// Then cancel TUI message handler
|
||||
tuiCancel()
|
||||
|
||||
// Wait for TUI message handler to finish
|
||||
tuiWg.Wait()
|
||||
|
||||
slog.Info("All goroutines cleaned up")
|
||||
}
|
||||
|
||||
// Run the TUI
|
||||
result, err := program.Run()
|
||||
cleanup()
|
||||
|
||||
if err != nil {
|
||||
slog.Error("TUI error", "error", err)
|
||||
return fmt.Errorf("TUI error: %v", err)
|
||||
}
|
||||
|
||||
slog.Info("TUI exited", "result", result)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// attemptTUIRecovery tries to recover the TUI after a panic
|
||||
func attemptTUIRecovery(program *tea.Program) {
|
||||
slog.Info("Attempting to recover TUI after panic")
|
||||
|
||||
// We could try to restart the TUI or gracefully exit
|
||||
// For now, we'll just quit the program to avoid further issues
|
||||
program.Quit()
|
||||
}
|
||||
|
||||
func initMCPTools(ctx context.Context, app *app.App) {
|
||||
go func() {
|
||||
defer logging.RecoverPanic("MCP-goroutine", nil)
|
||||
|
||||
// Create a context with timeout for the initial MCP tools fetch
|
||||
ctxWithTimeout, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Set this up once with proper error handling
|
||||
agent.GetMcpTools(ctxWithTimeout, app.Permissions)
|
||||
slog.Info("MCP message handling goroutine exiting")
|
||||
}()
|
||||
}
|
||||
|
||||
func setupSubscriber[T any](
|
||||
ctx context.Context,
|
||||
wg *sync.WaitGroup,
|
||||
name string,
|
||||
subscriber func(context.Context) <-chan pubsub.Event[T],
|
||||
outputCh chan<- tea.Msg,
|
||||
) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer logging.RecoverPanic(fmt.Sprintf("subscription-%s", name), nil)
|
||||
|
||||
subCh := subscriber(ctx)
|
||||
if subCh == nil {
|
||||
slog.Warn("subscription channel is nil", "name", name)
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case event, ok := <-subCh:
|
||||
if !ok {
|
||||
slog.Info("subscription channel closed", "name", name)
|
||||
return
|
||||
}
|
||||
|
||||
var msg tea.Msg = event
|
||||
|
||||
select {
|
||||
case outputCh <- msg:
|
||||
case <-time.After(2 * time.Second):
|
||||
slog.Warn("message dropped due to slow consumer", "name", name)
|
||||
case <-ctx.Done():
|
||||
slog.Info("subscription cancelled", "name", name)
|
||||
return
|
||||
}
|
||||
case <-ctx.Done():
|
||||
slog.Info("subscription cancelled", "name", name)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func setupSubscriptions(app *app.App, parentCtx context.Context) (chan tea.Msg, func()) {
|
||||
ch := make(chan tea.Msg, 100)
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
ctx, cancel := context.WithCancel(parentCtx) // Inherit from parent context
|
||||
|
||||
setupSubscriber(ctx, &wg, "logging", app.Logs.Subscribe, ch)
|
||||
setupSubscriber(ctx, &wg, "sessions", app.Sessions.Subscribe, ch)
|
||||
setupSubscriber(ctx, &wg, "messages", app.Messages.Subscribe, ch)
|
||||
setupSubscriber(ctx, &wg, "permissions", app.Permissions.Subscribe, ch)
|
||||
setupSubscriber(ctx, &wg, "status", app.Status.Subscribe, ch)
|
||||
|
||||
cleanupFunc := func() {
|
||||
slog.Info("Cancelling all subscriptions")
|
||||
cancel() // Signal all goroutines to stop
|
||||
|
||||
waitCh := make(chan struct{})
|
||||
go func() {
|
||||
defer logging.RecoverPanic("subscription-cleanup", nil)
|
||||
wg.Wait()
|
||||
close(waitCh)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-waitCh:
|
||||
slog.Info("All subscription goroutines completed successfully")
|
||||
close(ch) // Only close after all writers are confirmed done
|
||||
case <-time.After(5 * time.Second):
|
||||
slog.Warn("Timed out waiting for some subscription goroutines to complete")
|
||||
close(ch)
|
||||
}
|
||||
}
|
||||
return ch, cleanupFunc
|
||||
}
|
||||
|
||||
func Execute() {
|
||||
err := rootCmd.Execute()
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.Flags().BoolP("help", "h", false, "Help")
|
||||
rootCmd.Flags().BoolP("version", "v", false, "Version")
|
||||
rootCmd.Flags().BoolP("debug", "d", false, "Debug")
|
||||
rootCmd.Flags().StringP("cwd", "c", "", "Current working directory")
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
# OpenCode Configuration Schema Generator
|
||||
|
||||
This tool generates a JSON Schema for the OpenCode configuration file. The schema can be used to validate configuration files and provide autocompletion in editors that support JSON Schema.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
go run cmd/schema/main.go > opencode-schema.json
|
||||
```
|
||||
|
||||
This will generate a JSON Schema file that can be used to validate configuration files.
|
||||
|
||||
## Schema Features
|
||||
|
||||
The generated schema includes:
|
||||
|
||||
- All configuration options with descriptions
|
||||
- Default values where applicable
|
||||
- Validation for enum values (e.g., model IDs, provider types)
|
||||
- Required fields
|
||||
- Type checking
|
||||
|
||||
## Using the Schema
|
||||
|
||||
You can use the generated schema in several ways:
|
||||
|
||||
1. **Editor Integration**: Many editors (VS Code, JetBrains IDEs, etc.) support JSON Schema for validation and autocompletion. You can configure your editor to use the generated schema for `.opencode.json` files.
|
||||
|
||||
2. **Validation Tools**: You can use tools like [jsonschema](https://github.com/Julian/jsonschema) to validate your configuration files against the schema.
|
||||
|
||||
3. **Documentation**: The schema serves as documentation for the configuration options.
|
||||
|
||||
## Example Configuration
|
||||
|
||||
Here's an example configuration that conforms to the schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"directory": ".opencode"
|
||||
},
|
||||
"debug": false,
|
||||
"providers": {
|
||||
"anthropic": {
|
||||
"apiKey": "your-api-key"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"primary": {
|
||||
"model": "claude-3.7-sonnet",
|
||||
"maxTokens": 5000,
|
||||
"reasoningEffort": "medium"
|
||||
},
|
||||
"task": {
|
||||
"model": "claude-3.7-sonnet",
|
||||
"maxTokens": 5000
|
||||
},
|
||||
"title": {
|
||||
"model": "claude-3.7-sonnet",
|
||||
"maxTokens": 80
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1,335 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/sst/opencode/internal/config"
|
||||
"github.com/sst/opencode/internal/llm/models"
|
||||
)
|
||||
|
||||
// JSONSchemaType represents a JSON Schema type
|
||||
type JSONSchemaType struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Properties map[string]any `json:"properties,omitempty"`
|
||||
Required []string `json:"required,omitempty"`
|
||||
AdditionalProperties any `json:"additionalProperties,omitempty"`
|
||||
Enum []any `json:"enum,omitempty"`
|
||||
Items map[string]any `json:"items,omitempty"`
|
||||
OneOf []map[string]any `json:"oneOf,omitempty"`
|
||||
AnyOf []map[string]any `json:"anyOf,omitempty"`
|
||||
Default any `json:"default,omitempty"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
schema := generateSchema()
|
||||
|
||||
// Pretty print the schema
|
||||
encoder := json.NewEncoder(os.Stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
if err := encoder.Encode(schema); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error encoding schema: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func generateSchema() map[string]any {
|
||||
schema := map[string]any{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "OpenCode Configuration",
|
||||
"description": "Configuration schema for the OpenCode application",
|
||||
"type": "object",
|
||||
"properties": map[string]any{},
|
||||
}
|
||||
|
||||
// Add Data configuration
|
||||
schema["properties"].(map[string]any)["data"] = map[string]any{
|
||||
"type": "object",
|
||||
"description": "Storage configuration",
|
||||
"properties": map[string]any{
|
||||
"directory": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Directory where application data is stored",
|
||||
"default": ".opencode",
|
||||
},
|
||||
},
|
||||
"required": []string{"directory"},
|
||||
}
|
||||
|
||||
// Add working directory
|
||||
schema["properties"].(map[string]any)["wd"] = map[string]any{
|
||||
"type": "string",
|
||||
"description": "Working directory for the application",
|
||||
}
|
||||
|
||||
// Add debug flags
|
||||
schema["properties"].(map[string]any)["debug"] = map[string]any{
|
||||
"type": "boolean",
|
||||
"description": "Enable debug mode",
|
||||
"default": false,
|
||||
}
|
||||
|
||||
schema["properties"].(map[string]any)["debugLSP"] = map[string]any{
|
||||
"type": "boolean",
|
||||
"description": "Enable LSP debug mode",
|
||||
"default": false,
|
||||
}
|
||||
|
||||
schema["properties"].(map[string]any)["contextPaths"] = map[string]any{
|
||||
"type": "array",
|
||||
"description": "Context paths for the application",
|
||||
"items": map[string]any{
|
||||
"type": "string",
|
||||
},
|
||||
"default": []string{
|
||||
".github/copilot-instructions.md",
|
||||
".cursorrules",
|
||||
".cursor/rules/",
|
||||
"CLAUDE.md",
|
||||
"CLAUDE.local.md",
|
||||
"opencode.md",
|
||||
"opencode.local.md",
|
||||
"OpenCode.md",
|
||||
"OpenCode.local.md",
|
||||
"OPENCODE.md",
|
||||
"OPENCODE.local.md",
|
||||
},
|
||||
}
|
||||
|
||||
schema["properties"].(map[string]any)["tui"] = map[string]any{
|
||||
"type": "object",
|
||||
"description": "Terminal User Interface configuration",
|
||||
"properties": map[string]any{
|
||||
"theme": map[string]any{
|
||||
"type": "string",
|
||||
"description": "TUI theme name",
|
||||
"default": "opencode",
|
||||
"enum": []string{
|
||||
"opencode",
|
||||
"catppuccin",
|
||||
"dracula",
|
||||
"flexoki",
|
||||
"gruvbox",
|
||||
"monokai",
|
||||
"onedark",
|
||||
"tokyonight",
|
||||
"tron",
|
||||
"custom",
|
||||
},
|
||||
},
|
||||
"customTheme": map[string]any{
|
||||
"type": "object",
|
||||
"description": "Custom theme color definitions",
|
||||
"additionalProperties": map[string]any{
|
||||
"oneOf": []map[string]any{
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^#[0-9a-fA-F]{6}$",
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"dark": map[string]any{
|
||||
"type": "string",
|
||||
"pattern": "^#[0-9a-fA-F]{6}$",
|
||||
},
|
||||
"light": map[string]any{
|
||||
"type": "string",
|
||||
"pattern": "^#[0-9a-fA-F]{6}$",
|
||||
},
|
||||
},
|
||||
"required": []string{"dark", "light"},
|
||||
"additionalProperties": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Add MCP servers
|
||||
schema["properties"].(map[string]any)["mcpServers"] = map[string]any{
|
||||
"type": "object",
|
||||
"description": "Model Control Protocol server configurations",
|
||||
"additionalProperties": map[string]any{
|
||||
"type": "object",
|
||||
"description": "MCP server configuration",
|
||||
"properties": map[string]any{
|
||||
"command": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Command to execute for the MCP server",
|
||||
},
|
||||
"env": map[string]any{
|
||||
"type": "array",
|
||||
"description": "Environment variables for the MCP server",
|
||||
"items": map[string]any{
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
"args": map[string]any{
|
||||
"type": "array",
|
||||
"description": "Command arguments for the MCP server",
|
||||
"items": map[string]any{
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
"type": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Type of MCP server",
|
||||
"enum": []string{"stdio", "sse"},
|
||||
"default": "stdio",
|
||||
},
|
||||
"url": map[string]any{
|
||||
"type": "string",
|
||||
"description": "URL for SSE type MCP servers",
|
||||
},
|
||||
"headers": map[string]any{
|
||||
"type": "object",
|
||||
"description": "HTTP headers for SSE type MCP servers",
|
||||
"additionalProperties": map[string]any{
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": []string{"command"},
|
||||
},
|
||||
}
|
||||
|
||||
// Add providers
|
||||
providerSchema := map[string]any{
|
||||
"type": "object",
|
||||
"description": "LLM provider configurations",
|
||||
"additionalProperties": map[string]any{
|
||||
"type": "object",
|
||||
"description": "Provider configuration",
|
||||
"properties": map[string]any{
|
||||
"apiKey": map[string]any{
|
||||
"type": "string",
|
||||
"description": "API key for the provider",
|
||||
},
|
||||
"disabled": map[string]any{
|
||||
"type": "boolean",
|
||||
"description": "Whether the provider is disabled",
|
||||
"default": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Add known providers
|
||||
knownProviders := []string{
|
||||
string(models.ProviderAnthropic),
|
||||
string(models.ProviderOpenAI),
|
||||
string(models.ProviderGemini),
|
||||
string(models.ProviderGROQ),
|
||||
string(models.ProviderOpenRouter),
|
||||
string(models.ProviderBedrock),
|
||||
string(models.ProviderAzure),
|
||||
}
|
||||
|
||||
providerSchema["additionalProperties"].(map[string]any)["properties"].(map[string]any)["provider"] = map[string]any{
|
||||
"type": "string",
|
||||
"description": "Provider type",
|
||||
"enum": knownProviders,
|
||||
}
|
||||
|
||||
schema["properties"].(map[string]any)["providers"] = providerSchema
|
||||
|
||||
// Add agents
|
||||
agentSchema := map[string]any{
|
||||
"type": "object",
|
||||
"description": "Agent configurations",
|
||||
"additionalProperties": map[string]any{
|
||||
"type": "object",
|
||||
"description": "Agent configuration",
|
||||
"properties": map[string]any{
|
||||
"model": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Model ID for the agent",
|
||||
},
|
||||
"maxTokens": map[string]any{
|
||||
"type": "integer",
|
||||
"description": "Maximum tokens for the agent",
|
||||
"minimum": 1,
|
||||
},
|
||||
"reasoningEffort": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Reasoning effort for models that support it (OpenAI, Anthropic)",
|
||||
"enum": []string{"low", "medium", "high"},
|
||||
},
|
||||
},
|
||||
"required": []string{"model"},
|
||||
},
|
||||
}
|
||||
|
||||
// Add model enum
|
||||
modelEnum := []string{}
|
||||
for modelID := range models.SupportedModels {
|
||||
modelEnum = append(modelEnum, string(modelID))
|
||||
}
|
||||
agentSchema["additionalProperties"].(map[string]any)["properties"].(map[string]any)["model"].(map[string]any)["enum"] = modelEnum
|
||||
|
||||
// Add specific agent properties
|
||||
agentProperties := map[string]any{}
|
||||
knownAgents := []string{
|
||||
string(config.AgentPrimary),
|
||||
string(config.AgentTask),
|
||||
string(config.AgentTitle),
|
||||
}
|
||||
|
||||
for _, agentName := range knownAgents {
|
||||
agentProperties[agentName] = map[string]any{
|
||||
"$ref": "#/definitions/agent",
|
||||
}
|
||||
}
|
||||
|
||||
// Create a combined schema that allows both specific agents and additional ones
|
||||
combinedAgentSchema := map[string]any{
|
||||
"type": "object",
|
||||
"description": "Agent configurations",
|
||||
"properties": agentProperties,
|
||||
"additionalProperties": agentSchema["additionalProperties"],
|
||||
}
|
||||
|
||||
schema["properties"].(map[string]any)["agents"] = combinedAgentSchema
|
||||
schema["definitions"] = map[string]any{
|
||||
"agent": agentSchema["additionalProperties"],
|
||||
}
|
||||
|
||||
// Add LSP configuration
|
||||
schema["properties"].(map[string]any)["lsp"] = map[string]any{
|
||||
"type": "object",
|
||||
"description": "Language Server Protocol configurations",
|
||||
"additionalProperties": map[string]any{
|
||||
"type": "object",
|
||||
"description": "LSP configuration for a language",
|
||||
"properties": map[string]any{
|
||||
"disabled": map[string]any{
|
||||
"type": "boolean",
|
||||
"description": "Whether the LSP is disabled",
|
||||
"default": false,
|
||||
},
|
||||
"command": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Command to execute for the LSP server",
|
||||
},
|
||||
"args": map[string]any{
|
||||
"type": "array",
|
||||
"description": "Command arguments for the LSP server",
|
||||
"items": map[string]any{
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
"options": map[string]any{
|
||||
"type": "object",
|
||||
"description": "Additional options for the LSP server",
|
||||
},
|
||||
},
|
||||
"required": []string{"command"},
|
||||
},
|
||||
}
|
||||
|
||||
return schema
|
||||
}
|
||||
128
go.mod
128
go.mod
@@ -1,128 +0,0 @@
|
||||
module github.com/sst/opencode
|
||||
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0
|
||||
github.com/JohannesKaufmann/html-to-markdown v1.6.0
|
||||
github.com/PuerkitoBio/goquery v1.9.2
|
||||
github.com/alecthomas/chroma/v2 v2.15.0
|
||||
github.com/anthropics/anthropic-sdk-go v0.2.0-beta.2
|
||||
github.com/aymanbagabas/go-udiff v0.2.0
|
||||
github.com/bmatcuk/doublestar/v4 v4.8.1
|
||||
github.com/catppuccin/go v0.3.0
|
||||
github.com/charmbracelet/bubbles v0.20.0
|
||||
github.com/charmbracelet/bubbletea v1.3.4
|
||||
github.com/charmbracelet/glamour v0.9.1
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/charmbracelet/x/ansi v0.8.0
|
||||
github.com/fsnotify/fsnotify v1.8.0
|
||||
github.com/go-logfmt/logfmt v0.6.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231
|
||||
github.com/mark3labs/mcp-go v0.17.0
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
|
||||
github.com/muesli/reflow v0.3.0
|
||||
github.com/muesli/termenv v0.16.0
|
||||
github.com/ncruces/go-sqlite3 v0.25.0
|
||||
github.com/openai/openai-go v0.1.0-beta.2
|
||||
github.com/pressly/goose/v3 v3.24.2
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
|
||||
github.com/spf13/cobra v1.9.1
|
||||
github.com/spf13/viper v1.20.0
|
||||
github.com/stretchr/testify v1.10.0
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go v0.116.0 // indirect
|
||||
cloud.google.com/go/auth v0.13.0 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.6.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.2 // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.30.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.27.27 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.27 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.22.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 // indirect
|
||||
github.com/aws/smithy-go v1.20.3 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/dlclark/regexp2 v1.11.4 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/s2a-go v0.1.8 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/ncruces/julianday v1.0.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/sagikazarmark/locafero v0.7.0 // indirect
|
||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.12.0 // indirect
|
||||
github.com/spf13/cast v1.7.1 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/tetratelabs/wazero v1.9.0 // indirect
|
||||
github.com/tidwall/gjson v1.18.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
github.com/yuin/goldmark v1.7.8 // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.5 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect
|
||||
go.opentelemetry.io/otel v1.35.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.35.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.35.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/crypto v0.37.0 // indirect
|
||||
golang.org/x/image v0.26.0 // indirect
|
||||
golang.org/x/net v0.39.0 // indirect
|
||||
golang.org/x/sync v0.13.0 // indirect
|
||||
golang.org/x/sys v0.32.0 // indirect
|
||||
golang.org/x/term v0.31.0 // indirect
|
||||
golang.org/x/text v0.24.0 // indirect
|
||||
google.golang.org/genai v1.3.0
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect
|
||||
google.golang.org/grpc v1.71.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
359
go.sum
359
go.sum
@@ -1,359 +0,0 @@
|
||||
cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE=
|
||||
cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U=
|
||||
cloud.google.com/go/auth v0.13.0 h1:8Fu8TZy167JkW8Tj3q7dIkr2v4cndv41ouecJx0PAHs=
|
||||
cloud.google.com/go/auth v0.13.0/go.mod h1:COOjD9gwfKNKz+IIduatIhYJQIc0mG3H102r/EMxX6Q=
|
||||
cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I=
|
||||
cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 h1:g0EZJwz7xkXQiZAI5xi9f3WWFYBlX1CPTrR+NDToRkQ=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0/go.mod h1:XCW7KnZet0Opnr7HccfUw1PLc4CjHqpcaxW8DHklNkQ=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0 h1:tfLQ34V6F7tVSwoTf/4lH5sE0o6eCJuNDTmH09nDpbc=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 h1:ywEEhmNahHBihViHepv3xPBn1663uRv2t2q/ESv9seY=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
||||
github.com/JohannesKaufmann/html-to-markdown v1.6.0 h1:04VXMiE50YYfCfLboJCLcgqF5x+rHJnb1ssNmqpLH/k=
|
||||
github.com/JohannesKaufmann/html-to-markdown v1.6.0/go.mod h1:NUI78lGg/a7vpEJTz/0uOcYMaibytE4BUOQS8k78yPQ=
|
||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||
github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
|
||||
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.15.0 h1:LxXTQHFoYrstG2nnV9y2X5O94sOBzf0CIUpSTbpxvMc=
|
||||
github.com/alecthomas/chroma/v2 v2.15.0/go.mod h1:gUhVLrPDXPtp/f+L1jo9xepo9gL4eLwRuGAunSZMkio=
|
||||
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
|
||||
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
|
||||
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
|
||||
github.com/anthropics/anthropic-sdk-go v0.2.0-beta.2 h1:h7qxtumNjKPWFv1QM/HJy60MteeW23iKeEtBoY7bYZk=
|
||||
github.com/anthropics/anthropic-sdk-go v0.2.0-beta.2/go.mod h1:AapDW22irxK2PSumZiQXYUFvsdQgkwIWlpESweWZI/c=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aws/aws-sdk-go-v2 v1.30.3 h1:jUeBtG0Ih+ZIFH0F4UkmL9w3cSpaMv9tYYDbzILP8dY=
|
||||
github.com/aws/aws-sdk-go-v2 v1.30.3/go.mod h1:nIQjQVp5sfpQcTc9mPSr1B0PaWK5ByX9MOoDadSN4lc=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 h1:tW1/Rkad38LA15X4UQtjXZXNKsCgkshC3EbmcUmghTg=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3/go.mod h1:UbnqO+zjqk3uIt9yCACHJ9IVNhyhOCnYk8yA19SAWrM=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.27.27 h1:HdqgGt1OAP0HkEDDShEl0oSYa9ZZBSOmKpdpsDMdO90=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.27.27/go.mod h1:MVYamCg76dFNINkZFu4n4RjDixhVr51HLj4ErWzrVwg=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.27 h1:2raNba6gr2IfA0eqqiP2XiQ0UVOpGPgDSi0I9iAP+UI=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.27/go.mod h1:gniiwbGahQByxan6YjQUMcW4Aov6bLC3m+evgcoN4r4=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11 h1:KreluoV8FZDEtI6Co2xuNk/UqI9iwMrOx/87PBNIKqw=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11/go.mod h1:SeSUYBLsMYFoRvHE0Tjvn7kbxaUhl75CJi1sbfhMxkU=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15 h1:SoNJ4RlFEQEbtDcCEt+QG56MY4fm4W8rYirAmq+/DdU=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15/go.mod h1:U9ke74k1n2bf+RIgoX1SXFed1HLs51OgUSs+Ph0KJP8=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15 h1:C6WHdGnTDIYETAm5iErQUiVNsclNx9qbJVPIt03B6bI=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15/go.mod h1:ZQLZqhcu+JhSrA9/NXRm8SkDvsycE+JkV3WGY41e+IM=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3 h1:dT3MqvGhSoaIhRseqw2I0yH81l7wiR2vjs57O51EAm8=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3/go.mod h1:GlAeCkHwugxdHaueRr4nhPuY+WW+gR8UjlcqzPr1SPI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17 h1:HGErhhrxZlQ044RiM+WdoZxp0p+EGM62y3L6pwA4olE=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17/go.mod h1:RkZEx4l0EHYDJpWppMJ3nD9wZJAa8/0lq9aVC+r2UII=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.22.4 h1:BXx0ZIxvrJdSgSvKTZ+yRBeSqqgPM89VPlulEcl37tM=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.22.4/go.mod h1:ooyCOXjvJEsUw7x+ZDHeISPMhtwI3ZCB7ggFMcFfWLU=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 h1:yiwVzJW2ZxZTurVbYWA7QOrAaCYQR72t0wrSBfoesUE=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4/go.mod h1:0oxfLkpz3rQ/CHlx5hB7H69YUpFiI1tql6Q6Ne+1bCw=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 h1:ZsDKRLXGWHk8WdtyYMoGNO7bTudrvuKpDKgMVRlepGE=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.30.3/go.mod h1:zwySh8fpFyXp9yOr/KVzxOl8SRqgf/IDw5aUt9UKFcQ=
|
||||
github.com/aws/smithy-go v1.20.3 h1:ryHwveWzPV5BIof6fyDvor6V3iUL7nTfiTKXHiW05nE=
|
||||
github.com/aws/smithy-go v1.20.3/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
|
||||
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38=
|
||||
github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
|
||||
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||
github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE=
|
||||
github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU=
|
||||
github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
|
||||
github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||
github.com/charmbracelet/glamour v0.9.1 h1:11dEfiGP8q1BEqvGoIjivuc2rBk+5qEXdPtaQ2WoiCM=
|
||||
github.com/charmbracelet/glamour v0.9.1/go.mod h1:+SHvIS8qnwhgTpVMiXwn7OfGomSqff1cHBCI8jLOetk=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
|
||||
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
||||
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
|
||||
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
|
||||
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
|
||||
github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
|
||||
github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q=
|
||||
github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231 h1:9rjt7AfnrXKNSZhp36A3/4QAZAwGGCGD/p8Bse26zms=
|
||||
github.com/lrstanley/bubblezone v0.0.0-20250315020633-c249a3fe1231/go.mod h1:S5etECMx+sZnW0Gm100Ma9J1PgVCTgNyFaqGu2b08b4=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mark3labs/mcp-go v0.17.0 h1:5Ps6T7qXr7De/2QTqs9h6BKeZ/qdeUeGrgM5lPzi930=
|
||||
github.com/mark3labs/mcp-go v0.17.0/go.mod h1:KmJndYv7GIgcPVwEKJjNcbhVQ+hJGJhrCCB/9xITzpE=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
||||
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/ncruces/go-sqlite3 v0.25.0 h1:trugKUs98Zwy9KwRr/EUxZHL92LYt7UqcKqAfpGpK+I=
|
||||
github.com/ncruces/go-sqlite3 v0.25.0/go.mod h1:n6Z7036yFilJx04yV0mi5JWaF66rUmXn1It9Ux8dx68=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
|
||||
github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g=
|
||||
github.com/openai/openai-go v0.1.0-beta.2 h1:Ra5nCFkbEl9w+UJwAciC4kqnIBUCcJazhmMA0/YN894=
|
||||
github.com/openai/openai-go v0.1.0-beta.2/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pressly/goose/v3 v3.24.2 h1:c/ie0Gm8rnIVKvnDQ/scHErv46jrDv9b4I0WRcFJzYU=
|
||||
github.com/pressly/goose/v3 v3.24.2/go.mod h1:kjefwFB0eR4w30Td2Gj2Mznyw94vSP+2jJYkOVNbD1k=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
|
||||
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
|
||||
github.com/sebdah/goldie/v2 v2.5.3 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y=
|
||||
github.com/sebdah/goldie/v2 v2.5.3/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
|
||||
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
|
||||
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
||||
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||
github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
|
||||
github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4=
|
||||
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
||||
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.20.0 h1:zrxIyR3RQIOsarIrgL8+sAvALXul9jeEPa06Y0Ph6vY=
|
||||
github.com/spf13/viper v1.20.0/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
|
||||
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
|
||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
|
||||
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
|
||||
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
|
||||
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
|
||||
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
|
||||
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
|
||||
go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A=
|
||||
go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w=
|
||||
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
|
||||
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
|
||||
golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
||||
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
|
||||
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/genai v1.3.0 h1:tXhPJF30skOjnnDY7ZnjK3q7IKy4PuAlEA0fk7uEaEI=
|
||||
google.golang.org/genai v1.3.0/go.mod h1:TyfOKRz/QyCaj6f/ZDt505x+YreXnY40l2I6k8TvgqY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 h1:e0AIkUUhxyBKh6ssZNrAMeqhA7RKUj42346d1y02i2g=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||
google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg=
|
||||
google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8=
|
||||
modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g=
|
||||
modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.36.2 h1:vjcSazuoFve9Wm0IVNHgmJECoOXLZM1KfMXbcX2axHA=
|
||||
modernc.org/sqlite v1.36.2/go.mod h1:ADySlx7K4FdY5MaJcEv86hTJ0PjedAloTUuif0YS3ws=
|
||||
48
infra/app.ts
Normal file
48
infra/app.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
export const domain = (() => {
|
||||
if ($app.stage === "production") return "opencode.ai"
|
||||
if ($app.stage === "dev") return "dev.opencode.ai"
|
||||
return `${$app.stage}.dev.opencode.ai`
|
||||
})()
|
||||
|
||||
const GITHUB_APP_ID = new sst.Secret("GITHUB_APP_ID")
|
||||
const GITHUB_APP_PRIVATE_KEY = new sst.Secret("GITHUB_APP_PRIVATE_KEY")
|
||||
const bucket = new sst.cloudflare.Bucket("Bucket")
|
||||
|
||||
export const api = new sst.cloudflare.Worker("Api", {
|
||||
domain: `api.${domain}`,
|
||||
handler: "packages/function/src/api.ts",
|
||||
environment: {
|
||||
WEB_DOMAIN: domain,
|
||||
},
|
||||
url: true,
|
||||
link: [bucket, GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY],
|
||||
transform: {
|
||||
worker: (args) => {
|
||||
args.logpush = true
|
||||
args.bindings = $resolve(args.bindings).apply((bindings) => [
|
||||
...bindings,
|
||||
{
|
||||
name: "SYNC_SERVER",
|
||||
type: "durable_object_namespace",
|
||||
className: "SyncServer",
|
||||
},
|
||||
])
|
||||
args.migrations = {
|
||||
// Note: when releasing the next tag, make sure all stages use tag v2
|
||||
oldTag: $app.stage === "production" ? "" : "v1",
|
||||
newTag: $app.stage === "production" ? "" : "v1",
|
||||
//newSqliteClasses: ["SyncServer"],
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
new sst.cloudflare.x.Astro("Web", {
|
||||
domain,
|
||||
path: "packages/web",
|
||||
environment: {
|
||||
// For astro config
|
||||
SST_STAGE: $app.stage,
|
||||
VITE_API_URL: api.url,
|
||||
},
|
||||
})
|
||||
28
install
28
install
@@ -12,23 +12,28 @@ requested_version=${VERSION:-}
|
||||
|
||||
os=$(uname -s | tr '[:upper:]' '[:lower:]')
|
||||
if [[ "$os" == "darwin" ]]; then
|
||||
os="mac"
|
||||
os="darwin"
|
||||
fi
|
||||
arch=$(uname -m)
|
||||
|
||||
if [[ "$arch" == "aarch64" ]]; then
|
||||
arch="arm64"
|
||||
elif [[ "$arch" == "x86_64" ]]; then
|
||||
arch="x64"
|
||||
fi
|
||||
|
||||
filename="$APP-$os-$arch.tar.gz"
|
||||
filename="$APP-$os-$arch.zip"
|
||||
|
||||
|
||||
case "$filename" in
|
||||
*"-linux-"*)
|
||||
[[ "$arch" == "x86_64" || "$arch" == "arm64" || "$arch" == "i386" ]] || exit 1
|
||||
[[ "$arch" == "x64" || "$arch" == "arm64" ]] || exit 1
|
||||
;;
|
||||
*"-mac-"*)
|
||||
[[ "$arch" == "x86_64" || "$arch" == "arm64" ]] || exit 1
|
||||
*"-darwin-"*)
|
||||
[[ "$arch" == "x64" || "$arch" == "arm64" ]] || exit 1
|
||||
;;
|
||||
*"-windows-"*)
|
||||
[[ "$arch" == "x64" ]] || exit 1
|
||||
;;
|
||||
*)
|
||||
echo "${RED}Unsupported OS/Arch: $os/$arch${NC}"
|
||||
@@ -43,7 +48,7 @@ if [ -z "$requested_version" ]; then
|
||||
url="https://github.com/sst/opencode/releases/latest/download/$filename"
|
||||
specific_version=$(curl -s https://api.github.com/repos/sst/opencode/releases/latest | awk -F'"' '/"tag_name": "/ {gsub(/^v/, "", $4); print $4}')
|
||||
|
||||
if [[ $? -ne 0 ]]; then
|
||||
if [[ $? -ne 0 || -z "$specific_version" ]]; then
|
||||
echo "${RED}Failed to fetch version information${NC}"
|
||||
exit 1
|
||||
fi
|
||||
@@ -88,8 +93,9 @@ check_version() {
|
||||
download_and_install() {
|
||||
print_message info "Downloading ${ORANGE}opencode ${GREEN}version: ${YELLOW}$specific_version ${GREEN}..."
|
||||
mkdir -p opencodetmp && cd opencodetmp
|
||||
curl -# -L $url | tar xz
|
||||
mv opencode $INSTALL_DIR
|
||||
curl -# -L -o "$filename" "$url"
|
||||
unzip -q "$filename"
|
||||
mv opencode "$INSTALL_DIR"
|
||||
cd .. && rm -rf opencodetmp
|
||||
}
|
||||
|
||||
@@ -101,7 +107,9 @@ add_to_path() {
|
||||
local config_file=$1
|
||||
local command=$2
|
||||
|
||||
if [[ -w $config_file ]]; then
|
||||
if grep -Fxq "$command" "$config_file"; then
|
||||
print_message info "Command already exists in $config_file, skipping write."
|
||||
elif [[ -w $config_file ]]; then
|
||||
echo -e "\n# opencode" >> "$config_file"
|
||||
echo "$command" >> "$config_file"
|
||||
print_message info "Successfully added ${ORANGE}opencode ${GREEN}to \$PATH in $config_file"
|
||||
@@ -167,6 +175,7 @@ if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then
|
||||
add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
|
||||
;;
|
||||
*)
|
||||
export PATH=$INSTALL_DIR:$PATH
|
||||
print_message warning "Manually add the directory to $config_file (or similar):"
|
||||
print_message info " export PATH=$INSTALL_DIR:\$PATH"
|
||||
;;
|
||||
@@ -177,4 +186,3 @@ if [ -n "${GITHUB_ACTIONS-}" ] && [ "${GITHUB_ACTIONS}" == "true" ]; then
|
||||
echo "$INSTALL_DIR" >> $GITHUB_PATH
|
||||
print_message info "Added $INSTALL_DIR to \$GITHUB_PATH"
|
||||
fi
|
||||
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"maps"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"log/slog"
|
||||
|
||||
"github.com/sst/opencode/internal/config"
|
||||
"github.com/sst/opencode/internal/history"
|
||||
"github.com/sst/opencode/internal/llm/agent"
|
||||
"github.com/sst/opencode/internal/logging"
|
||||
"github.com/sst/opencode/internal/lsp"
|
||||
"github.com/sst/opencode/internal/message"
|
||||
"github.com/sst/opencode/internal/permission"
|
||||
"github.com/sst/opencode/internal/session"
|
||||
"github.com/sst/opencode/internal/status"
|
||||
"github.com/sst/opencode/internal/tui/theme"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
CurrentSession *session.Session
|
||||
Logs logging.Service
|
||||
Sessions session.Service
|
||||
Messages message.Service
|
||||
History history.Service
|
||||
Permissions permission.Service
|
||||
Status status.Service
|
||||
|
||||
PrimaryAgent agent.Service
|
||||
|
||||
LSPClients map[string]*lsp.Client
|
||||
|
||||
clientsMutex sync.RWMutex
|
||||
|
||||
watcherCancelFuncs []context.CancelFunc
|
||||
cancelFuncsMutex sync.Mutex
|
||||
watcherWG sync.WaitGroup
|
||||
}
|
||||
|
||||
func New(ctx context.Context, conn *sql.DB) (*App, error) {
|
||||
err := logging.InitService(conn)
|
||||
if err != nil {
|
||||
slog.Error("Failed to initialize logging service", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
err = session.InitService(conn)
|
||||
if err != nil {
|
||||
slog.Error("Failed to initialize session service", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
err = message.InitService(conn)
|
||||
if err != nil {
|
||||
slog.Error("Failed to initialize message service", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
err = history.InitService(conn)
|
||||
if err != nil {
|
||||
slog.Error("Failed to initialize history service", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
err = permission.InitService()
|
||||
if err != nil {
|
||||
slog.Error("Failed to initialize permission service", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
err = status.InitService()
|
||||
if err != nil {
|
||||
slog.Error("Failed to initialize status service", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
app := &App{
|
||||
CurrentSession: &session.Session{},
|
||||
Logs: logging.GetService(),
|
||||
Sessions: session.GetService(),
|
||||
Messages: message.GetService(),
|
||||
History: history.GetService(),
|
||||
Permissions: permission.GetService(),
|
||||
Status: status.GetService(),
|
||||
LSPClients: make(map[string]*lsp.Client),
|
||||
}
|
||||
|
||||
// Initialize theme based on configuration
|
||||
app.initTheme()
|
||||
|
||||
// Initialize LSP clients in the background
|
||||
go app.initLSPClients(ctx)
|
||||
|
||||
app.PrimaryAgent, err = agent.NewAgent(
|
||||
config.AgentPrimary,
|
||||
app.Sessions,
|
||||
app.Messages,
|
||||
agent.PrimaryAgentTools(
|
||||
app.Permissions,
|
||||
app.Sessions,
|
||||
app.Messages,
|
||||
app.History,
|
||||
app.LSPClients,
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("Failed to create primary agent", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return app, nil
|
||||
}
|
||||
|
||||
// initTheme sets the application theme based on the configuration
|
||||
func (app *App) initTheme() {
|
||||
cfg := config.Get()
|
||||
if cfg == nil || cfg.TUI.Theme == "" {
|
||||
return // Use default theme
|
||||
}
|
||||
|
||||
// Try to set the theme from config
|
||||
err := theme.SetTheme(cfg.TUI.Theme)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to set theme from config, using default theme", "theme", cfg.TUI.Theme, "error", err)
|
||||
} else {
|
||||
slog.Debug("Set theme from config", "theme", cfg.TUI.Theme)
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown performs a clean shutdown of the application
|
||||
func (app *App) Shutdown() {
|
||||
// Cancel all watcher goroutines
|
||||
app.cancelFuncsMutex.Lock()
|
||||
for _, cancel := range app.watcherCancelFuncs {
|
||||
cancel()
|
||||
}
|
||||
app.cancelFuncsMutex.Unlock()
|
||||
app.watcherWG.Wait()
|
||||
|
||||
// Perform additional cleanup for LSP clients
|
||||
app.clientsMutex.RLock()
|
||||
clients := make(map[string]*lsp.Client, len(app.LSPClients))
|
||||
maps.Copy(clients, app.LSPClients)
|
||||
app.clientsMutex.RUnlock()
|
||||
|
||||
for name, client := range clients {
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
if err := client.Shutdown(shutdownCtx); err != nil {
|
||||
slog.Error("Failed to shutdown LSP client", "name", name, "error", err)
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"log/slog"
|
||||
|
||||
"github.com/sst/opencode/internal/config"
|
||||
"github.com/sst/opencode/internal/logging"
|
||||
"github.com/sst/opencode/internal/lsp"
|
||||
"github.com/sst/opencode/internal/lsp/watcher"
|
||||
)
|
||||
|
||||
func (app *App) initLSPClients(ctx context.Context) {
|
||||
cfg := config.Get()
|
||||
|
||||
// Initialize LSP clients
|
||||
for name, clientConfig := range cfg.LSP {
|
||||
// Start each client initialization in its own goroutine
|
||||
go app.createAndStartLSPClient(ctx, name, clientConfig.Command, clientConfig.Args...)
|
||||
}
|
||||
slog.Info("LSP clients initialization started in background")
|
||||
}
|
||||
|
||||
// createAndStartLSPClient creates a new LSP client, initializes it, and starts its workspace watcher
|
||||
func (app *App) createAndStartLSPClient(ctx context.Context, name string, command string, args ...string) {
|
||||
// Create a specific context for initialization with a timeout
|
||||
slog.Info("Creating LSP client", "name", name, "command", command, "args", args)
|
||||
|
||||
// Create the LSP client
|
||||
lspClient, err := lsp.NewClient(ctx, command, args...)
|
||||
if err != nil {
|
||||
slog.Error("Failed to create LSP client for", name, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Create a longer timeout for initialization (some servers take time to start)
|
||||
initCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Initialize with the initialization context
|
||||
_, err = lspClient.InitializeLSPClient(initCtx, config.WorkingDirectory())
|
||||
if err != nil {
|
||||
slog.Error("Initialize failed", "name", name, "error", err)
|
||||
// Clean up the client to prevent resource leaks
|
||||
lspClient.Close()
|
||||
return
|
||||
}
|
||||
|
||||
// Wait for the server to be ready
|
||||
if err := lspClient.WaitForServerReady(initCtx); err != nil {
|
||||
slog.Error("Server failed to become ready", "name", name, "error", err)
|
||||
// We'll continue anyway, as some functionality might still work
|
||||
lspClient.SetServerState(lsp.StateError)
|
||||
} else {
|
||||
slog.Info("LSP server is ready", "name", name)
|
||||
lspClient.SetServerState(lsp.StateReady)
|
||||
}
|
||||
|
||||
slog.Info("LSP client initialized", "name", name)
|
||||
|
||||
// Create a child context that can be canceled when the app is shutting down
|
||||
watchCtx, cancelFunc := context.WithCancel(ctx)
|
||||
|
||||
// Create a context with the server name for better identification
|
||||
watchCtx = context.WithValue(watchCtx, "serverName", name)
|
||||
|
||||
// Create the workspace watcher
|
||||
workspaceWatcher := watcher.NewWorkspaceWatcher(lspClient)
|
||||
|
||||
// Store the cancel function to be called during cleanup
|
||||
app.cancelFuncsMutex.Lock()
|
||||
app.watcherCancelFuncs = append(app.watcherCancelFuncs, cancelFunc)
|
||||
app.cancelFuncsMutex.Unlock()
|
||||
|
||||
// Add the watcher to a WaitGroup to track active goroutines
|
||||
app.watcherWG.Add(1)
|
||||
|
||||
// Add to map with mutex protection before starting goroutine
|
||||
app.clientsMutex.Lock()
|
||||
app.LSPClients[name] = lspClient
|
||||
app.clientsMutex.Unlock()
|
||||
|
||||
go app.runWorkspaceWatcher(watchCtx, name, workspaceWatcher)
|
||||
}
|
||||
|
||||
// runWorkspaceWatcher executes the workspace watcher for an LSP client
|
||||
func (app *App) runWorkspaceWatcher(ctx context.Context, name string, workspaceWatcher *watcher.WorkspaceWatcher) {
|
||||
defer app.watcherWG.Done()
|
||||
defer logging.RecoverPanic("LSP-"+name, func() {
|
||||
// Try to restart the client
|
||||
app.restartLSPClient(ctx, name)
|
||||
})
|
||||
|
||||
workspaceWatcher.WatchWorkspace(ctx, config.WorkingDirectory())
|
||||
slog.Info("Workspace watcher stopped", "client", name)
|
||||
}
|
||||
|
||||
// restartLSPClient attempts to restart a crashed or failed LSP client
|
||||
func (app *App) restartLSPClient(ctx context.Context, name string) {
|
||||
// Get the original configuration
|
||||
cfg := config.Get()
|
||||
clientConfig, exists := cfg.LSP[name]
|
||||
if !exists {
|
||||
slog.Error("Cannot restart client, configuration not found", "client", name)
|
||||
return
|
||||
}
|
||||
|
||||
// Clean up the old client if it exists
|
||||
app.clientsMutex.Lock()
|
||||
oldClient, exists := app.LSPClients[name]
|
||||
if exists {
|
||||
delete(app.LSPClients, name) // Remove from map before potentially slow shutdown
|
||||
}
|
||||
app.clientsMutex.Unlock()
|
||||
|
||||
if exists && oldClient != nil {
|
||||
// Try to shut it down gracefully, but don't block on errors
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
_ = oldClient.Shutdown(shutdownCtx)
|
||||
cancel()
|
||||
|
||||
// Ensure we close the client to free resources
|
||||
_ = oldClient.Close()
|
||||
}
|
||||
|
||||
// Wait a moment before restarting to avoid rapid restart cycles
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
// Create a new client using the shared function
|
||||
app.createAndStartLSPClient(ctx, name, clientConfig.Command, clientConfig.Args...)
|
||||
slog.Info("Successfully restarted LSP client", "client", name)
|
||||
}
|
||||
@@ -1,797 +0,0 @@
|
||||
// Package config manages application configuration from various sources.
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
"github.com/sst/opencode/internal/llm/models"
|
||||
)
|
||||
|
||||
// MCPType defines the type of MCP (Model Control Protocol) server.
|
||||
type MCPType string
|
||||
|
||||
// Supported MCP types
|
||||
const (
|
||||
MCPStdio MCPType = "stdio"
|
||||
MCPSse MCPType = "sse"
|
||||
)
|
||||
|
||||
// MCPServer defines the configuration for a Model Control Protocol server.
|
||||
type MCPServer struct {
|
||||
Command string `json:"command"`
|
||||
Env []string `json:"env"`
|
||||
Args []string `json:"args"`
|
||||
Type MCPType `json:"type"`
|
||||
URL string `json:"url"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
}
|
||||
|
||||
type AgentName string
|
||||
|
||||
const (
|
||||
AgentPrimary AgentName = "primary"
|
||||
AgentTask AgentName = "task"
|
||||
AgentTitle AgentName = "title"
|
||||
)
|
||||
|
||||
// Agent defines configuration for different LLM models and their token limits.
|
||||
type Agent struct {
|
||||
Model models.ModelID `json:"model"`
|
||||
MaxTokens int64 `json:"maxTokens"`
|
||||
ReasoningEffort string `json:"reasoningEffort"` // For openai models low,medium,heigh
|
||||
}
|
||||
|
||||
// Provider defines configuration for an LLM provider.
|
||||
type Provider struct {
|
||||
APIKey string `json:"apiKey"`
|
||||
Disabled bool `json:"disabled"`
|
||||
}
|
||||
|
||||
// Data defines storage configuration.
|
||||
type Data struct {
|
||||
Directory string `json:"directory"`
|
||||
}
|
||||
|
||||
// LSPConfig defines configuration for Language Server Protocol integration.
|
||||
type LSPConfig struct {
|
||||
Disabled bool `json:"enabled"`
|
||||
Command string `json:"command"`
|
||||
Args []string `json:"args"`
|
||||
Options any `json:"options"`
|
||||
}
|
||||
|
||||
// TUIConfig defines the configuration for the Terminal User Interface.
|
||||
type TUIConfig struct {
|
||||
Theme string `json:"theme,omitempty"`
|
||||
CustomTheme map[string]any `json:"customTheme,omitempty"`
|
||||
}
|
||||
|
||||
// Config is the main configuration structure for the application.
|
||||
type Config struct {
|
||||
Data Data `json:"data"`
|
||||
WorkingDir string `json:"wd,omitempty"`
|
||||
MCPServers map[string]MCPServer `json:"mcpServers,omitempty"`
|
||||
Providers map[models.ModelProvider]Provider `json:"providers,omitempty"`
|
||||
LSP map[string]LSPConfig `json:"lsp,omitempty"`
|
||||
Agents map[AgentName]Agent `json:"agents"`
|
||||
Debug bool `json:"debug,omitempty"`
|
||||
DebugLSP bool `json:"debugLSP,omitempty"`
|
||||
ContextPaths []string `json:"contextPaths,omitempty"`
|
||||
TUI TUIConfig `json:"tui"`
|
||||
}
|
||||
|
||||
// Application constants
|
||||
const (
|
||||
defaultDataDirectory = ".opencode"
|
||||
defaultLogLevel = "info"
|
||||
appName = "opencode"
|
||||
|
||||
MaxTokensFallbackDefault = 4096
|
||||
)
|
||||
|
||||
var defaultContextPaths = []string{
|
||||
".github/copilot-instructions.md",
|
||||
".cursorrules",
|
||||
".cursor/rules/",
|
||||
"CLAUDE.md",
|
||||
"CLAUDE.local.md",
|
||||
"CONTEXT.md",
|
||||
"CONTEXT.local.md",
|
||||
"opencode.md",
|
||||
"opencode.local.md",
|
||||
"OpenCode.md",
|
||||
"OpenCode.local.md",
|
||||
"OPENCODE.md",
|
||||
"OPENCODE.local.md",
|
||||
}
|
||||
|
||||
// Global configuration instance
|
||||
var cfg *Config
|
||||
|
||||
// Load initializes the configuration from environment variables and config files.
|
||||
// If debug is true, debug mode is enabled and log level is set to debug.
|
||||
// It returns an error if configuration loading fails.
|
||||
func Load(workingDir string, debug bool, lvl *slog.LevelVar) (*Config, error) {
|
||||
if cfg != nil {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
cfg = &Config{
|
||||
WorkingDir: workingDir,
|
||||
MCPServers: make(map[string]MCPServer),
|
||||
Providers: make(map[models.ModelProvider]Provider),
|
||||
LSP: make(map[string]LSPConfig),
|
||||
}
|
||||
|
||||
configureViper()
|
||||
setDefaults(debug)
|
||||
|
||||
// Read global config
|
||||
if err := readConfig(viper.ReadInConfig()); err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
|
||||
// Load and merge local config
|
||||
mergeLocalConfig(workingDir)
|
||||
|
||||
setProviderDefaults()
|
||||
|
||||
// Apply configuration to the struct
|
||||
if err := viper.Unmarshal(cfg); err != nil {
|
||||
return cfg, fmt.Errorf("failed to unmarshal config: %w", err)
|
||||
}
|
||||
|
||||
applyDefaultValues()
|
||||
|
||||
defaultLevel := slog.LevelInfo
|
||||
if cfg.Debug {
|
||||
defaultLevel = slog.LevelDebug
|
||||
}
|
||||
lvl.Set(defaultLevel)
|
||||
slog.SetLogLoggerLevel(defaultLevel)
|
||||
|
||||
// Validate configuration
|
||||
if err := Validate(); err != nil {
|
||||
return cfg, fmt.Errorf("config validation failed: %w", err)
|
||||
}
|
||||
|
||||
if cfg.Agents == nil {
|
||||
cfg.Agents = make(map[AgentName]Agent)
|
||||
}
|
||||
|
||||
// Override the max tokens for title agent
|
||||
cfg.Agents[AgentTitle] = Agent{
|
||||
Model: cfg.Agents[AgentTitle].Model,
|
||||
MaxTokens: 80,
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// configureViper sets up viper's configuration paths and environment variables.
|
||||
func configureViper() {
|
||||
viper.SetConfigName(fmt.Sprintf(".%s", appName))
|
||||
viper.SetConfigType("json")
|
||||
viper.AddConfigPath("$HOME")
|
||||
viper.AddConfigPath(fmt.Sprintf("$XDG_CONFIG_HOME/%s", appName))
|
||||
viper.AddConfigPath(fmt.Sprintf("$HOME/.config/%s", appName))
|
||||
viper.SetEnvPrefix(strings.ToUpper(appName))
|
||||
viper.AutomaticEnv()
|
||||
}
|
||||
|
||||
// setDefaults configures default values for configuration options.
|
||||
func setDefaults(debug bool) {
|
||||
viper.SetDefault("data.directory", defaultDataDirectory)
|
||||
viper.SetDefault("contextPaths", defaultContextPaths)
|
||||
viper.SetDefault("tui.theme", "opencode")
|
||||
|
||||
if debug {
|
||||
viper.SetDefault("debug", true)
|
||||
viper.Set("log.level", "debug")
|
||||
} else {
|
||||
viper.SetDefault("debug", false)
|
||||
viper.SetDefault("log.level", defaultLogLevel)
|
||||
}
|
||||
}
|
||||
|
||||
// setProviderDefaults configures LLM provider defaults based on provider provided by
|
||||
// environment variables and configuration file.
|
||||
func setProviderDefaults() {
|
||||
// Set all API keys we can find in the environment
|
||||
if apiKey := os.Getenv("ANTHROPIC_API_KEY"); apiKey != "" {
|
||||
viper.SetDefault("providers.anthropic.apiKey", apiKey)
|
||||
}
|
||||
if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" {
|
||||
viper.SetDefault("providers.openai.apiKey", apiKey)
|
||||
}
|
||||
if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
|
||||
viper.SetDefault("providers.gemini.apiKey", apiKey)
|
||||
}
|
||||
if apiKey := os.Getenv("GROQ_API_KEY"); apiKey != "" {
|
||||
viper.SetDefault("providers.groq.apiKey", apiKey)
|
||||
}
|
||||
if apiKey := os.Getenv("OPENROUTER_API_KEY"); apiKey != "" {
|
||||
viper.SetDefault("providers.openrouter.apiKey", apiKey)
|
||||
}
|
||||
if apiKey := os.Getenv("XAI_API_KEY"); apiKey != "" {
|
||||
viper.SetDefault("providers.xai.apiKey", apiKey)
|
||||
}
|
||||
if apiKey := os.Getenv("AZURE_OPENAI_ENDPOINT"); apiKey != "" {
|
||||
// api-key may be empty when using Entra ID credentials – that's okay
|
||||
viper.SetDefault("providers.azure.apiKey", os.Getenv("AZURE_OPENAI_API_KEY"))
|
||||
}
|
||||
|
||||
// Use this order to set the default models
|
||||
// 1. Anthropic
|
||||
// 2. OpenAI
|
||||
// 3. Google Gemini
|
||||
// 4. Groq
|
||||
// 5. OpenRouter
|
||||
// 6. AWS Bedrock
|
||||
// 7. Azure
|
||||
|
||||
// Anthropic configuration
|
||||
if key := viper.GetString("providers.anthropic.apiKey"); strings.TrimSpace(key) != "" {
|
||||
viper.SetDefault("agents.primary.model", models.Claude37Sonnet)
|
||||
viper.SetDefault("agents.task.model", models.Claude37Sonnet)
|
||||
viper.SetDefault("agents.title.model", models.Claude37Sonnet)
|
||||
return
|
||||
}
|
||||
|
||||
// OpenAI configuration
|
||||
if key := viper.GetString("providers.openai.apiKey"); strings.TrimSpace(key) != "" {
|
||||
viper.SetDefault("agents.primary.model", models.GPT41)
|
||||
viper.SetDefault("agents.task.model", models.GPT41Mini)
|
||||
viper.SetDefault("agents.title.model", models.GPT41Mini)
|
||||
return
|
||||
}
|
||||
|
||||
// Google Gemini configuration
|
||||
if key := viper.GetString("providers.gemini.apiKey"); strings.TrimSpace(key) != "" {
|
||||
viper.SetDefault("agents.primary.model", models.Gemini25)
|
||||
viper.SetDefault("agents.task.model", models.Gemini25Flash)
|
||||
viper.SetDefault("agents.title.model", models.Gemini25Flash)
|
||||
return
|
||||
}
|
||||
|
||||
// Groq configuration
|
||||
if key := viper.GetString("providers.groq.apiKey"); strings.TrimSpace(key) != "" {
|
||||
viper.SetDefault("agents.primary.model", models.QWENQwq)
|
||||
viper.SetDefault("agents.task.model", models.QWENQwq)
|
||||
viper.SetDefault("agents.title.model", models.QWENQwq)
|
||||
return
|
||||
}
|
||||
|
||||
// OpenRouter configuration
|
||||
if key := viper.GetString("providers.openrouter.apiKey"); strings.TrimSpace(key) != "" {
|
||||
viper.SetDefault("agents.primary.model", models.OpenRouterClaude37Sonnet)
|
||||
viper.SetDefault("agents.task.model", models.OpenRouterClaude37Sonnet)
|
||||
viper.SetDefault("agents.title.model", models.OpenRouterClaude35Haiku)
|
||||
return
|
||||
}
|
||||
|
||||
// XAI configuration
|
||||
if key := viper.GetString("providers.xai.apiKey"); strings.TrimSpace(key) != "" {
|
||||
viper.SetDefault("agents.primary.model", models.XAIGrok3Beta)
|
||||
viper.SetDefault("agents.task.model", models.XAIGrok3Beta)
|
||||
viper.SetDefault("agents.title.model", models.XAiGrok3MiniFastBeta)
|
||||
return
|
||||
}
|
||||
|
||||
// AWS Bedrock configuration
|
||||
if hasAWSCredentials() {
|
||||
viper.SetDefault("agents.primary.model", models.BedrockClaude37Sonnet)
|
||||
viper.SetDefault("agents.task.model", models.BedrockClaude37Sonnet)
|
||||
viper.SetDefault("agents.title.model", models.BedrockClaude37Sonnet)
|
||||
return
|
||||
}
|
||||
|
||||
// Azure OpenAI configuration
|
||||
if os.Getenv("AZURE_OPENAI_ENDPOINT") != "" {
|
||||
viper.SetDefault("agents.primary.model", models.AzureGPT41)
|
||||
viper.SetDefault("agents.task.model", models.AzureGPT41Mini)
|
||||
viper.SetDefault("agents.title.model", models.AzureGPT41Mini)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// hasAWSCredentials checks if AWS credentials are available in the environment.
|
||||
func hasAWSCredentials() bool {
|
||||
// Check for explicit AWS credentials
|
||||
if os.Getenv("AWS_ACCESS_KEY_ID") != "" && os.Getenv("AWS_SECRET_ACCESS_KEY") != "" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for AWS profile
|
||||
if os.Getenv("AWS_PROFILE") != "" || os.Getenv("AWS_DEFAULT_PROFILE") != "" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for AWS region
|
||||
if os.Getenv("AWS_REGION") != "" || os.Getenv("AWS_DEFAULT_REGION") != "" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if running on EC2 with instance profile
|
||||
if os.Getenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") != "" ||
|
||||
os.Getenv("AWS_CONTAINER_CREDENTIALS_FULL_URI") != "" {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// readConfig handles the result of reading a configuration file.
|
||||
func readConfig(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// It's okay if the config file doesn't exist
|
||||
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("failed to read config: %w", err)
|
||||
}
|
||||
|
||||
// mergeLocalConfig loads and merges configuration from the local directory.
|
||||
func mergeLocalConfig(workingDir string) {
|
||||
local := viper.New()
|
||||
local.SetConfigName(fmt.Sprintf(".%s", appName))
|
||||
local.SetConfigType("json")
|
||||
local.AddConfigPath(workingDir)
|
||||
|
||||
// Merge local config if it exists
|
||||
if err := local.ReadInConfig(); err == nil {
|
||||
viper.MergeConfigMap(local.AllSettings())
|
||||
}
|
||||
}
|
||||
|
||||
// applyDefaultValues sets default values for configuration fields that need processing.
|
||||
func applyDefaultValues() {
|
||||
// Set default MCP type if not specified
|
||||
for k, v := range cfg.MCPServers {
|
||||
if v.Type == "" {
|
||||
v.Type = MCPStdio
|
||||
cfg.MCPServers[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// It validates model IDs and providers, ensuring they are supported.
|
||||
func validateAgent(cfg *Config, name AgentName, agent Agent) error {
|
||||
// Check if model exists
|
||||
model, modelExists := models.SupportedModels[agent.Model]
|
||||
if !modelExists {
|
||||
slog.Warn("unsupported model configured, reverting to default",
|
||||
"agent", name,
|
||||
"configured_model", agent.Model)
|
||||
|
||||
// Set default model based on available providers
|
||||
if setDefaultModelForAgent(name) {
|
||||
slog.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model)
|
||||
} else {
|
||||
return fmt.Errorf("no valid provider available for agent %s", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if provider for the model is configured
|
||||
provider := model.Provider
|
||||
providerCfg, providerExists := cfg.Providers[provider]
|
||||
|
||||
if !providerExists {
|
||||
// Provider not configured, check if we have environment variables
|
||||
apiKey := getProviderAPIKey(provider)
|
||||
if apiKey == "" {
|
||||
slog.Warn("provider not configured for model, reverting to default",
|
||||
"agent", name,
|
||||
"model", agent.Model,
|
||||
"provider", provider)
|
||||
|
||||
// Set default model based on available providers
|
||||
if setDefaultModelForAgent(name) {
|
||||
slog.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model)
|
||||
} else {
|
||||
return fmt.Errorf("no valid provider available for agent %s", name)
|
||||
}
|
||||
} else {
|
||||
// Add provider with API key from environment
|
||||
cfg.Providers[provider] = Provider{
|
||||
APIKey: apiKey,
|
||||
}
|
||||
slog.Info("added provider from environment", "provider", provider)
|
||||
}
|
||||
} else if providerCfg.Disabled || providerCfg.APIKey == "" {
|
||||
// Provider is disabled or has no API key
|
||||
slog.Warn("provider is disabled or has no API key, reverting to default",
|
||||
"agent", name,
|
||||
"model", agent.Model,
|
||||
"provider", provider)
|
||||
|
||||
// Set default model based on available providers
|
||||
if setDefaultModelForAgent(name) {
|
||||
slog.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model)
|
||||
} else {
|
||||
return fmt.Errorf("no valid provider available for agent %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate max tokens
|
||||
if agent.MaxTokens <= 0 {
|
||||
slog.Warn("invalid max tokens, setting to default",
|
||||
"agent", name,
|
||||
"model", agent.Model,
|
||||
"max_tokens", agent.MaxTokens)
|
||||
|
||||
// Update the agent with default max tokens
|
||||
updatedAgent := cfg.Agents[name]
|
||||
if model.DefaultMaxTokens > 0 {
|
||||
updatedAgent.MaxTokens = model.DefaultMaxTokens
|
||||
} else {
|
||||
updatedAgent.MaxTokens = MaxTokensFallbackDefault
|
||||
}
|
||||
cfg.Agents[name] = updatedAgent
|
||||
} else if model.ContextWindow > 0 && agent.MaxTokens > model.ContextWindow/2 {
|
||||
// Ensure max tokens doesn't exceed half the context window (reasonable limit)
|
||||
slog.Warn("max tokens exceeds half the context window, adjusting",
|
||||
"agent", name,
|
||||
"model", agent.Model,
|
||||
"max_tokens", agent.MaxTokens,
|
||||
"context_window", model.ContextWindow)
|
||||
|
||||
// Update the agent with adjusted max tokens
|
||||
updatedAgent := cfg.Agents[name]
|
||||
updatedAgent.MaxTokens = model.ContextWindow / 2
|
||||
cfg.Agents[name] = updatedAgent
|
||||
}
|
||||
|
||||
// Validate reasoning effort for models that support reasoning
|
||||
if model.CanReason && provider == models.ProviderOpenAI {
|
||||
if agent.ReasoningEffort == "" {
|
||||
// Set default reasoning effort for models that support it
|
||||
slog.Info("setting default reasoning effort for model that supports reasoning",
|
||||
"agent", name,
|
||||
"model", agent.Model)
|
||||
|
||||
// Update the agent with default reasoning effort
|
||||
updatedAgent := cfg.Agents[name]
|
||||
updatedAgent.ReasoningEffort = "medium"
|
||||
cfg.Agents[name] = updatedAgent
|
||||
} else {
|
||||
// Check if reasoning effort is valid (low, medium, high)
|
||||
effort := strings.ToLower(agent.ReasoningEffort)
|
||||
if effort != "low" && effort != "medium" && effort != "high" {
|
||||
slog.Warn("invalid reasoning effort, setting to medium",
|
||||
"agent", name,
|
||||
"model", agent.Model,
|
||||
"reasoning_effort", agent.ReasoningEffort)
|
||||
|
||||
// Update the agent with valid reasoning effort
|
||||
updatedAgent := cfg.Agents[name]
|
||||
updatedAgent.ReasoningEffort = "medium"
|
||||
cfg.Agents[name] = updatedAgent
|
||||
}
|
||||
}
|
||||
} else if !model.CanReason && agent.ReasoningEffort != "" {
|
||||
// Model doesn't support reasoning but reasoning effort is set
|
||||
slog.Warn("model doesn't support reasoning but reasoning effort is set, ignoring",
|
||||
"agent", name,
|
||||
"model", agent.Model,
|
||||
"reasoning_effort", agent.ReasoningEffort)
|
||||
|
||||
// Update the agent to remove reasoning effort
|
||||
updatedAgent := cfg.Agents[name]
|
||||
updatedAgent.ReasoningEffort = ""
|
||||
cfg.Agents[name] = updatedAgent
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate checks if the configuration is valid and applies defaults where needed.
|
||||
func Validate() error {
|
||||
if cfg == nil {
|
||||
return fmt.Errorf("config not loaded")
|
||||
}
|
||||
|
||||
// Validate agent models
|
||||
for name, agent := range cfg.Agents {
|
||||
if err := validateAgent(cfg, name, agent); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Validate providers
|
||||
for provider, providerCfg := range cfg.Providers {
|
||||
if providerCfg.APIKey == "" && !providerCfg.Disabled {
|
||||
slog.Warn("provider has no API key, marking as disabled", "provider", provider)
|
||||
providerCfg.Disabled = true
|
||||
cfg.Providers[provider] = providerCfg
|
||||
}
|
||||
}
|
||||
|
||||
// Validate LSP configurations
|
||||
for language, lspConfig := range cfg.LSP {
|
||||
if lspConfig.Command == "" && !lspConfig.Disabled {
|
||||
slog.Warn("LSP configuration has no command, marking as disabled", "language", language)
|
||||
lspConfig.Disabled = true
|
||||
cfg.LSP[language] = lspConfig
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getProviderAPIKey gets the API key for a provider from environment variables
|
||||
func getProviderAPIKey(provider models.ModelProvider) string {
|
||||
switch provider {
|
||||
case models.ProviderAnthropic:
|
||||
return os.Getenv("ANTHROPIC_API_KEY")
|
||||
case models.ProviderOpenAI:
|
||||
return os.Getenv("OPENAI_API_KEY")
|
||||
case models.ProviderGemini:
|
||||
return os.Getenv("GEMINI_API_KEY")
|
||||
case models.ProviderGROQ:
|
||||
return os.Getenv("GROQ_API_KEY")
|
||||
case models.ProviderAzure:
|
||||
return os.Getenv("AZURE_OPENAI_API_KEY")
|
||||
case models.ProviderOpenRouter:
|
||||
return os.Getenv("OPENROUTER_API_KEY")
|
||||
case models.ProviderBedrock:
|
||||
if hasAWSCredentials() {
|
||||
return "aws-credentials-available"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// setDefaultModelForAgent sets a default model for an agent based on available providers
|
||||
func setDefaultModelForAgent(agent AgentName) bool {
|
||||
// Check providers in order of preference
|
||||
if apiKey := os.Getenv("ANTHROPIC_API_KEY"); apiKey != "" {
|
||||
maxTokens := int64(5000)
|
||||
if agent == AgentTitle {
|
||||
maxTokens = 80
|
||||
}
|
||||
cfg.Agents[agent] = Agent{
|
||||
Model: models.Claude37Sonnet,
|
||||
MaxTokens: maxTokens,
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" {
|
||||
var model models.ModelID
|
||||
maxTokens := int64(5000)
|
||||
reasoningEffort := ""
|
||||
|
||||
switch agent {
|
||||
case AgentTitle:
|
||||
model = models.GPT41Mini
|
||||
maxTokens = 80
|
||||
case AgentTask:
|
||||
model = models.GPT41Mini
|
||||
default:
|
||||
model = models.GPT41
|
||||
}
|
||||
|
||||
// Check if model supports reasoning
|
||||
if modelInfo, ok := models.SupportedModels[model]; ok && modelInfo.CanReason {
|
||||
reasoningEffort = "medium"
|
||||
}
|
||||
|
||||
cfg.Agents[agent] = Agent{
|
||||
Model: model,
|
||||
MaxTokens: maxTokens,
|
||||
ReasoningEffort: reasoningEffort,
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if apiKey := os.Getenv("OPENROUTER_API_KEY"); apiKey != "" {
|
||||
var model models.ModelID
|
||||
maxTokens := int64(5000)
|
||||
reasoningEffort := ""
|
||||
|
||||
switch agent {
|
||||
case AgentTitle:
|
||||
model = models.OpenRouterClaude35Haiku
|
||||
maxTokens = 80
|
||||
case AgentTask:
|
||||
model = models.OpenRouterClaude37Sonnet
|
||||
default:
|
||||
model = models.OpenRouterClaude37Sonnet
|
||||
}
|
||||
|
||||
// Check if model supports reasoning
|
||||
if modelInfo, ok := models.SupportedModels[model]; ok && modelInfo.CanReason {
|
||||
reasoningEffort = "medium"
|
||||
}
|
||||
|
||||
cfg.Agents[agent] = Agent{
|
||||
Model: model,
|
||||
MaxTokens: maxTokens,
|
||||
ReasoningEffort: reasoningEffort,
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
|
||||
var model models.ModelID
|
||||
maxTokens := int64(5000)
|
||||
|
||||
if agent == AgentTitle {
|
||||
model = models.Gemini25Flash
|
||||
maxTokens = 80
|
||||
} else {
|
||||
model = models.Gemini25
|
||||
}
|
||||
|
||||
cfg.Agents[agent] = Agent{
|
||||
Model: model,
|
||||
MaxTokens: maxTokens,
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if apiKey := os.Getenv("GROQ_API_KEY"); apiKey != "" {
|
||||
maxTokens := int64(5000)
|
||||
if agent == AgentTitle {
|
||||
maxTokens = 80
|
||||
}
|
||||
|
||||
cfg.Agents[agent] = Agent{
|
||||
Model: models.QWENQwq,
|
||||
MaxTokens: maxTokens,
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if hasAWSCredentials() {
|
||||
maxTokens := int64(5000)
|
||||
if agent == AgentTitle {
|
||||
maxTokens = 80
|
||||
}
|
||||
|
||||
cfg.Agents[agent] = Agent{
|
||||
Model: models.BedrockClaude37Sonnet,
|
||||
MaxTokens: maxTokens,
|
||||
ReasoningEffort: "medium", // Claude models support reasoning
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Get returns the current configuration.
|
||||
// It's safe to call this function multiple times.
|
||||
func Get() *Config {
|
||||
return cfg
|
||||
}
|
||||
|
||||
// WorkingDirectory returns the current working directory from the configuration.
|
||||
func WorkingDirectory() string {
|
||||
if cfg == nil {
|
||||
panic("config not loaded")
|
||||
}
|
||||
return cfg.WorkingDir
|
||||
}
|
||||
|
||||
// GetHostname returns the system hostname or "User" if it can't be determined
|
||||
func GetHostname() (string, error) {
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil {
|
||||
return "User", err
|
||||
}
|
||||
return hostname, nil
|
||||
}
|
||||
|
||||
// GetUsername returns the current user's username
|
||||
func GetUsername() (string, error) {
|
||||
currentUser, err := user.Current()
|
||||
if err != nil {
|
||||
return "User", err
|
||||
}
|
||||
return currentUser.Username, nil
|
||||
}
|
||||
|
||||
func UpdateAgentModel(agentName AgentName, modelID models.ModelID) error {
|
||||
if cfg == nil {
|
||||
panic("config not loaded")
|
||||
}
|
||||
|
||||
existingAgentCfg := cfg.Agents[agentName]
|
||||
|
||||
model, ok := models.SupportedModels[modelID]
|
||||
if !ok {
|
||||
return fmt.Errorf("model %s not supported", modelID)
|
||||
}
|
||||
|
||||
maxTokens := existingAgentCfg.MaxTokens
|
||||
if model.DefaultMaxTokens > 0 {
|
||||
maxTokens = model.DefaultMaxTokens
|
||||
}
|
||||
|
||||
newAgentCfg := Agent{
|
||||
Model: modelID,
|
||||
MaxTokens: maxTokens,
|
||||
ReasoningEffort: existingAgentCfg.ReasoningEffort,
|
||||
}
|
||||
cfg.Agents[agentName] = newAgentCfg
|
||||
|
||||
if err := validateAgent(cfg, agentName, newAgentCfg); err != nil {
|
||||
// revert config update on failure
|
||||
cfg.Agents[agentName] = existingAgentCfg
|
||||
return fmt.Errorf("failed to update agent model: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateTheme updates the theme in the configuration and writes it to the config file.
|
||||
func UpdateTheme(themeName string) error {
|
||||
if cfg == nil {
|
||||
return fmt.Errorf("config not loaded")
|
||||
}
|
||||
|
||||
// Update the in-memory config
|
||||
cfg.TUI.Theme = themeName
|
||||
|
||||
// Get the config file path
|
||||
configFile := viper.ConfigFileUsed()
|
||||
var configData []byte
|
||||
if configFile == "" {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get home directory: %w", err)
|
||||
}
|
||||
configFile = filepath.Join(homeDir, fmt.Sprintf(".%s.json", appName))
|
||||
slog.Info("config file not found, creating new one", "path", configFile)
|
||||
configData = []byte(`{}`)
|
||||
} else {
|
||||
// Read the existing config file
|
||||
data, err := os.ReadFile(configFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read config file: %w", err)
|
||||
}
|
||||
configData = data
|
||||
}
|
||||
|
||||
// Parse the JSON
|
||||
var configMap map[string]any
|
||||
if err := json.Unmarshal(configData, &configMap); err != nil {
|
||||
return fmt.Errorf("failed to parse config file: %w", err)
|
||||
}
|
||||
|
||||
// Update just the theme value
|
||||
tuiConfig, ok := configMap["tui"].(map[string]any)
|
||||
if !ok {
|
||||
// TUI config doesn't exist yet, create it
|
||||
configMap["tui"] = map[string]any{"theme": themeName}
|
||||
} else {
|
||||
// Update existing TUI config
|
||||
tuiConfig["theme"] = themeName
|
||||
configMap["tui"] = tuiConfig
|
||||
}
|
||||
|
||||
// Write the updated config back to file
|
||||
updatedData, err := json.MarshalIndent(configMap, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal config: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(configFile, updatedData, 0o644); err != nil {
|
||||
return fmt.Errorf("failed to write config file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
const (
|
||||
// InitFlagFilename is the name of the file that indicates whether the project has been initialized
|
||||
InitFlagFilename = "init"
|
||||
)
|
||||
|
||||
// ProjectInitFlag represents the initialization status for a project directory
|
||||
type ProjectInitFlag struct {
|
||||
Initialized bool `json:"initialized"`
|
||||
}
|
||||
|
||||
// ShouldShowInitDialog checks if the initialization dialog should be shown for the current directory
|
||||
func ShouldShowInitDialog() (bool, error) {
|
||||
if cfg == nil {
|
||||
return false, fmt.Errorf("config not loaded")
|
||||
}
|
||||
|
||||
// Create the flag file path
|
||||
flagFilePath := filepath.Join(cfg.Data.Directory, InitFlagFilename)
|
||||
|
||||
// Check if the flag file exists
|
||||
_, err := os.Stat(flagFilePath)
|
||||
if err == nil {
|
||||
// File exists, don't show the dialog
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// If the error is not "file not found", return the error
|
||||
if !os.IsNotExist(err) {
|
||||
return false, fmt.Errorf("failed to check init flag file: %w", err)
|
||||
}
|
||||
|
||||
// File doesn't exist, show the dialog
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// MarkProjectInitialized marks the current project as initialized
|
||||
func MarkProjectInitialized() error {
|
||||
if cfg == nil {
|
||||
return fmt.Errorf("config not loaded")
|
||||
}
|
||||
// Create the flag file path
|
||||
flagFilePath := filepath.Join(cfg.Data.Directory, InitFlagFilename)
|
||||
|
||||
// Create an empty file to mark the project as initialized
|
||||
file, err := os.Create(flagFilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create init flag file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
_ "github.com/ncruces/go-sqlite3/driver"
|
||||
_ "github.com/ncruces/go-sqlite3/embed"
|
||||
|
||||
"github.com/sst/opencode/internal/config"
|
||||
"log/slog"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func Connect() (*sql.DB, error) {
|
||||
dataDir := config.Get().Data.Directory
|
||||
if dataDir == "" {
|
||||
return nil, fmt.Errorf("data.dir is not set")
|
||||
}
|
||||
if err := os.MkdirAll(dataDir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("failed to create data directory: %w", err)
|
||||
}
|
||||
dbPath := filepath.Join(dataDir, "opencode.db")
|
||||
// Open the SQLite database
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
|
||||
// Verify connection
|
||||
if err = db.Ping(); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
|
||||
// Set pragmas for better performance
|
||||
pragmas := []string{
|
||||
"PRAGMA foreign_keys = ON;",
|
||||
"PRAGMA journal_mode = WAL;",
|
||||
"PRAGMA page_size = 4096;",
|
||||
"PRAGMA cache_size = -8000;",
|
||||
"PRAGMA synchronous = NORMAL;",
|
||||
}
|
||||
|
||||
for _, pragma := range pragmas {
|
||||
if _, err = db.Exec(pragma); err != nil {
|
||||
slog.Error("Failed to set pragma", pragma, err)
|
||||
} else {
|
||||
slog.Debug("Set pragma", "pragma", pragma)
|
||||
}
|
||||
}
|
||||
|
||||
goose.SetBaseFS(FS)
|
||||
|
||||
if err := goose.SetDialect("sqlite3"); err != nil {
|
||||
slog.Error("Failed to set dialect", "error", err)
|
||||
return nil, fmt.Errorf("failed to set dialect: %w", err)
|
||||
}
|
||||
|
||||
if err := goose.Up(db, "migrations"); err != nil {
|
||||
slog.Error("Failed to apply migrations", "error", err)
|
||||
return nil, fmt.Errorf("failed to apply migrations: %w", err)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
@@ -1,328 +0,0 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type DBTX interface {
|
||||
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
|
||||
PrepareContext(context.Context, string) (*sql.Stmt, error)
|
||||
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
|
||||
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
|
||||
}
|
||||
|
||||
func New(db DBTX) *Queries {
|
||||
return &Queries{db: db}
|
||||
}
|
||||
|
||||
func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
|
||||
q := Queries{db: db}
|
||||
var err error
|
||||
if q.createFileStmt, err = db.PrepareContext(ctx, createFile); err != nil {
|
||||
return nil, fmt.Errorf("error preparing query CreateFile: %w", err)
|
||||
}
|
||||
if q.createLogStmt, err = db.PrepareContext(ctx, createLog); err != nil {
|
||||
return nil, fmt.Errorf("error preparing query CreateLog: %w", err)
|
||||
}
|
||||
if q.createMessageStmt, err = db.PrepareContext(ctx, createMessage); err != nil {
|
||||
return nil, fmt.Errorf("error preparing query CreateMessage: %w", err)
|
||||
}
|
||||
if q.createSessionStmt, err = db.PrepareContext(ctx, createSession); err != nil {
|
||||
return nil, fmt.Errorf("error preparing query CreateSession: %w", err)
|
||||
}
|
||||
if q.deleteFileStmt, err = db.PrepareContext(ctx, deleteFile); err != nil {
|
||||
return nil, fmt.Errorf("error preparing query DeleteFile: %w", err)
|
||||
}
|
||||
if q.deleteMessageStmt, err = db.PrepareContext(ctx, deleteMessage); err != nil {
|
||||
return nil, fmt.Errorf("error preparing query DeleteMessage: %w", err)
|
||||
}
|
||||
if q.deleteSessionStmt, err = db.PrepareContext(ctx, deleteSession); err != nil {
|
||||
return nil, fmt.Errorf("error preparing query DeleteSession: %w", err)
|
||||
}
|
||||
if q.deleteSessionFilesStmt, err = db.PrepareContext(ctx, deleteSessionFiles); err != nil {
|
||||
return nil, fmt.Errorf("error preparing query DeleteSessionFiles: %w", err)
|
||||
}
|
||||
if q.deleteSessionMessagesStmt, err = db.PrepareContext(ctx, deleteSessionMessages); err != nil {
|
||||
return nil, fmt.Errorf("error preparing query DeleteSessionMessages: %w", err)
|
||||
}
|
||||
if q.getFileStmt, err = db.PrepareContext(ctx, getFile); err != nil {
|
||||
return nil, fmt.Errorf("error preparing query GetFile: %w", err)
|
||||
}
|
||||
if q.getFileByPathAndSessionStmt, err = db.PrepareContext(ctx, getFileByPathAndSession); err != nil {
|
||||
return nil, fmt.Errorf("error preparing query GetFileByPathAndSession: %w", err)
|
||||
}
|
||||
if q.getMessageStmt, err = db.PrepareContext(ctx, getMessage); err != nil {
|
||||
return nil, fmt.Errorf("error preparing query GetMessage: %w", err)
|
||||
}
|
||||
if q.getSessionByIDStmt, err = db.PrepareContext(ctx, getSessionByID); err != nil {
|
||||
return nil, fmt.Errorf("error preparing query GetSessionByID: %w", err)
|
||||
}
|
||||
if q.listAllLogsStmt, err = db.PrepareContext(ctx, listAllLogs); err != nil {
|
||||
return nil, fmt.Errorf("error preparing query ListAllLogs: %w", err)
|
||||
}
|
||||
if q.listFilesByPathStmt, err = db.PrepareContext(ctx, listFilesByPath); err != nil {
|
||||
return nil, fmt.Errorf("error preparing query ListFilesByPath: %w", err)
|
||||
}
|
||||
if q.listFilesBySessionStmt, err = db.PrepareContext(ctx, listFilesBySession); err != nil {
|
||||
return nil, fmt.Errorf("error preparing query ListFilesBySession: %w", err)
|
||||
}
|
||||
if q.listLatestSessionFilesStmt, err = db.PrepareContext(ctx, listLatestSessionFiles); err != nil {
|
||||
return nil, fmt.Errorf("error preparing query ListLatestSessionFiles: %w", err)
|
||||
}
|
||||
if q.listLogsBySessionStmt, err = db.PrepareContext(ctx, listLogsBySession); err != nil {
|
||||
return nil, fmt.Errorf("error preparing query ListLogsBySession: %w", err)
|
||||
}
|
||||
if q.listMessagesBySessionStmt, err = db.PrepareContext(ctx, listMessagesBySession); err != nil {
|
||||
return nil, fmt.Errorf("error preparing query ListMessagesBySession: %w", err)
|
||||
}
|
||||
if q.listMessagesBySessionAfterStmt, err = db.PrepareContext(ctx, listMessagesBySessionAfter); err != nil {
|
||||
return nil, fmt.Errorf("error preparing query ListMessagesBySessionAfter: %w", err)
|
||||
}
|
||||
if q.listNewFilesStmt, err = db.PrepareContext(ctx, listNewFiles); err != nil {
|
||||
return nil, fmt.Errorf("error preparing query ListNewFiles: %w", err)
|
||||
}
|
||||
if q.listSessionsStmt, err = db.PrepareContext(ctx, listSessions); err != nil {
|
||||
return nil, fmt.Errorf("error preparing query ListSessions: %w", err)
|
||||
}
|
||||
if q.updateFileStmt, err = db.PrepareContext(ctx, updateFile); err != nil {
|
||||
return nil, fmt.Errorf("error preparing query UpdateFile: %w", err)
|
||||
}
|
||||
if q.updateMessageStmt, err = db.PrepareContext(ctx, updateMessage); err != nil {
|
||||
return nil, fmt.Errorf("error preparing query UpdateMessage: %w", err)
|
||||
}
|
||||
if q.updateSessionStmt, err = db.PrepareContext(ctx, updateSession); err != nil {
|
||||
return nil, fmt.Errorf("error preparing query UpdateSession: %w", err)
|
||||
}
|
||||
return &q, nil
|
||||
}
|
||||
|
||||
func (q *Queries) Close() error {
|
||||
var err error
|
||||
if q.createFileStmt != nil {
|
||||
if cerr := q.createFileStmt.Close(); cerr != nil {
|
||||
err = fmt.Errorf("error closing createFileStmt: %w", cerr)
|
||||
}
|
||||
}
|
||||
if q.createLogStmt != nil {
|
||||
if cerr := q.createLogStmt.Close(); cerr != nil {
|
||||
err = fmt.Errorf("error closing createLogStmt: %w", cerr)
|
||||
}
|
||||
}
|
||||
if q.createMessageStmt != nil {
|
||||
if cerr := q.createMessageStmt.Close(); cerr != nil {
|
||||
err = fmt.Errorf("error closing createMessageStmt: %w", cerr)
|
||||
}
|
||||
}
|
||||
if q.createSessionStmt != nil {
|
||||
if cerr := q.createSessionStmt.Close(); cerr != nil {
|
||||
err = fmt.Errorf("error closing createSessionStmt: %w", cerr)
|
||||
}
|
||||
}
|
||||
if q.deleteFileStmt != nil {
|
||||
if cerr := q.deleteFileStmt.Close(); cerr != nil {
|
||||
err = fmt.Errorf("error closing deleteFileStmt: %w", cerr)
|
||||
}
|
||||
}
|
||||
if q.deleteMessageStmt != nil {
|
||||
if cerr := q.deleteMessageStmt.Close(); cerr != nil {
|
||||
err = fmt.Errorf("error closing deleteMessageStmt: %w", cerr)
|
||||
}
|
||||
}
|
||||
if q.deleteSessionStmt != nil {
|
||||
if cerr := q.deleteSessionStmt.Close(); cerr != nil {
|
||||
err = fmt.Errorf("error closing deleteSessionStmt: %w", cerr)
|
||||
}
|
||||
}
|
||||
if q.deleteSessionFilesStmt != nil {
|
||||
if cerr := q.deleteSessionFilesStmt.Close(); cerr != nil {
|
||||
err = fmt.Errorf("error closing deleteSessionFilesStmt: %w", cerr)
|
||||
}
|
||||
}
|
||||
if q.deleteSessionMessagesStmt != nil {
|
||||
if cerr := q.deleteSessionMessagesStmt.Close(); cerr != nil {
|
||||
err = fmt.Errorf("error closing deleteSessionMessagesStmt: %w", cerr)
|
||||
}
|
||||
}
|
||||
if q.getFileStmt != nil {
|
||||
if cerr := q.getFileStmt.Close(); cerr != nil {
|
||||
err = fmt.Errorf("error closing getFileStmt: %w", cerr)
|
||||
}
|
||||
}
|
||||
if q.getFileByPathAndSessionStmt != nil {
|
||||
if cerr := q.getFileByPathAndSessionStmt.Close(); cerr != nil {
|
||||
err = fmt.Errorf("error closing getFileByPathAndSessionStmt: %w", cerr)
|
||||
}
|
||||
}
|
||||
if q.getMessageStmt != nil {
|
||||
if cerr := q.getMessageStmt.Close(); cerr != nil {
|
||||
err = fmt.Errorf("error closing getMessageStmt: %w", cerr)
|
||||
}
|
||||
}
|
||||
if q.getSessionByIDStmt != nil {
|
||||
if cerr := q.getSessionByIDStmt.Close(); cerr != nil {
|
||||
err = fmt.Errorf("error closing getSessionByIDStmt: %w", cerr)
|
||||
}
|
||||
}
|
||||
if q.listAllLogsStmt != nil {
|
||||
if cerr := q.listAllLogsStmt.Close(); cerr != nil {
|
||||
err = fmt.Errorf("error closing listAllLogsStmt: %w", cerr)
|
||||
}
|
||||
}
|
||||
if q.listFilesByPathStmt != nil {
|
||||
if cerr := q.listFilesByPathStmt.Close(); cerr != nil {
|
||||
err = fmt.Errorf("error closing listFilesByPathStmt: %w", cerr)
|
||||
}
|
||||
}
|
||||
if q.listFilesBySessionStmt != nil {
|
||||
if cerr := q.listFilesBySessionStmt.Close(); cerr != nil {
|
||||
err = fmt.Errorf("error closing listFilesBySessionStmt: %w", cerr)
|
||||
}
|
||||
}
|
||||
if q.listLatestSessionFilesStmt != nil {
|
||||
if cerr := q.listLatestSessionFilesStmt.Close(); cerr != nil {
|
||||
err = fmt.Errorf("error closing listLatestSessionFilesStmt: %w", cerr)
|
||||
}
|
||||
}
|
||||
if q.listLogsBySessionStmt != nil {
|
||||
if cerr := q.listLogsBySessionStmt.Close(); cerr != nil {
|
||||
err = fmt.Errorf("error closing listLogsBySessionStmt: %w", cerr)
|
||||
}
|
||||
}
|
||||
if q.listMessagesBySessionStmt != nil {
|
||||
if cerr := q.listMessagesBySessionStmt.Close(); cerr != nil {
|
||||
err = fmt.Errorf("error closing listMessagesBySessionStmt: %w", cerr)
|
||||
}
|
||||
}
|
||||
if q.listMessagesBySessionAfterStmt != nil {
|
||||
if cerr := q.listMessagesBySessionAfterStmt.Close(); cerr != nil {
|
||||
err = fmt.Errorf("error closing listMessagesBySessionAfterStmt: %w", cerr)
|
||||
}
|
||||
}
|
||||
if q.listNewFilesStmt != nil {
|
||||
if cerr := q.listNewFilesStmt.Close(); cerr != nil {
|
||||
err = fmt.Errorf("error closing listNewFilesStmt: %w", cerr)
|
||||
}
|
||||
}
|
||||
if q.listSessionsStmt != nil {
|
||||
if cerr := q.listSessionsStmt.Close(); cerr != nil {
|
||||
err = fmt.Errorf("error closing listSessionsStmt: %w", cerr)
|
||||
}
|
||||
}
|
||||
if q.updateFileStmt != nil {
|
||||
if cerr := q.updateFileStmt.Close(); cerr != nil {
|
||||
err = fmt.Errorf("error closing updateFileStmt: %w", cerr)
|
||||
}
|
||||
}
|
||||
if q.updateMessageStmt != nil {
|
||||
if cerr := q.updateMessageStmt.Close(); cerr != nil {
|
||||
err = fmt.Errorf("error closing updateMessageStmt: %w", cerr)
|
||||
}
|
||||
}
|
||||
if q.updateSessionStmt != nil {
|
||||
if cerr := q.updateSessionStmt.Close(); cerr != nil {
|
||||
err = fmt.Errorf("error closing updateSessionStmt: %w", cerr)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (q *Queries) exec(ctx context.Context, stmt *sql.Stmt, query string, args ...interface{}) (sql.Result, error) {
|
||||
switch {
|
||||
case stmt != nil && q.tx != nil:
|
||||
return q.tx.StmtContext(ctx, stmt).ExecContext(ctx, args...)
|
||||
case stmt != nil:
|
||||
return stmt.ExecContext(ctx, args...)
|
||||
default:
|
||||
return q.db.ExecContext(ctx, query, args...)
|
||||
}
|
||||
}
|
||||
|
||||
func (q *Queries) query(ctx context.Context, stmt *sql.Stmt, query string, args ...interface{}) (*sql.Rows, error) {
|
||||
switch {
|
||||
case stmt != nil && q.tx != nil:
|
||||
return q.tx.StmtContext(ctx, stmt).QueryContext(ctx, args...)
|
||||
case stmt != nil:
|
||||
return stmt.QueryContext(ctx, args...)
|
||||
default:
|
||||
return q.db.QueryContext(ctx, query, args...)
|
||||
}
|
||||
}
|
||||
|
||||
func (q *Queries) queryRow(ctx context.Context, stmt *sql.Stmt, query string, args ...interface{}) *sql.Row {
|
||||
switch {
|
||||
case stmt != nil && q.tx != nil:
|
||||
return q.tx.StmtContext(ctx, stmt).QueryRowContext(ctx, args...)
|
||||
case stmt != nil:
|
||||
return stmt.QueryRowContext(ctx, args...)
|
||||
default:
|
||||
return q.db.QueryRowContext(ctx, query, args...)
|
||||
}
|
||||
}
|
||||
|
||||
type Queries struct {
|
||||
db DBTX
|
||||
tx *sql.Tx
|
||||
createFileStmt *sql.Stmt
|
||||
createLogStmt *sql.Stmt
|
||||
createMessageStmt *sql.Stmt
|
||||
createSessionStmt *sql.Stmt
|
||||
deleteFileStmt *sql.Stmt
|
||||
deleteMessageStmt *sql.Stmt
|
||||
deleteSessionStmt *sql.Stmt
|
||||
deleteSessionFilesStmt *sql.Stmt
|
||||
deleteSessionMessagesStmt *sql.Stmt
|
||||
getFileStmt *sql.Stmt
|
||||
getFileByPathAndSessionStmt *sql.Stmt
|
||||
getMessageStmt *sql.Stmt
|
||||
getSessionByIDStmt *sql.Stmt
|
||||
listAllLogsStmt *sql.Stmt
|
||||
listFilesByPathStmt *sql.Stmt
|
||||
listFilesBySessionStmt *sql.Stmt
|
||||
listLatestSessionFilesStmt *sql.Stmt
|
||||
listLogsBySessionStmt *sql.Stmt
|
||||
listMessagesBySessionStmt *sql.Stmt
|
||||
listMessagesBySessionAfterStmt *sql.Stmt
|
||||
listNewFilesStmt *sql.Stmt
|
||||
listSessionsStmt *sql.Stmt
|
||||
updateFileStmt *sql.Stmt
|
||||
updateMessageStmt *sql.Stmt
|
||||
updateSessionStmt *sql.Stmt
|
||||
}
|
||||
|
||||
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
|
||||
return &Queries{
|
||||
db: tx,
|
||||
tx: tx,
|
||||
createFileStmt: q.createFileStmt,
|
||||
createLogStmt: q.createLogStmt,
|
||||
createMessageStmt: q.createMessageStmt,
|
||||
createSessionStmt: q.createSessionStmt,
|
||||
deleteFileStmt: q.deleteFileStmt,
|
||||
deleteMessageStmt: q.deleteMessageStmt,
|
||||
deleteSessionStmt: q.deleteSessionStmt,
|
||||
deleteSessionFilesStmt: q.deleteSessionFilesStmt,
|
||||
deleteSessionMessagesStmt: q.deleteSessionMessagesStmt,
|
||||
getFileStmt: q.getFileStmt,
|
||||
getFileByPathAndSessionStmt: q.getFileByPathAndSessionStmt,
|
||||
getMessageStmt: q.getMessageStmt,
|
||||
getSessionByIDStmt: q.getSessionByIDStmt,
|
||||
listAllLogsStmt: q.listAllLogsStmt,
|
||||
listFilesByPathStmt: q.listFilesByPathStmt,
|
||||
listFilesBySessionStmt: q.listFilesBySessionStmt,
|
||||
listLatestSessionFilesStmt: q.listLatestSessionFilesStmt,
|
||||
listLogsBySessionStmt: q.listLogsBySessionStmt,
|
||||
listMessagesBySessionStmt: q.listMessagesBySessionStmt,
|
||||
listMessagesBySessionAfterStmt: q.listMessagesBySessionAfterStmt,
|
||||
listNewFilesStmt: q.listNewFilesStmt,
|
||||
listSessionsStmt: q.listSessionsStmt,
|
||||
updateFileStmt: q.updateFileStmt,
|
||||
updateMessageStmt: q.updateMessageStmt,
|
||||
updateSessionStmt: q.updateSessionStmt,
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
package db
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var FS embed.FS
|
||||
@@ -1,317 +0,0 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
// source: files.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const createFile = `-- name: CreateFile :one
|
||||
INSERT INTO files (
|
||||
id,
|
||||
session_id,
|
||||
path,
|
||||
content,
|
||||
version
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?
|
||||
)
|
||||
RETURNING id, session_id, path, content, version, is_new, created_at, updated_at
|
||||
`
|
||||
|
||||
type CreateFileParams struct {
|
||||
ID string `json:"id"`
|
||||
SessionID string `json:"session_id"`
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateFile(ctx context.Context, arg CreateFileParams) (File, error) {
|
||||
row := q.queryRow(ctx, q.createFileStmt, createFile,
|
||||
arg.ID,
|
||||
arg.SessionID,
|
||||
arg.Path,
|
||||
arg.Content,
|
||||
arg.Version,
|
||||
)
|
||||
var i File
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.SessionID,
|
||||
&i.Path,
|
||||
&i.Content,
|
||||
&i.Version,
|
||||
&i.IsNew,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteFile = `-- name: DeleteFile :exec
|
||||
DELETE FROM files
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteFile(ctx context.Context, id string) error {
|
||||
_, err := q.exec(ctx, q.deleteFileStmt, deleteFile, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteSessionFiles = `-- name: DeleteSessionFiles :exec
|
||||
DELETE FROM files
|
||||
WHERE session_id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteSessionFiles(ctx context.Context, sessionID string) error {
|
||||
_, err := q.exec(ctx, q.deleteSessionFilesStmt, deleteSessionFiles, sessionID)
|
||||
return err
|
||||
}
|
||||
|
||||
const getFile = `-- name: GetFile :one
|
||||
SELECT id, session_id, path, content, version, is_new, created_at, updated_at
|
||||
FROM files
|
||||
WHERE id = ? LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) GetFile(ctx context.Context, id string) (File, error) {
|
||||
row := q.queryRow(ctx, q.getFileStmt, getFile, id)
|
||||
var i File
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.SessionID,
|
||||
&i.Path,
|
||||
&i.Content,
|
||||
&i.Version,
|
||||
&i.IsNew,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getFileByPathAndSession = `-- name: GetFileByPathAndSession :one
|
||||
SELECT id, session_id, path, content, version, is_new, created_at, updated_at
|
||||
FROM files
|
||||
WHERE path = ? AND session_id = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
type GetFileByPathAndSessionParams struct {
|
||||
Path string `json:"path"`
|
||||
SessionID string `json:"session_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetFileByPathAndSession(ctx context.Context, arg GetFileByPathAndSessionParams) (File, error) {
|
||||
row := q.queryRow(ctx, q.getFileByPathAndSessionStmt, getFileByPathAndSession, arg.Path, arg.SessionID)
|
||||
var i File
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.SessionID,
|
||||
&i.Path,
|
||||
&i.Content,
|
||||
&i.Version,
|
||||
&i.IsNew,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listFilesByPath = `-- name: ListFilesByPath :many
|
||||
SELECT id, session_id, path, content, version, is_new, created_at, updated_at
|
||||
FROM files
|
||||
WHERE path = ?
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
func (q *Queries) ListFilesByPath(ctx context.Context, path string) ([]File, error) {
|
||||
rows, err := q.query(ctx, q.listFilesByPathStmt, listFilesByPath, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []File{}
|
||||
for rows.Next() {
|
||||
var i File
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.SessionID,
|
||||
&i.Path,
|
||||
&i.Content,
|
||||
&i.Version,
|
||||
&i.IsNew,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listFilesBySession = `-- name: ListFilesBySession :many
|
||||
SELECT id, session_id, path, content, version, is_new, created_at, updated_at
|
||||
FROM files
|
||||
WHERE session_id = ?
|
||||
ORDER BY created_at ASC
|
||||
`
|
||||
|
||||
func (q *Queries) ListFilesBySession(ctx context.Context, sessionID string) ([]File, error) {
|
||||
rows, err := q.query(ctx, q.listFilesBySessionStmt, listFilesBySession, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []File{}
|
||||
for rows.Next() {
|
||||
var i File
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.SessionID,
|
||||
&i.Path,
|
||||
&i.Content,
|
||||
&i.Version,
|
||||
&i.IsNew,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listLatestSessionFiles = `-- name: ListLatestSessionFiles :many
|
||||
SELECT f.id, f.session_id, f.path, f.content, f.version, f.is_new, f.created_at, f.updated_at
|
||||
FROM files f
|
||||
INNER JOIN (
|
||||
SELECT path, MAX(created_at) as max_created_at
|
||||
FROM files
|
||||
GROUP BY path
|
||||
) latest ON f.path = latest.path AND f.created_at = latest.max_created_at
|
||||
WHERE f.session_id = ?
|
||||
ORDER BY f.path
|
||||
`
|
||||
|
||||
func (q *Queries) ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error) {
|
||||
rows, err := q.query(ctx, q.listLatestSessionFilesStmt, listLatestSessionFiles, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []File{}
|
||||
for rows.Next() {
|
||||
var i File
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.SessionID,
|
||||
&i.Path,
|
||||
&i.Content,
|
||||
&i.Version,
|
||||
&i.IsNew,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listNewFiles = `-- name: ListNewFiles :many
|
||||
SELECT id, session_id, path, content, version, is_new, created_at, updated_at
|
||||
FROM files
|
||||
WHERE is_new = 1
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
func (q *Queries) ListNewFiles(ctx context.Context) ([]File, error) {
|
||||
rows, err := q.query(ctx, q.listNewFilesStmt, listNewFiles)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []File{}
|
||||
for rows.Next() {
|
||||
var i File
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.SessionID,
|
||||
&i.Path,
|
||||
&i.Content,
|
||||
&i.Version,
|
||||
&i.IsNew,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const updateFile = `-- name: UpdateFile :one
|
||||
UPDATE files
|
||||
SET
|
||||
content = ?,
|
||||
version = ?,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')
|
||||
WHERE id = ?
|
||||
RETURNING id, session_id, path, content, version, is_new, created_at, updated_at
|
||||
`
|
||||
|
||||
type UpdateFileParams struct {
|
||||
Content string `json:"content"`
|
||||
Version string `json:"version"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateFile(ctx context.Context, arg UpdateFileParams) (File, error) {
|
||||
row := q.queryRow(ctx, q.updateFileStmt, updateFile, arg.Content, arg.Version, arg.ID)
|
||||
var i File
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.SessionID,
|
||||
&i.Path,
|
||||
&i.Content,
|
||||
&i.Version,
|
||||
&i.IsNew,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
// source: logs.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
const createLog = `-- name: CreateLog :one
|
||||
INSERT INTO logs (
|
||||
id,
|
||||
session_id,
|
||||
timestamp,
|
||||
level,
|
||||
message,
|
||||
attributes
|
||||
) VALUES (
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?
|
||||
) RETURNING id, session_id, timestamp, level, message, attributes, created_at, updated_at
|
||||
`
|
||||
|
||||
type CreateLogParams struct {
|
||||
ID string `json:"id"`
|
||||
SessionID sql.NullString `json:"session_id"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Level string `json:"level"`
|
||||
Message string `json:"message"`
|
||||
Attributes sql.NullString `json:"attributes"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateLog(ctx context.Context, arg CreateLogParams) (Log, error) {
|
||||
row := q.queryRow(ctx, q.createLogStmt, createLog,
|
||||
arg.ID,
|
||||
arg.SessionID,
|
||||
arg.Timestamp,
|
||||
arg.Level,
|
||||
arg.Message,
|
||||
arg.Attributes,
|
||||
)
|
||||
var i Log
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.SessionID,
|
||||
&i.Timestamp,
|
||||
&i.Level,
|
||||
&i.Message,
|
||||
&i.Attributes,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listAllLogs = `-- name: ListAllLogs :many
|
||||
SELECT id, session_id, timestamp, level, message, attributes, created_at, updated_at FROM logs
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
`
|
||||
|
||||
func (q *Queries) ListAllLogs(ctx context.Context, limit int64) ([]Log, error) {
|
||||
rows, err := q.query(ctx, q.listAllLogsStmt, listAllLogs, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Log{}
|
||||
for rows.Next() {
|
||||
var i Log
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.SessionID,
|
||||
&i.Timestamp,
|
||||
&i.Level,
|
||||
&i.Message,
|
||||
&i.Attributes,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listLogsBySession = `-- name: ListLogsBySession :many
|
||||
SELECT id, session_id, timestamp, level, message, attributes, created_at, updated_at FROM logs
|
||||
WHERE session_id = ?
|
||||
ORDER BY timestamp DESC
|
||||
`
|
||||
|
||||
func (q *Queries) ListLogsBySession(ctx context.Context, sessionID sql.NullString) ([]Log, error) {
|
||||
rows, err := q.query(ctx, q.listLogsBySessionStmt, listLogsBySession, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Log{}
|
||||
for rows.Next() {
|
||||
var i Log
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.SessionID,
|
||||
&i.Timestamp,
|
||||
&i.Level,
|
||||
&i.Message,
|
||||
&i.Attributes,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
// source: messages.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
const createMessage = `-- name: CreateMessage :one
|
||||
INSERT INTO messages (
|
||||
id,
|
||||
session_id,
|
||||
role,
|
||||
parts,
|
||||
model
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?
|
||||
)
|
||||
RETURNING id, session_id, role, parts, model, created_at, updated_at, finished_at
|
||||
`
|
||||
|
||||
type CreateMessageParams struct {
|
||||
ID string `json:"id"`
|
||||
SessionID string `json:"session_id"`
|
||||
Role string `json:"role"`
|
||||
Parts string `json:"parts"`
|
||||
Model sql.NullString `json:"model"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (Message, error) {
|
||||
row := q.queryRow(ctx, q.createMessageStmt, createMessage,
|
||||
arg.ID,
|
||||
arg.SessionID,
|
||||
arg.Role,
|
||||
arg.Parts,
|
||||
arg.Model,
|
||||
)
|
||||
var i Message
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.SessionID,
|
||||
&i.Role,
|
||||
&i.Parts,
|
||||
&i.Model,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.FinishedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteMessage = `-- name: DeleteMessage :exec
|
||||
DELETE FROM messages
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteMessage(ctx context.Context, id string) error {
|
||||
_, err := q.exec(ctx, q.deleteMessageStmt, deleteMessage, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteSessionMessages = `-- name: DeleteSessionMessages :exec
|
||||
DELETE FROM messages
|
||||
WHERE session_id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteSessionMessages(ctx context.Context, sessionID string) error {
|
||||
_, err := q.exec(ctx, q.deleteSessionMessagesStmt, deleteSessionMessages, sessionID)
|
||||
return err
|
||||
}
|
||||
|
||||
const getMessage = `-- name: GetMessage :one
|
||||
SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at
|
||||
FROM messages
|
||||
WHERE id = ? LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) GetMessage(ctx context.Context, id string) (Message, error) {
|
||||
row := q.queryRow(ctx, q.getMessageStmt, getMessage, id)
|
||||
var i Message
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.SessionID,
|
||||
&i.Role,
|
||||
&i.Parts,
|
||||
&i.Model,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.FinishedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listMessagesBySession = `-- name: ListMessagesBySession :many
|
||||
SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at
|
||||
FROM messages
|
||||
WHERE session_id = ?
|
||||
ORDER BY created_at ASC
|
||||
`
|
||||
|
||||
func (q *Queries) ListMessagesBySession(ctx context.Context, sessionID string) ([]Message, error) {
|
||||
rows, err := q.query(ctx, q.listMessagesBySessionStmt, listMessagesBySession, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Message{}
|
||||
for rows.Next() {
|
||||
var i Message
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.SessionID,
|
||||
&i.Role,
|
||||
&i.Parts,
|
||||
&i.Model,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.FinishedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listMessagesBySessionAfter = `-- name: ListMessagesBySessionAfter :many
|
||||
SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at
|
||||
FROM messages
|
||||
WHERE session_id = ? AND created_at > ?
|
||||
ORDER BY created_at ASC
|
||||
`
|
||||
|
||||
type ListMessagesBySessionAfterParams struct {
|
||||
SessionID string `json:"session_id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListMessagesBySessionAfter(ctx context.Context, arg ListMessagesBySessionAfterParams) ([]Message, error) {
|
||||
rows, err := q.query(ctx, q.listMessagesBySessionAfterStmt, listMessagesBySessionAfter, arg.SessionID, arg.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Message{}
|
||||
for rows.Next() {
|
||||
var i Message
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.SessionID,
|
||||
&i.Role,
|
||||
&i.Parts,
|
||||
&i.Model,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.FinishedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const updateMessage = `-- name: UpdateMessage :exec
|
||||
UPDATE messages
|
||||
SET
|
||||
parts = ?,
|
||||
finished_at = ?,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
type UpdateMessageParams struct {
|
||||
Parts string `json:"parts"`
|
||||
FinishedAt sql.NullString `json:"finished_at"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateMessage(ctx context.Context, arg UpdateMessageParams) error {
|
||||
_, err := q.exec(ctx, q.updateMessageStmt, updateMessage, arg.Parts, arg.FinishedAt, arg.ID)
|
||||
return err
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
-- Sessions
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
parent_session_id TEXT,
|
||||
title TEXT NOT NULL,
|
||||
message_count INTEGER NOT NULL DEFAULT 0 CHECK (message_count >= 0),
|
||||
prompt_tokens INTEGER NOT NULL DEFAULT 0 CHECK (prompt_tokens >= 0),
|
||||
completion_tokens INTEGER NOT NULL DEFAULT 0 CHECK (completion_tokens >= 0),
|
||||
cost REAL NOT NULL DEFAULT 0.0 CHECK (cost >= 0.0),
|
||||
summary TEXT,
|
||||
summarized_at TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')),
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f000Z', 'now'))
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS update_sessions_updated_at
|
||||
AFTER UPDATE ON sessions
|
||||
BEGIN
|
||||
UPDATE sessions SET updated_at = strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')
|
||||
WHERE id = new.id;
|
||||
END;
|
||||
|
||||
-- Files
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
is_new INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')),
|
||||
FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE,
|
||||
UNIQUE(path, session_id, version)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_files_session_id ON files (session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_files_path ON files (path);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS update_files_updated_at
|
||||
AFTER UPDATE ON files
|
||||
BEGIN
|
||||
UPDATE files SET updated_at = strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')
|
||||
WHERE id = new.id;
|
||||
END;
|
||||
|
||||
-- Messages
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
parts TEXT NOT NULL default '[]',
|
||||
model TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')),
|
||||
finished_at TEXT,
|
||||
FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages (session_id);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS update_messages_updated_at
|
||||
AFTER UPDATE ON messages
|
||||
BEGIN
|
||||
UPDATE messages SET updated_at = strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')
|
||||
WHERE id = new.id;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS update_session_message_count_on_insert
|
||||
AFTER INSERT ON messages
|
||||
BEGIN
|
||||
UPDATE sessions SET
|
||||
message_count = message_count + 1
|
||||
WHERE id = new.session_id;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS update_session_message_count_on_delete
|
||||
AFTER DELETE ON messages
|
||||
BEGIN
|
||||
UPDATE sessions SET
|
||||
message_count = message_count - 1
|
||||
WHERE id = old.session_id;
|
||||
END;
|
||||
|
||||
-- Logs
|
||||
CREATE TABLE IF NOT EXISTS logs (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
timestamp TEXT NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
attributes TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f000Z', 'now'))
|
||||
);
|
||||
|
||||
CREATE INDEX logs_session_id_idx ON logs(session_id);
|
||||
CREATE INDEX logs_timestamp_idx ON logs(timestamp);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS update_logs_updated_at
|
||||
AFTER UPDATE ON logs
|
||||
BEGIN
|
||||
UPDATE logs SET updated_at = strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')
|
||||
WHERE id = new.id;
|
||||
END;
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TRIGGER IF EXISTS update_sessions_updated_at;
|
||||
DROP TRIGGER IF EXISTS update_messages_updated_at;
|
||||
DROP TRIGGER IF EXISTS update_files_updated_at;
|
||||
DROP TRIGGER IF EXISTS update_logs_updated_at;
|
||||
|
||||
DROP TRIGGER IF EXISTS update_session_message_count_on_delete;
|
||||
DROP TRIGGER IF EXISTS update_session_message_count_on_insert;
|
||||
|
||||
DROP TABLE IF EXISTS logs;
|
||||
DROP TABLE IF EXISTS messages;
|
||||
DROP TABLE IF EXISTS files;
|
||||
DROP TABLE IF EXISTS sessions;
|
||||
-- +goose StatementEnd
|
||||
@@ -1,56 +0,0 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
type File struct {
|
||||
ID string `json:"id"`
|
||||
SessionID string `json:"session_id"`
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
Version string `json:"version"`
|
||||
IsNew sql.NullInt64 `json:"is_new"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Log struct {
|
||||
ID string `json:"id"`
|
||||
SessionID sql.NullString `json:"session_id"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Level string `json:"level"`
|
||||
Message string `json:"message"`
|
||||
Attributes sql.NullString `json:"attributes"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
ID string `json:"id"`
|
||||
SessionID string `json:"session_id"`
|
||||
Role string `json:"role"`
|
||||
Parts string `json:"parts"`
|
||||
Model sql.NullString `json:"model"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
FinishedAt sql.NullString `json:"finished_at"`
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
ID string `json:"id"`
|
||||
ParentSessionID sql.NullString `json:"parent_session_id"`
|
||||
Title string `json:"title"`
|
||||
MessageCount int64 `json:"message_count"`
|
||||
PromptTokens int64 `json:"prompt_tokens"`
|
||||
CompletionTokens int64 `json:"completion_tokens"`
|
||||
Cost float64 `json:"cost"`
|
||||
Summary sql.NullString `json:"summary"`
|
||||
SummarizedAt sql.NullString `json:"summarized_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
type Querier interface {
|
||||
CreateFile(ctx context.Context, arg CreateFileParams) (File, error)
|
||||
CreateLog(ctx context.Context, arg CreateLogParams) (Log, error)
|
||||
CreateMessage(ctx context.Context, arg CreateMessageParams) (Message, error)
|
||||
CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error)
|
||||
DeleteFile(ctx context.Context, id string) error
|
||||
DeleteMessage(ctx context.Context, id string) error
|
||||
DeleteSession(ctx context.Context, id string) error
|
||||
DeleteSessionFiles(ctx context.Context, sessionID string) error
|
||||
DeleteSessionMessages(ctx context.Context, sessionID string) error
|
||||
GetFile(ctx context.Context, id string) (File, error)
|
||||
GetFileByPathAndSession(ctx context.Context, arg GetFileByPathAndSessionParams) (File, error)
|
||||
GetMessage(ctx context.Context, id string) (Message, error)
|
||||
GetSessionByID(ctx context.Context, id string) (Session, error)
|
||||
ListAllLogs(ctx context.Context, limit int64) ([]Log, error)
|
||||
ListFilesByPath(ctx context.Context, path string) ([]File, error)
|
||||
ListFilesBySession(ctx context.Context, sessionID string) ([]File, error)
|
||||
ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error)
|
||||
ListLogsBySession(ctx context.Context, sessionID sql.NullString) ([]Log, error)
|
||||
ListMessagesBySession(ctx context.Context, sessionID string) ([]Message, error)
|
||||
ListMessagesBySessionAfter(ctx context.Context, arg ListMessagesBySessionAfterParams) ([]Message, error)
|
||||
ListNewFiles(ctx context.Context) ([]File, error)
|
||||
ListSessions(ctx context.Context) ([]Session, error)
|
||||
UpdateFile(ctx context.Context, arg UpdateFileParams) (File, error)
|
||||
UpdateMessage(ctx context.Context, arg UpdateMessageParams) error
|
||||
UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error)
|
||||
}
|
||||
|
||||
var _ Querier = (*Queries)(nil)
|
||||
@@ -1,203 +0,0 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
// source: sessions.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
const createSession = `-- name: CreateSession :one
|
||||
INSERT INTO sessions (
|
||||
id,
|
||||
parent_session_id,
|
||||
title,
|
||||
message_count,
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
cost,
|
||||
summary,
|
||||
summarized_at
|
||||
) VALUES (
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?
|
||||
) RETURNING id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, summary, summarized_at, updated_at, created_at
|
||||
`
|
||||
|
||||
type CreateSessionParams struct {
|
||||
ID string `json:"id"`
|
||||
ParentSessionID sql.NullString `json:"parent_session_id"`
|
||||
Title string `json:"title"`
|
||||
MessageCount int64 `json:"message_count"`
|
||||
PromptTokens int64 `json:"prompt_tokens"`
|
||||
CompletionTokens int64 `json:"completion_tokens"`
|
||||
Cost float64 `json:"cost"`
|
||||
Summary sql.NullString `json:"summary"`
|
||||
SummarizedAt sql.NullString `json:"summarized_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) {
|
||||
row := q.queryRow(ctx, q.createSessionStmt, createSession,
|
||||
arg.ID,
|
||||
arg.ParentSessionID,
|
||||
arg.Title,
|
||||
arg.MessageCount,
|
||||
arg.PromptTokens,
|
||||
arg.CompletionTokens,
|
||||
arg.Cost,
|
||||
arg.Summary,
|
||||
arg.SummarizedAt,
|
||||
)
|
||||
var i Session
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ParentSessionID,
|
||||
&i.Title,
|
||||
&i.MessageCount,
|
||||
&i.PromptTokens,
|
||||
&i.CompletionTokens,
|
||||
&i.Cost,
|
||||
&i.Summary,
|
||||
&i.SummarizedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteSession = `-- name: DeleteSession :exec
|
||||
DELETE FROM sessions
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteSession(ctx context.Context, id string) error {
|
||||
_, err := q.exec(ctx, q.deleteSessionStmt, deleteSession, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const getSessionByID = `-- name: GetSessionByID :one
|
||||
SELECT id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, summary, summarized_at, updated_at, created_at
|
||||
FROM sessions
|
||||
WHERE id = ? LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) GetSessionByID(ctx context.Context, id string) (Session, error) {
|
||||
row := q.queryRow(ctx, q.getSessionByIDStmt, getSessionByID, id)
|
||||
var i Session
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ParentSessionID,
|
||||
&i.Title,
|
||||
&i.MessageCount,
|
||||
&i.PromptTokens,
|
||||
&i.CompletionTokens,
|
||||
&i.Cost,
|
||||
&i.Summary,
|
||||
&i.SummarizedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listSessions = `-- name: ListSessions :many
|
||||
SELECT id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, summary, summarized_at, updated_at, created_at
|
||||
FROM sessions
|
||||
WHERE parent_session_id is NULL
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
func (q *Queries) ListSessions(ctx context.Context) ([]Session, error) {
|
||||
rows, err := q.query(ctx, q.listSessionsStmt, listSessions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Session{}
|
||||
for rows.Next() {
|
||||
var i Session
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ParentSessionID,
|
||||
&i.Title,
|
||||
&i.MessageCount,
|
||||
&i.PromptTokens,
|
||||
&i.CompletionTokens,
|
||||
&i.Cost,
|
||||
&i.Summary,
|
||||
&i.SummarizedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const updateSession = `-- name: UpdateSession :one
|
||||
UPDATE sessions
|
||||
SET
|
||||
title = ?,
|
||||
prompt_tokens = ?,
|
||||
completion_tokens = ?,
|
||||
cost = ?,
|
||||
summary = ?,
|
||||
summarized_at = ?
|
||||
WHERE id = ?
|
||||
RETURNING id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, summary, summarized_at, updated_at, created_at
|
||||
`
|
||||
|
||||
type UpdateSessionParams struct {
|
||||
Title string `json:"title"`
|
||||
PromptTokens int64 `json:"prompt_tokens"`
|
||||
CompletionTokens int64 `json:"completion_tokens"`
|
||||
Cost float64 `json:"cost"`
|
||||
Summary sql.NullString `json:"summary"`
|
||||
SummarizedAt sql.NullString `json:"summarized_at"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error) {
|
||||
row := q.queryRow(ctx, q.updateSessionStmt, updateSession,
|
||||
arg.Title,
|
||||
arg.PromptTokens,
|
||||
arg.CompletionTokens,
|
||||
arg.Cost,
|
||||
arg.Summary,
|
||||
arg.SummarizedAt,
|
||||
arg.ID,
|
||||
)
|
||||
var i Session
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ParentSessionID,
|
||||
&i.Title,
|
||||
&i.MessageCount,
|
||||
&i.PromptTokens,
|
||||
&i.CompletionTokens,
|
||||
&i.Cost,
|
||||
&i.Summary,
|
||||
&i.SummarizedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
-- name: GetFile :one
|
||||
SELECT *
|
||||
FROM files
|
||||
WHERE id = ? LIMIT 1;
|
||||
|
||||
-- name: GetFileByPathAndSession :one
|
||||
SELECT *
|
||||
FROM files
|
||||
WHERE path = ? AND session_id = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1;
|
||||
|
||||
-- name: ListFilesBySession :many
|
||||
SELECT *
|
||||
FROM files
|
||||
WHERE session_id = ?
|
||||
ORDER BY created_at ASC;
|
||||
|
||||
-- name: ListFilesByPath :many
|
||||
SELECT *
|
||||
FROM files
|
||||
WHERE path = ?
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- name: CreateFile :one
|
||||
INSERT INTO files (
|
||||
id,
|
||||
session_id,
|
||||
path,
|
||||
content,
|
||||
version
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?
|
||||
)
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateFile :one
|
||||
UPDATE files
|
||||
SET
|
||||
content = ?,
|
||||
version = ?,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')
|
||||
WHERE id = ?
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeleteFile :exec
|
||||
DELETE FROM files
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: DeleteSessionFiles :exec
|
||||
DELETE FROM files
|
||||
WHERE session_id = ?;
|
||||
|
||||
-- name: ListLatestSessionFiles :many
|
||||
SELECT f.*
|
||||
FROM files f
|
||||
INNER JOIN (
|
||||
SELECT path, MAX(created_at) as max_created_at
|
||||
FROM files
|
||||
GROUP BY path
|
||||
) latest ON f.path = latest.path AND f.created_at = latest.max_created_at
|
||||
WHERE f.session_id = ?
|
||||
ORDER BY f.path;
|
||||
|
||||
-- name: ListNewFiles :many
|
||||
SELECT *
|
||||
FROM files
|
||||
WHERE is_new = 1
|
||||
ORDER BY created_at DESC;
|
||||
@@ -1,26 +0,0 @@
|
||||
-- name: CreateLog :one
|
||||
INSERT INTO logs (
|
||||
id,
|
||||
session_id,
|
||||
timestamp,
|
||||
level,
|
||||
message,
|
||||
attributes
|
||||
) VALUES (
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?
|
||||
) RETURNING *;
|
||||
|
||||
-- name: ListLogsBySession :many
|
||||
SELECT * FROM logs
|
||||
WHERE session_id = ?
|
||||
ORDER BY timestamp DESC;
|
||||
|
||||
-- name: ListAllLogs :many
|
||||
SELECT * FROM logs
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?;
|
||||
@@ -1,45 +0,0 @@
|
||||
-- name: GetMessage :one
|
||||
SELECT *
|
||||
FROM messages
|
||||
WHERE id = ? LIMIT 1;
|
||||
|
||||
-- name: ListMessagesBySession :many
|
||||
SELECT *
|
||||
FROM messages
|
||||
WHERE session_id = ?
|
||||
ORDER BY created_at ASC;
|
||||
|
||||
-- name: ListMessagesBySessionAfter :many
|
||||
SELECT *
|
||||
FROM messages
|
||||
WHERE session_id = ? AND created_at > ?
|
||||
ORDER BY created_at ASC;
|
||||
|
||||
-- name: CreateMessage :one
|
||||
INSERT INTO messages (
|
||||
id,
|
||||
session_id,
|
||||
role,
|
||||
parts,
|
||||
model
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?
|
||||
)
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateMessage :exec
|
||||
UPDATE messages
|
||||
SET
|
||||
parts = ?,
|
||||
finished_at = ?,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')
|
||||
WHERE id = ?;
|
||||
|
||||
|
||||
-- name: DeleteMessage :exec
|
||||
DELETE FROM messages
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: DeleteSessionMessages :exec
|
||||
DELETE FROM messages
|
||||
WHERE session_id = ?;
|
||||
@@ -1,50 +0,0 @@
|
||||
-- name: CreateSession :one
|
||||
INSERT INTO sessions (
|
||||
id,
|
||||
parent_session_id,
|
||||
title,
|
||||
message_count,
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
cost,
|
||||
summary,
|
||||
summarized_at
|
||||
) VALUES (
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?
|
||||
) RETURNING *;
|
||||
|
||||
-- name: GetSessionByID :one
|
||||
SELECT *
|
||||
FROM sessions
|
||||
WHERE id = ? LIMIT 1;
|
||||
|
||||
-- name: ListSessions :many
|
||||
SELECT *
|
||||
FROM sessions
|
||||
WHERE parent_session_id is NULL
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- name: UpdateSession :one
|
||||
UPDATE sessions
|
||||
SET
|
||||
title = ?,
|
||||
prompt_tokens = ?,
|
||||
completion_tokens = ?,
|
||||
cost = ?,
|
||||
summary = ?,
|
||||
summarized_at = ?
|
||||
WHERE id = ?
|
||||
RETURNING *;
|
||||
|
||||
|
||||
-- name: DeleteSession :exec
|
||||
DELETE FROM sessions
|
||||
WHERE id = ?;
|
||||
@@ -1,103 +0,0 @@
|
||||
package diff
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestApplyHighlighting tests the applyHighlighting function with various ANSI sequences
|
||||
func TestApplyHighlighting(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Mock theme colors for testing
|
||||
mockHighlightBg := lipgloss.AdaptiveColor{
|
||||
Dark: "#FF0000", // Red background for highlighting
|
||||
Light: "#FF0000",
|
||||
}
|
||||
|
||||
// Test cases
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
segments []Segment
|
||||
segmentType LineType
|
||||
expectContains string
|
||||
}{
|
||||
{
|
||||
name: "Simple text with no ANSI",
|
||||
content: "This is a test",
|
||||
segments: []Segment{{Start: 0, End: 4, Type: LineAdded}},
|
||||
segmentType: LineAdded,
|
||||
// Should contain full reset sequence after highlighting
|
||||
expectContains: "\x1b[0m",
|
||||
},
|
||||
{
|
||||
name: "Text with existing ANSI foreground",
|
||||
content: "This \x1b[32mis\x1b[0m a test", // "is" in green
|
||||
segments: []Segment{{Start: 5, End: 7, Type: LineAdded}},
|
||||
segmentType: LineAdded,
|
||||
// Should contain full reset sequence after highlighting
|
||||
expectContains: "\x1b[0m",
|
||||
},
|
||||
{
|
||||
name: "Text with existing ANSI background",
|
||||
content: "This \x1b[42mis\x1b[0m a test", // "is" with green background
|
||||
segments: []Segment{{Start: 5, End: 7, Type: LineAdded}},
|
||||
segmentType: LineAdded,
|
||||
// Should contain full reset sequence after highlighting
|
||||
expectContains: "\x1b[0m",
|
||||
},
|
||||
{
|
||||
name: "Text with complex ANSI styling",
|
||||
content: "This \x1b[1;32;45mis\x1b[0m a test", // "is" bold green on magenta
|
||||
segments: []Segment{{Start: 5, End: 7, Type: LineAdded}},
|
||||
segmentType: LineAdded,
|
||||
// Should contain full reset sequence after highlighting
|
||||
expectContains: "\x1b[0m",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
tc := tc // Capture range variable for parallel testing
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
result := applyHighlighting(tc.content, tc.segments, tc.segmentType, mockHighlightBg)
|
||||
|
||||
// Verify the result contains the expected sequence
|
||||
assert.Contains(t, result, tc.expectContains,
|
||||
"Result should contain full reset sequence")
|
||||
|
||||
// Print the result for manual inspection if needed
|
||||
if t.Failed() {
|
||||
fmt.Printf("Original: %q\nResult: %q\n", tc.content, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyHighlightingWithMultipleSegments tests highlighting multiple segments
|
||||
func TestApplyHighlightingWithMultipleSegments(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Mock theme colors for testing
|
||||
mockHighlightBg := lipgloss.AdaptiveColor{
|
||||
Dark: "#FF0000", // Red background for highlighting
|
||||
Light: "#FF0000",
|
||||
}
|
||||
|
||||
content := "This is a test with multiple segments to highlight"
|
||||
segments := []Segment{
|
||||
{Start: 0, End: 4, Type: LineAdded}, // "This"
|
||||
{Start: 8, End: 9, Type: LineAdded}, // "a"
|
||||
{Start: 15, End: 23, Type: LineAdded}, // "multiple"
|
||||
}
|
||||
|
||||
result := applyHighlighting(content, segments, LineAdded, mockHighlightBg)
|
||||
|
||||
// Verify the result contains the full reset sequence
|
||||
assert.Contains(t, result, "\x1b[0m",
|
||||
"Result should contain full reset sequence")
|
||||
}
|
||||
@@ -1,740 +0,0 @@
|
||||
package diff
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ActionType string
|
||||
|
||||
const (
|
||||
ActionAdd ActionType = "add"
|
||||
ActionDelete ActionType = "delete"
|
||||
ActionUpdate ActionType = "update"
|
||||
)
|
||||
|
||||
type FileChange struct {
|
||||
Type ActionType
|
||||
OldContent *string
|
||||
NewContent *string
|
||||
MovePath *string
|
||||
}
|
||||
|
||||
type Commit struct {
|
||||
Changes map[string]FileChange
|
||||
}
|
||||
|
||||
type Chunk struct {
|
||||
OrigIndex int // line index of the first line in the original file
|
||||
DelLines []string // lines to delete
|
||||
InsLines []string // lines to insert
|
||||
}
|
||||
|
||||
type PatchAction struct {
|
||||
Type ActionType
|
||||
NewFile *string
|
||||
Chunks []Chunk
|
||||
MovePath *string
|
||||
}
|
||||
|
||||
type Patch struct {
|
||||
Actions map[string]PatchAction
|
||||
}
|
||||
|
||||
type DiffError struct {
|
||||
message string
|
||||
}
|
||||
|
||||
func (e DiffError) Error() string {
|
||||
return e.message
|
||||
}
|
||||
|
||||
// Helper functions for error handling
|
||||
func NewDiffError(message string) DiffError {
|
||||
return DiffError{message: message}
|
||||
}
|
||||
|
||||
func fileError(action, reason, path string) DiffError {
|
||||
return NewDiffError(fmt.Sprintf("%s File Error: %s: %s", action, reason, path))
|
||||
}
|
||||
|
||||
func contextError(index int, context string, isEOF bool) DiffError {
|
||||
prefix := "Invalid Context"
|
||||
if isEOF {
|
||||
prefix = "Invalid EOF Context"
|
||||
}
|
||||
return NewDiffError(fmt.Sprintf("%s %d:\n%s", prefix, index, context))
|
||||
}
|
||||
|
||||
type Parser struct {
|
||||
currentFiles map[string]string
|
||||
lines []string
|
||||
index int
|
||||
patch Patch
|
||||
fuzz int
|
||||
}
|
||||
|
||||
func NewParser(currentFiles map[string]string, lines []string) *Parser {
|
||||
return &Parser{
|
||||
currentFiles: currentFiles,
|
||||
lines: lines,
|
||||
index: 0,
|
||||
patch: Patch{Actions: make(map[string]PatchAction, len(currentFiles))},
|
||||
fuzz: 0,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Parser) isDone(prefixes []string) bool {
|
||||
if p.index >= len(p.lines) {
|
||||
return true
|
||||
}
|
||||
for _, prefix := range prefixes {
|
||||
if strings.HasPrefix(p.lines[p.index], prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *Parser) startsWith(prefix any) bool {
|
||||
var prefixes []string
|
||||
switch v := prefix.(type) {
|
||||
case string:
|
||||
prefixes = []string{v}
|
||||
case []string:
|
||||
prefixes = v
|
||||
}
|
||||
|
||||
for _, pfx := range prefixes {
|
||||
if strings.HasPrefix(p.lines[p.index], pfx) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *Parser) readStr(prefix string, returnEverything bool) string {
|
||||
if p.index >= len(p.lines) {
|
||||
return "" // Changed from panic to return empty string for safer operation
|
||||
}
|
||||
if strings.HasPrefix(p.lines[p.index], prefix) {
|
||||
var text string
|
||||
if returnEverything {
|
||||
text = p.lines[p.index]
|
||||
} else {
|
||||
text = p.lines[p.index][len(prefix):]
|
||||
}
|
||||
p.index++
|
||||
return text
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (p *Parser) Parse() error {
|
||||
endPatchPrefixes := []string{"*** End Patch"}
|
||||
|
||||
for !p.isDone(endPatchPrefixes) {
|
||||
path := p.readStr("*** Update File: ", false)
|
||||
if path != "" {
|
||||
if _, exists := p.patch.Actions[path]; exists {
|
||||
return fileError("Update", "Duplicate Path", path)
|
||||
}
|
||||
moveTo := p.readStr("*** Move to: ", false)
|
||||
if _, exists := p.currentFiles[path]; !exists {
|
||||
return fileError("Update", "Missing File", path)
|
||||
}
|
||||
text := p.currentFiles[path]
|
||||
action, err := p.parseUpdateFile(text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if moveTo != "" {
|
||||
action.MovePath = &moveTo
|
||||
}
|
||||
p.patch.Actions[path] = action
|
||||
continue
|
||||
}
|
||||
|
||||
path = p.readStr("*** Delete File: ", false)
|
||||
if path != "" {
|
||||
if _, exists := p.patch.Actions[path]; exists {
|
||||
return fileError("Delete", "Duplicate Path", path)
|
||||
}
|
||||
if _, exists := p.currentFiles[path]; !exists {
|
||||
return fileError("Delete", "Missing File", path)
|
||||
}
|
||||
p.patch.Actions[path] = PatchAction{Type: ActionDelete, Chunks: []Chunk{}}
|
||||
continue
|
||||
}
|
||||
|
||||
path = p.readStr("*** Add File: ", false)
|
||||
if path != "" {
|
||||
if _, exists := p.patch.Actions[path]; exists {
|
||||
return fileError("Add", "Duplicate Path", path)
|
||||
}
|
||||
if _, exists := p.currentFiles[path]; exists {
|
||||
return fileError("Add", "File already exists", path)
|
||||
}
|
||||
action, err := p.parseAddFile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.patch.Actions[path] = action
|
||||
continue
|
||||
}
|
||||
|
||||
return NewDiffError(fmt.Sprintf("Unknown Line: %s", p.lines[p.index]))
|
||||
}
|
||||
|
||||
if !p.startsWith("*** End Patch") {
|
||||
return NewDiffError("Missing End Patch")
|
||||
}
|
||||
p.index++
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Parser) parseUpdateFile(text string) (PatchAction, error) {
|
||||
action := PatchAction{Type: ActionUpdate, Chunks: []Chunk{}}
|
||||
fileLines := strings.Split(text, "\n")
|
||||
index := 0
|
||||
|
||||
endPrefixes := []string{
|
||||
"*** End Patch",
|
||||
"*** Update File:",
|
||||
"*** Delete File:",
|
||||
"*** Add File:",
|
||||
"*** End of File",
|
||||
}
|
||||
|
||||
for !p.isDone(endPrefixes) {
|
||||
defStr := p.readStr("@@ ", false)
|
||||
sectionStr := ""
|
||||
if defStr == "" && p.index < len(p.lines) && p.lines[p.index] == "@@" {
|
||||
sectionStr = p.lines[p.index]
|
||||
p.index++
|
||||
}
|
||||
if defStr == "" && sectionStr == "" && index != 0 {
|
||||
return action, NewDiffError(fmt.Sprintf("Invalid Line:\n%s", p.lines[p.index]))
|
||||
}
|
||||
if strings.TrimSpace(defStr) != "" {
|
||||
found := false
|
||||
for i := range fileLines[:index] {
|
||||
if fileLines[i] == defStr {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
for i := index; i < len(fileLines); i++ {
|
||||
if fileLines[i] == defStr {
|
||||
index = i + 1
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
for i := range fileLines[:index] {
|
||||
if strings.TrimSpace(fileLines[i]) == strings.TrimSpace(defStr) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
for i := index; i < len(fileLines); i++ {
|
||||
if strings.TrimSpace(fileLines[i]) == strings.TrimSpace(defStr) {
|
||||
index = i + 1
|
||||
p.fuzz++
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nextChunkContext, chunks, endPatchIndex, eof := peekNextSection(p.lines, p.index)
|
||||
newIndex, fuzz := findContext(fileLines, nextChunkContext, index, eof)
|
||||
if newIndex == -1 {
|
||||
ctxText := strings.Join(nextChunkContext, "\n")
|
||||
return action, contextError(index, ctxText, eof)
|
||||
}
|
||||
p.fuzz += fuzz
|
||||
|
||||
for _, ch := range chunks {
|
||||
ch.OrigIndex += newIndex
|
||||
action.Chunks = append(action.Chunks, ch)
|
||||
}
|
||||
index = newIndex + len(nextChunkContext)
|
||||
p.index = endPatchIndex
|
||||
}
|
||||
return action, nil
|
||||
}
|
||||
|
||||
func (p *Parser) parseAddFile() (PatchAction, error) {
|
||||
lines := make([]string, 0, 16) // Preallocate space for better performance
|
||||
endPrefixes := []string{
|
||||
"*** End Patch",
|
||||
"*** Update File:",
|
||||
"*** Delete File:",
|
||||
"*** Add File:",
|
||||
}
|
||||
|
||||
for !p.isDone(endPrefixes) {
|
||||
s := p.readStr("", true)
|
||||
if !strings.HasPrefix(s, "+") {
|
||||
return PatchAction{}, NewDiffError(fmt.Sprintf("Invalid Add File Line: %s", s))
|
||||
}
|
||||
lines = append(lines, s[1:])
|
||||
}
|
||||
|
||||
newFile := strings.Join(lines, "\n")
|
||||
return PatchAction{
|
||||
Type: ActionAdd,
|
||||
NewFile: &newFile,
|
||||
Chunks: []Chunk{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Refactored to use a matcher function for each comparison type
|
||||
func findContextCore(lines []string, context []string, start int) (int, int) {
|
||||
if len(context) == 0 {
|
||||
return start, 0
|
||||
}
|
||||
|
||||
// Try exact match
|
||||
if idx, fuzz := tryFindMatch(lines, context, start, func(a, b string) bool {
|
||||
return a == b
|
||||
}); idx >= 0 {
|
||||
return idx, fuzz
|
||||
}
|
||||
|
||||
// Try trimming right whitespace
|
||||
if idx, fuzz := tryFindMatch(lines, context, start, func(a, b string) bool {
|
||||
return strings.TrimRight(a, " \t") == strings.TrimRight(b, " \t")
|
||||
}); idx >= 0 {
|
||||
return idx, fuzz
|
||||
}
|
||||
|
||||
// Try trimming all whitespace
|
||||
if idx, fuzz := tryFindMatch(lines, context, start, func(a, b string) bool {
|
||||
return strings.TrimSpace(a) == strings.TrimSpace(b)
|
||||
}); idx >= 0 {
|
||||
return idx, fuzz
|
||||
}
|
||||
|
||||
return -1, 0
|
||||
}
|
||||
|
||||
// Helper function to DRY up the match logic
|
||||
func tryFindMatch(lines []string, context []string, start int,
|
||||
compareFunc func(string, string) bool,
|
||||
) (int, int) {
|
||||
for i := start; i < len(lines); i++ {
|
||||
if i+len(context) <= len(lines) {
|
||||
match := true
|
||||
for j := range context {
|
||||
if !compareFunc(lines[i+j], context[j]) {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if match {
|
||||
// Return fuzz level: 0 for exact, 1 for trimRight, 100 for trimSpace
|
||||
var fuzz int
|
||||
if compareFunc("a ", "a") && !compareFunc("a", "b") {
|
||||
fuzz = 1
|
||||
} else if compareFunc("a ", "a") {
|
||||
fuzz = 100
|
||||
}
|
||||
return i, fuzz
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1, 0
|
||||
}
|
||||
|
||||
func findContext(lines []string, context []string, start int, eof bool) (int, int) {
|
||||
if eof {
|
||||
newIndex, fuzz := findContextCore(lines, context, len(lines)-len(context))
|
||||
if newIndex != -1 {
|
||||
return newIndex, fuzz
|
||||
}
|
||||
newIndex, fuzz = findContextCore(lines, context, start)
|
||||
return newIndex, fuzz + 10000
|
||||
}
|
||||
return findContextCore(lines, context, start)
|
||||
}
|
||||
|
||||
func peekNextSection(lines []string, initialIndex int) ([]string, []Chunk, int, bool) {
|
||||
index := initialIndex
|
||||
old := make([]string, 0, 32) // Preallocate for better performance
|
||||
delLines := make([]string, 0, 8)
|
||||
insLines := make([]string, 0, 8)
|
||||
chunks := make([]Chunk, 0, 4)
|
||||
mode := "keep"
|
||||
|
||||
// End conditions for the section
|
||||
endSectionConditions := func(s string) bool {
|
||||
return strings.HasPrefix(s, "@@") ||
|
||||
strings.HasPrefix(s, "*** End Patch") ||
|
||||
strings.HasPrefix(s, "*** Update File:") ||
|
||||
strings.HasPrefix(s, "*** Delete File:") ||
|
||||
strings.HasPrefix(s, "*** Add File:") ||
|
||||
strings.HasPrefix(s, "*** End of File") ||
|
||||
s == "***" ||
|
||||
strings.HasPrefix(s, "***")
|
||||
}
|
||||
|
||||
for index < len(lines) {
|
||||
s := lines[index]
|
||||
if endSectionConditions(s) {
|
||||
break
|
||||
}
|
||||
index++
|
||||
lastMode := mode
|
||||
line := s
|
||||
|
||||
if len(line) > 0 {
|
||||
switch line[0] {
|
||||
case '+':
|
||||
mode = "add"
|
||||
case '-':
|
||||
mode = "delete"
|
||||
case ' ':
|
||||
mode = "keep"
|
||||
default:
|
||||
mode = "keep"
|
||||
line = " " + line
|
||||
}
|
||||
} else {
|
||||
mode = "keep"
|
||||
line = " "
|
||||
}
|
||||
|
||||
line = line[1:]
|
||||
if mode == "keep" && lastMode != mode {
|
||||
if len(insLines) > 0 || len(delLines) > 0 {
|
||||
chunks = append(chunks, Chunk{
|
||||
OrigIndex: len(old) - len(delLines),
|
||||
DelLines: delLines,
|
||||
InsLines: insLines,
|
||||
})
|
||||
}
|
||||
delLines = make([]string, 0, 8)
|
||||
insLines = make([]string, 0, 8)
|
||||
}
|
||||
switch mode {
|
||||
case "delete":
|
||||
delLines = append(delLines, line)
|
||||
old = append(old, line)
|
||||
case "add":
|
||||
insLines = append(insLines, line)
|
||||
default:
|
||||
old = append(old, line)
|
||||
}
|
||||
}
|
||||
|
||||
if len(insLines) > 0 || len(delLines) > 0 {
|
||||
chunks = append(chunks, Chunk{
|
||||
OrigIndex: len(old) - len(delLines),
|
||||
DelLines: delLines,
|
||||
InsLines: insLines,
|
||||
})
|
||||
}
|
||||
|
||||
if index < len(lines) && lines[index] == "*** End of File" {
|
||||
index++
|
||||
return old, chunks, index, true
|
||||
}
|
||||
return old, chunks, index, false
|
||||
}
|
||||
|
||||
func TextToPatch(text string, orig map[string]string) (Patch, int, error) {
|
||||
text = strings.TrimSpace(text)
|
||||
lines := strings.Split(text, "\n")
|
||||
if len(lines) < 2 || !strings.HasPrefix(lines[0], "*** Begin Patch") || lines[len(lines)-1] != "*** End Patch" {
|
||||
return Patch{}, 0, NewDiffError("Invalid patch text")
|
||||
}
|
||||
parser := NewParser(orig, lines)
|
||||
parser.index = 1
|
||||
if err := parser.Parse(); err != nil {
|
||||
return Patch{}, 0, err
|
||||
}
|
||||
return parser.patch, parser.fuzz, nil
|
||||
}
|
||||
|
||||
func IdentifyFilesNeeded(text string) []string {
|
||||
text = strings.TrimSpace(text)
|
||||
lines := strings.Split(text, "\n")
|
||||
result := make(map[string]bool)
|
||||
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "*** Update File: ") {
|
||||
result[line[len("*** Update File: "):]] = true
|
||||
}
|
||||
if strings.HasPrefix(line, "*** Delete File: ") {
|
||||
result[line[len("*** Delete File: "):]] = true
|
||||
}
|
||||
}
|
||||
|
||||
files := make([]string, 0, len(result))
|
||||
for file := range result {
|
||||
files = append(files, file)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func IdentifyFilesAdded(text string) []string {
|
||||
text = strings.TrimSpace(text)
|
||||
lines := strings.Split(text, "\n")
|
||||
result := make(map[string]bool)
|
||||
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "*** Add File: ") {
|
||||
result[line[len("*** Add File: "):]] = true
|
||||
}
|
||||
}
|
||||
|
||||
files := make([]string, 0, len(result))
|
||||
for file := range result {
|
||||
files = append(files, file)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func getUpdatedFile(text string, action PatchAction, path string) (string, error) {
|
||||
if action.Type != ActionUpdate {
|
||||
return "", errors.New("expected UPDATE action")
|
||||
}
|
||||
origLines := strings.Split(text, "\n")
|
||||
destLines := make([]string, 0, len(origLines)) // Preallocate with capacity
|
||||
origIndex := 0
|
||||
|
||||
for _, chunk := range action.Chunks {
|
||||
if chunk.OrigIndex > len(origLines) {
|
||||
return "", NewDiffError(fmt.Sprintf("%s: chunk.orig_index %d > len(lines) %d", path, chunk.OrigIndex, len(origLines)))
|
||||
}
|
||||
if origIndex > chunk.OrigIndex {
|
||||
return "", NewDiffError(fmt.Sprintf("%s: orig_index %d > chunk.orig_index %d", path, origIndex, chunk.OrigIndex))
|
||||
}
|
||||
destLines = append(destLines, origLines[origIndex:chunk.OrigIndex]...)
|
||||
delta := chunk.OrigIndex - origIndex
|
||||
origIndex += delta
|
||||
|
||||
if len(chunk.InsLines) > 0 {
|
||||
destLines = append(destLines, chunk.InsLines...)
|
||||
}
|
||||
origIndex += len(chunk.DelLines)
|
||||
}
|
||||
|
||||
destLines = append(destLines, origLines[origIndex:]...)
|
||||
return strings.Join(destLines, "\n"), nil
|
||||
}
|
||||
|
||||
func PatchToCommit(patch Patch, orig map[string]string) (Commit, error) {
|
||||
commit := Commit{Changes: make(map[string]FileChange, len(patch.Actions))}
|
||||
for pathKey, action := range patch.Actions {
|
||||
switch action.Type {
|
||||
case ActionDelete:
|
||||
oldContent := orig[pathKey]
|
||||
commit.Changes[pathKey] = FileChange{
|
||||
Type: ActionDelete,
|
||||
OldContent: &oldContent,
|
||||
}
|
||||
case ActionAdd:
|
||||
commit.Changes[pathKey] = FileChange{
|
||||
Type: ActionAdd,
|
||||
NewContent: action.NewFile,
|
||||
}
|
||||
case ActionUpdate:
|
||||
newContent, err := getUpdatedFile(orig[pathKey], action, pathKey)
|
||||
if err != nil {
|
||||
return Commit{}, err
|
||||
}
|
||||
oldContent := orig[pathKey]
|
||||
fileChange := FileChange{
|
||||
Type: ActionUpdate,
|
||||
OldContent: &oldContent,
|
||||
NewContent: &newContent,
|
||||
}
|
||||
if action.MovePath != nil {
|
||||
fileChange.MovePath = action.MovePath
|
||||
}
|
||||
commit.Changes[pathKey] = fileChange
|
||||
}
|
||||
}
|
||||
return commit, nil
|
||||
}
|
||||
|
||||
func AssembleChanges(orig map[string]string, updatedFiles map[string]string) Commit {
|
||||
commit := Commit{Changes: make(map[string]FileChange, len(updatedFiles))}
|
||||
for p, newContent := range updatedFiles {
|
||||
oldContent, exists := orig[p]
|
||||
if exists && oldContent == newContent {
|
||||
continue
|
||||
}
|
||||
|
||||
if exists && newContent != "" {
|
||||
commit.Changes[p] = FileChange{
|
||||
Type: ActionUpdate,
|
||||
OldContent: &oldContent,
|
||||
NewContent: &newContent,
|
||||
}
|
||||
} else if newContent != "" {
|
||||
commit.Changes[p] = FileChange{
|
||||
Type: ActionAdd,
|
||||
NewContent: &newContent,
|
||||
}
|
||||
} else if exists {
|
||||
commit.Changes[p] = FileChange{
|
||||
Type: ActionDelete,
|
||||
OldContent: &oldContent,
|
||||
}
|
||||
} else {
|
||||
return commit // Changed from panic to simply return current commit
|
||||
}
|
||||
}
|
||||
return commit
|
||||
}
|
||||
|
||||
func LoadFiles(paths []string, openFn func(string) (string, error)) (map[string]string, error) {
|
||||
orig := make(map[string]string, len(paths))
|
||||
for _, p := range paths {
|
||||
content, err := openFn(p)
|
||||
if err != nil {
|
||||
return nil, fileError("Open", "File not found", p)
|
||||
}
|
||||
orig[p] = content
|
||||
}
|
||||
return orig, nil
|
||||
}
|
||||
|
||||
func ApplyCommit(commit Commit, writeFn func(string, string) error, removeFn func(string) error) error {
|
||||
for p, change := range commit.Changes {
|
||||
switch change.Type {
|
||||
case ActionDelete:
|
||||
if err := removeFn(p); err != nil {
|
||||
return err
|
||||
}
|
||||
case ActionAdd:
|
||||
if change.NewContent == nil {
|
||||
return NewDiffError(fmt.Sprintf("Add action for %s has nil new_content", p))
|
||||
}
|
||||
if err := writeFn(p, *change.NewContent); err != nil {
|
||||
return err
|
||||
}
|
||||
case ActionUpdate:
|
||||
if change.NewContent == nil {
|
||||
return NewDiffError(fmt.Sprintf("Update action for %s has nil new_content", p))
|
||||
}
|
||||
if change.MovePath != nil {
|
||||
if err := writeFn(*change.MovePath, *change.NewContent); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := removeFn(p); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := writeFn(p, *change.NewContent); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ProcessPatch(text string, openFn func(string) (string, error), writeFn func(string, string) error, removeFn func(string) error) (string, error) {
|
||||
if !strings.HasPrefix(text, "*** Begin Patch") {
|
||||
return "", NewDiffError("Patch must start with *** Begin Patch")
|
||||
}
|
||||
paths := IdentifyFilesNeeded(text)
|
||||
orig, err := LoadFiles(paths, openFn)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
patch, fuzz, err := TextToPatch(text, orig)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if fuzz > 0 {
|
||||
return "", NewDiffError(fmt.Sprintf("Patch contains fuzzy matches (fuzz level: %d)", fuzz))
|
||||
}
|
||||
|
||||
commit, err := PatchToCommit(patch, orig)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := ApplyCommit(commit, writeFn, removeFn); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return "Patch applied successfully", nil
|
||||
}
|
||||
|
||||
func OpenFile(p string) (string, error) {
|
||||
data, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func WriteFile(p string, content string) error {
|
||||
if filepath.IsAbs(p) {
|
||||
return NewDiffError("We do not support absolute paths.")
|
||||
}
|
||||
|
||||
dir := filepath.Dir(p)
|
||||
if dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return os.WriteFile(p, []byte(content), 0o644)
|
||||
}
|
||||
|
||||
func RemoveFile(p string) error {
|
||||
return os.Remove(p)
|
||||
}
|
||||
|
||||
func ValidatePatch(patchText string, files map[string]string) (bool, string, error) {
|
||||
if !strings.HasPrefix(patchText, "*** Begin Patch") {
|
||||
return false, "Patch must start with *** Begin Patch", nil
|
||||
}
|
||||
|
||||
neededFiles := IdentifyFilesNeeded(patchText)
|
||||
for _, filePath := range neededFiles {
|
||||
if _, exists := files[filePath]; !exists {
|
||||
return false, fmt.Sprintf("File not found: %s", filePath), nil
|
||||
}
|
||||
}
|
||||
|
||||
patch, fuzz, err := TextToPatch(patchText, files)
|
||||
if err != nil {
|
||||
return false, err.Error(), nil
|
||||
}
|
||||
|
||||
if fuzz > 0 {
|
||||
return false, fmt.Sprintf("Patch contains fuzzy matches (fuzz level: %d)", fuzz), nil
|
||||
}
|
||||
|
||||
_, err = PatchToCommit(patch, files)
|
||||
if err != nil {
|
||||
return false, err.Error(), nil
|
||||
}
|
||||
|
||||
return true, "Patch is valid", nil
|
||||
}
|
||||
@@ -1,441 +0,0 @@
|
||||
package history
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/sst/opencode/internal/db"
|
||||
"github.com/sst/opencode/internal/pubsub"
|
||||
)
|
||||
|
||||
const (
|
||||
InitialVersion = "initial"
|
||||
)
|
||||
|
||||
type File struct {
|
||||
ID string
|
||||
SessionID string
|
||||
Path string
|
||||
Content string
|
||||
Version string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
const (
|
||||
EventFileCreated pubsub.EventType = "history_file_created"
|
||||
EventFileVersionCreated pubsub.EventType = "history_file_version_created"
|
||||
EventFileUpdated pubsub.EventType = "history_file_updated"
|
||||
EventFileDeleted pubsub.EventType = "history_file_deleted"
|
||||
EventSessionFilesDeleted pubsub.EventType = "history_session_files_deleted"
|
||||
)
|
||||
|
||||
type Service interface {
|
||||
pubsub.Subscriber[File]
|
||||
|
||||
Create(ctx context.Context, sessionID, path, content string) (File, error)
|
||||
CreateVersion(ctx context.Context, sessionID, path, content string) (File, error)
|
||||
Get(ctx context.Context, id string) (File, error)
|
||||
GetByPathAndVersion(ctx context.Context, sessionID, path, version string) (File, error)
|
||||
GetLatestByPathAndSession(ctx context.Context, path, sessionID string) (File, error)
|
||||
ListBySession(ctx context.Context, sessionID string) ([]File, error)
|
||||
ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error)
|
||||
ListVersionsByPath(ctx context.Context, path string) ([]File, error)
|
||||
Update(ctx context.Context, file File) (File, error)
|
||||
Delete(ctx context.Context, id string) error
|
||||
DeleteSessionFiles(ctx context.Context, sessionID string) error
|
||||
}
|
||||
|
||||
type service struct {
|
||||
db *db.Queries
|
||||
sqlDB *sql.DB
|
||||
broker *pubsub.Broker[File]
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
var globalHistoryService *service
|
||||
|
||||
func InitService(sqlDatabase *sql.DB) error {
|
||||
if globalHistoryService != nil {
|
||||
return fmt.Errorf("history service already initialized")
|
||||
}
|
||||
queries := db.New(sqlDatabase)
|
||||
broker := pubsub.NewBroker[File]()
|
||||
|
||||
globalHistoryService = &service{
|
||||
db: queries,
|
||||
sqlDB: sqlDatabase,
|
||||
broker: broker,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetService() Service {
|
||||
if globalHistoryService == nil {
|
||||
panic("history service not initialized. Call history.InitService() first.")
|
||||
}
|
||||
return globalHistoryService
|
||||
}
|
||||
|
||||
func (s *service) Create(ctx context.Context, sessionID, path, content string) (File, error) {
|
||||
return s.createWithVersion(ctx, sessionID, path, content, InitialVersion, EventFileCreated)
|
||||
}
|
||||
|
||||
func (s *service) CreateVersion(ctx context.Context, sessionID, path, content string) (File, error) {
|
||||
s.mu.RLock()
|
||||
files, err := s.db.ListFilesByPath(ctx, path)
|
||||
s.mu.RUnlock()
|
||||
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return File{}, fmt.Errorf("db.ListFilesByPath for next version: %w", err)
|
||||
}
|
||||
|
||||
latestVersionNumber := 0
|
||||
if len(files) > 0 {
|
||||
// Sort to be absolutely sure about the latest version globally for this path
|
||||
slices.SortFunc(files, func(a, b db.File) int {
|
||||
if strings.HasPrefix(a.Version, "v") && strings.HasPrefix(b.Version, "v") {
|
||||
vA, _ := strconv.Atoi(a.Version[1:])
|
||||
vB, _ := strconv.Atoi(b.Version[1:])
|
||||
return vB - vA // Descending to get latest first
|
||||
}
|
||||
if a.Version == InitialVersion && b.Version != InitialVersion {
|
||||
return 1 // initial comes after vX
|
||||
}
|
||||
if b.Version == InitialVersion && a.Version != InitialVersion {
|
||||
return -1
|
||||
}
|
||||
// Compare timestamps as strings (ISO format sorts correctly)
|
||||
if b.CreatedAt > a.CreatedAt {
|
||||
return 1
|
||||
} else if a.CreatedAt > b.CreatedAt {
|
||||
return -1
|
||||
}
|
||||
return 0 // Equal timestamps
|
||||
})
|
||||
|
||||
latestFile := files[0]
|
||||
if strings.HasPrefix(latestFile.Version, "v") {
|
||||
vNum, parseErr := strconv.Atoi(latestFile.Version[1:])
|
||||
if parseErr == nil {
|
||||
latestVersionNumber = vNum
|
||||
}
|
||||
}
|
||||
}
|
||||
nextVersionStr := fmt.Sprintf("v%d", latestVersionNumber+1)
|
||||
return s.createWithVersion(ctx, sessionID, path, content, nextVersionStr, EventFileVersionCreated)
|
||||
}
|
||||
|
||||
func (s *service) createWithVersion(ctx context.Context, sessionID, path, content, version string, eventType pubsub.EventType) (File, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
const maxRetries = 3
|
||||
var file File
|
||||
var err error
|
||||
|
||||
for attempt := range maxRetries {
|
||||
tx, txErr := s.sqlDB.BeginTx(ctx, nil)
|
||||
if txErr != nil {
|
||||
return File{}, fmt.Errorf("failed to begin transaction: %w", txErr)
|
||||
}
|
||||
qtx := s.db.WithTx(tx)
|
||||
|
||||
dbFile, createErr := qtx.CreateFile(ctx, db.CreateFileParams{
|
||||
ID: uuid.New().String(),
|
||||
SessionID: sessionID,
|
||||
Path: path,
|
||||
Content: content,
|
||||
Version: version,
|
||||
})
|
||||
|
||||
if createErr != nil {
|
||||
if rbErr := tx.Rollback(); rbErr != nil {
|
||||
slog.Error("Failed to rollback transaction on create error", "error", rbErr)
|
||||
}
|
||||
if strings.Contains(createErr.Error(), "UNIQUE constraint failed: files.path, files.session_id, files.version") {
|
||||
if attempt < maxRetries-1 {
|
||||
slog.Warn("Unique constraint violation for file version, retrying with incremented version", "path", path, "session", sessionID, "attempted_version", version, "attempt", attempt+1)
|
||||
// Increment version string like v1, v2, v3...
|
||||
if strings.HasPrefix(version, "v") {
|
||||
numPart := version[1:]
|
||||
num, parseErr := strconv.Atoi(numPart)
|
||||
if parseErr == nil {
|
||||
version = fmt.Sprintf("v%d", num+1)
|
||||
continue // Retry with new version
|
||||
}
|
||||
}
|
||||
// Fallback if version is not "vX" or parsing failed
|
||||
version = fmt.Sprintf("%s-retry%d", version, attempt+1)
|
||||
continue
|
||||
}
|
||||
}
|
||||
return File{}, fmt.Errorf("db.CreateFile within transaction: %w", createErr)
|
||||
}
|
||||
|
||||
if commitErr := tx.Commit(); commitErr != nil {
|
||||
return File{}, fmt.Errorf("failed to commit transaction: %w", commitErr)
|
||||
}
|
||||
|
||||
file = s.fromDBItem(dbFile)
|
||||
s.broker.Publish(eventType, file)
|
||||
return file, nil // Success
|
||||
}
|
||||
|
||||
return File{}, fmt.Errorf("failed to create file after %d retries due to version conflicts: %w", maxRetries, err)
|
||||
}
|
||||
|
||||
func (s *service) Get(ctx context.Context, id string) (File, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
dbFile, err := s.db.GetFile(ctx, id)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return File{}, fmt.Errorf("file with ID '%s' not found", id)
|
||||
}
|
||||
return File{}, fmt.Errorf("db.GetFile: %w", err)
|
||||
}
|
||||
return s.fromDBItem(dbFile), nil
|
||||
}
|
||||
|
||||
func (s *service) GetByPathAndVersion(ctx context.Context, sessionID, path, version string) (File, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
// sqlc doesn't directly support GetyByPathAndVersionAndSession
|
||||
// We list and filter. This could be optimized with a custom query if performance is an issue.
|
||||
allFilesForPath, err := s.db.ListFilesByPath(ctx, path)
|
||||
if err != nil {
|
||||
return File{}, fmt.Errorf("db.ListFilesByPath for GetByPathAndVersion: %w", err)
|
||||
}
|
||||
|
||||
for _, dbFile := range allFilesForPath {
|
||||
if dbFile.SessionID == sessionID && dbFile.Version == version {
|
||||
return s.fromDBItem(dbFile), nil
|
||||
}
|
||||
}
|
||||
return File{}, fmt.Errorf("file not found for session '%s', path '%s', version '%s'", sessionID, path, version)
|
||||
}
|
||||
|
||||
func (s *service) GetLatestByPathAndSession(ctx context.Context, path, sessionID string) (File, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
// GetFileByPathAndSession in sqlc already orders by created_at DESC and takes LIMIT 1
|
||||
dbFile, err := s.db.GetFileByPathAndSession(ctx, db.GetFileByPathAndSessionParams{
|
||||
Path: path,
|
||||
SessionID: sessionID,
|
||||
})
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return File{}, fmt.Errorf("no file found for path '%s' in session '%s'", path, sessionID)
|
||||
}
|
||||
return File{}, fmt.Errorf("db.GetFileByPathAndSession: %w", err)
|
||||
}
|
||||
return s.fromDBItem(dbFile), nil
|
||||
}
|
||||
|
||||
func (s *service) ListBySession(ctx context.Context, sessionID string) ([]File, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
dbFiles, err := s.db.ListFilesBySession(ctx, sessionID) // Assumes this orders by created_at ASC
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db.ListFilesBySession: %w", err)
|
||||
}
|
||||
files := make([]File, len(dbFiles))
|
||||
for i, dbF := range dbFiles {
|
||||
files[i] = s.fromDBItem(dbF)
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func (s *service) ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
dbFiles, err := s.db.ListLatestSessionFiles(ctx, sessionID) // Uses the specific sqlc query
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db.ListLatestSessionFiles: %w", err)
|
||||
}
|
||||
files := make([]File, len(dbFiles))
|
||||
for i, dbF := range dbFiles {
|
||||
files[i] = s.fromDBItem(dbF)
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func (s *service) ListVersionsByPath(ctx context.Context, path string) ([]File, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
dbFiles, err := s.db.ListFilesByPath(ctx, path) // sqlc query orders by created_at DESC
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db.ListFilesByPath: %w", err)
|
||||
}
|
||||
files := make([]File, len(dbFiles))
|
||||
for i, dbF := range dbFiles {
|
||||
files[i] = s.fromDBItem(dbF)
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func (s *service) Update(ctx context.Context, file File) (File, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if file.ID == "" {
|
||||
return File{}, fmt.Errorf("cannot update file with empty ID")
|
||||
}
|
||||
// UpdatedAt is handled by DB trigger
|
||||
dbFile, err := s.db.UpdateFile(ctx, db.UpdateFileParams{
|
||||
ID: file.ID,
|
||||
Content: file.Content,
|
||||
Version: file.Version,
|
||||
})
|
||||
if err != nil {
|
||||
return File{}, fmt.Errorf("db.UpdateFile: %w", err)
|
||||
}
|
||||
updatedFile := s.fromDBItem(dbFile)
|
||||
s.broker.Publish(EventFileUpdated, updatedFile)
|
||||
return updatedFile, nil
|
||||
}
|
||||
|
||||
func (s *service) Delete(ctx context.Context, id string) error {
|
||||
s.mu.Lock()
|
||||
fileToPublish, err := s.getServiceForPublish(ctx, id) // Use internal method with appropriate locking
|
||||
s.mu.Unlock()
|
||||
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
slog.Warn("Attempted to delete non-existent file history", "id", id)
|
||||
return nil // Or return specific error if needed
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
err = s.db.DeleteFile(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("db.DeleteFile: %w", err)
|
||||
}
|
||||
if fileToPublish != nil {
|
||||
s.broker.Publish(EventFileDeleted, *fileToPublish)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *service) getServiceForPublish(ctx context.Context, id string) (*File, error) {
|
||||
// Assumes outer lock is NOT held or caller manages it.
|
||||
// For GetFile, it has its own RLock.
|
||||
dbFile, err := s.db.GetFile(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file := s.fromDBItem(dbFile)
|
||||
return &file, nil
|
||||
}
|
||||
|
||||
func (s *service) DeleteSessionFiles(ctx context.Context, sessionID string) error {
|
||||
s.mu.Lock() // Lock for the entire operation
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Get files first for publishing events
|
||||
filesToDelete, err := s.db.ListFilesBySession(ctx, sessionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("db.ListFilesBySession for deletion: %w", err)
|
||||
}
|
||||
|
||||
err = s.db.DeleteSessionFiles(ctx, sessionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("db.DeleteSessionFiles: %w", err)
|
||||
}
|
||||
|
||||
for _, dbFile := range filesToDelete {
|
||||
file := s.fromDBItem(dbFile)
|
||||
s.broker.Publish(EventFileDeleted, file) // Individual delete events
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *service) Subscribe(ctx context.Context) <-chan pubsub.Event[File] {
|
||||
return s.broker.Subscribe(ctx)
|
||||
}
|
||||
|
||||
func (s *service) fromDBItem(item db.File) File {
|
||||
// Parse timestamps from ISO strings
|
||||
createdAt, err := time.Parse(time.RFC3339Nano, item.CreatedAt)
|
||||
if err != nil {
|
||||
slog.Error("Failed to parse created_at", "value", item.CreatedAt, "error", err)
|
||||
createdAt = time.Now() // Fallback
|
||||
}
|
||||
|
||||
updatedAt, err := time.Parse(time.RFC3339Nano, item.UpdatedAt)
|
||||
if err != nil {
|
||||
slog.Error("Failed to parse created_at", "value", item.CreatedAt, "error", err)
|
||||
updatedAt = time.Now() // Fallback
|
||||
}
|
||||
|
||||
return File{
|
||||
ID: item.ID,
|
||||
SessionID: item.SessionID,
|
||||
Path: item.Path,
|
||||
Content: item.Content,
|
||||
Version: item.Version,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func Create(ctx context.Context, sessionID, path, content string) (File, error) {
|
||||
return GetService().Create(ctx, sessionID, path, content)
|
||||
}
|
||||
|
||||
func CreateVersion(ctx context.Context, sessionID, path, content string) (File, error) {
|
||||
return GetService().CreateVersion(ctx, sessionID, path, content)
|
||||
}
|
||||
|
||||
func Get(ctx context.Context, id string) (File, error) {
|
||||
return GetService().Get(ctx, id)
|
||||
}
|
||||
|
||||
func GetByPathAndVersion(ctx context.Context, sessionID, path, version string) (File, error) {
|
||||
return GetService().GetByPathAndVersion(ctx, sessionID, path, version)
|
||||
}
|
||||
|
||||
func GetLatestByPathAndSession(ctx context.Context, path, sessionID string) (File, error) {
|
||||
return GetService().GetLatestByPathAndSession(ctx, path, sessionID)
|
||||
}
|
||||
|
||||
func ListBySession(ctx context.Context, sessionID string) ([]File, error) {
|
||||
return GetService().ListBySession(ctx, sessionID)
|
||||
}
|
||||
|
||||
func ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error) {
|
||||
return GetService().ListLatestSessionFiles(ctx, sessionID)
|
||||
}
|
||||
|
||||
func ListVersionsByPath(ctx context.Context, path string) ([]File, error) {
|
||||
return GetService().ListVersionsByPath(ctx, path)
|
||||
}
|
||||
|
||||
func Update(ctx context.Context, file File) (File, error) {
|
||||
return GetService().Update(ctx, file)
|
||||
}
|
||||
|
||||
func Delete(ctx context.Context, id string) error {
|
||||
return GetService().Delete(ctx, id)
|
||||
}
|
||||
|
||||
func DeleteSessionFiles(ctx context.Context, sessionID string) error {
|
||||
return GetService().DeleteSessionFiles(ctx, sessionID)
|
||||
}
|
||||
|
||||
func Subscribe(ctx context.Context) <-chan pubsub.Event[File] {
|
||||
return GetService().Subscribe(ctx)
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/sst/opencode/internal/config"
|
||||
"github.com/sst/opencode/internal/llm/tools"
|
||||
"github.com/sst/opencode/internal/lsp"
|
||||
"github.com/sst/opencode/internal/message"
|
||||
"github.com/sst/opencode/internal/session"
|
||||
)
|
||||
|
||||
type agentTool struct {
|
||||
sessions session.Service
|
||||
messages message.Service
|
||||
lspClients map[string]*lsp.Client
|
||||
}
|
||||
|
||||
const (
|
||||
AgentToolName = "agent"
|
||||
)
|
||||
|
||||
type AgentParams struct {
|
||||
Prompt string `json:"prompt"`
|
||||
}
|
||||
|
||||
func (b *agentTool) Info() tools.ToolInfo {
|
||||
return tools.ToolInfo{
|
||||
Name: AgentToolName,
|
||||
Description: "Launch a new agent that has access to the following tools: GlobTool, GrepTool, LS, View. When you are searching for a keyword or file and are not confident that you will find the right match on the first try, use the Agent tool to perform the search for you. For example:\n\n- If you are searching for a keyword like \"config\" or \"logger\", or for questions like \"which file does X?\", the Agent tool is strongly recommended\n- If you want to read a specific file path, use the View or GlobTool tool instead of the Agent tool, to find the match more quickly\n- If you are searching for a specific class definition like \"class Foo\", use the GlobTool tool instead, to find the match more quickly\n\nUsage notes:\n1. Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses\n2. When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result.\n3. Each agent invocation is stateless. You will not be able to send additional messages to the agent, nor will the agent be able to communicate with you outside of its final report. Therefore, your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you.\n4. The agent's outputs should generally be trusted\n5. IMPORTANT: The agent can not use Bash, Replace, Edit, so can not modify files. If you want to use these tools, use them directly instead of going through the agent.",
|
||||
Parameters: map[string]any{
|
||||
"prompt": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The task for the agent to perform",
|
||||
},
|
||||
},
|
||||
Required: []string{"prompt"},
|
||||
}
|
||||
}
|
||||
|
||||
func (b *agentTool) Run(ctx context.Context, call tools.ToolCall) (tools.ToolResponse, error) {
|
||||
var params AgentParams
|
||||
if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil {
|
||||
return tools.NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
|
||||
}
|
||||
if params.Prompt == "" {
|
||||
return tools.NewTextErrorResponse("prompt is required"), nil
|
||||
}
|
||||
|
||||
sessionID, messageID := tools.GetContextValues(ctx)
|
||||
if sessionID == "" || messageID == "" {
|
||||
return tools.ToolResponse{}, fmt.Errorf("session_id and message_id are required")
|
||||
}
|
||||
|
||||
agent, err := NewAgent(config.AgentTask, b.sessions, b.messages, TaskAgentTools(b.lspClients))
|
||||
if err != nil {
|
||||
return tools.ToolResponse{}, fmt.Errorf("error creating agent: %s", err)
|
||||
}
|
||||
|
||||
session, err := b.sessions.CreateTaskSession(ctx, call.ID, sessionID, "New Agent Session")
|
||||
if err != nil {
|
||||
return tools.ToolResponse{}, fmt.Errorf("error creating session: %s", err)
|
||||
}
|
||||
|
||||
done, err := agent.Run(ctx, session.ID, params.Prompt)
|
||||
if err != nil {
|
||||
return tools.ToolResponse{}, fmt.Errorf("error generating agent: %s", err)
|
||||
}
|
||||
result := <-done
|
||||
if result.Err() != nil {
|
||||
return tools.ToolResponse{}, fmt.Errorf("error generating agent: %s", result.Err())
|
||||
}
|
||||
|
||||
response := result.Response()
|
||||
if response.Role != message.Assistant {
|
||||
return tools.NewTextErrorResponse("no response"), nil
|
||||
}
|
||||
|
||||
updatedSession, err := b.sessions.Get(ctx, session.ID)
|
||||
if err != nil {
|
||||
return tools.ToolResponse{}, fmt.Errorf("error getting session: %s", err)
|
||||
}
|
||||
parentSession, err := b.sessions.Get(ctx, sessionID)
|
||||
if err != nil {
|
||||
return tools.ToolResponse{}, fmt.Errorf("error getting parent session: %s", err)
|
||||
}
|
||||
|
||||
parentSession.Cost += updatedSession.Cost
|
||||
|
||||
_, err = b.sessions.Update(ctx, parentSession)
|
||||
if err != nil {
|
||||
return tools.ToolResponse{}, fmt.Errorf("error saving parent session: %s", err)
|
||||
}
|
||||
return tools.NewTextResponse(response.Content().String()), nil
|
||||
}
|
||||
|
||||
func NewAgentTool(
|
||||
Sessions session.Service,
|
||||
Messages message.Service,
|
||||
LspClients map[string]*lsp.Client,
|
||||
) tools.BaseTool {
|
||||
return &agentTool{
|
||||
sessions: Sessions,
|
||||
messages: Messages,
|
||||
lspClients: LspClients,
|
||||
}
|
||||
}
|
||||
@@ -1,814 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"log/slog"
|
||||
|
||||
"github.com/sst/opencode/internal/config"
|
||||
"github.com/sst/opencode/internal/llm/models"
|
||||
"github.com/sst/opencode/internal/llm/prompt"
|
||||
"github.com/sst/opencode/internal/llm/provider"
|
||||
"github.com/sst/opencode/internal/llm/tools"
|
||||
"github.com/sst/opencode/internal/logging"
|
||||
"github.com/sst/opencode/internal/message"
|
||||
"github.com/sst/opencode/internal/permission"
|
||||
"github.com/sst/opencode/internal/session"
|
||||
"github.com/sst/opencode/internal/status"
|
||||
)
|
||||
|
||||
// Common errors
|
||||
var (
|
||||
ErrRequestCancelled = errors.New("request cancelled by user")
|
||||
ErrSessionBusy = errors.New("session is currently processing another request")
|
||||
)
|
||||
|
||||
type AgentEvent struct {
|
||||
message message.Message
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *AgentEvent) Err() error {
|
||||
return e.err
|
||||
}
|
||||
|
||||
func (e *AgentEvent) Response() message.Message {
|
||||
return e.message
|
||||
}
|
||||
|
||||
type Service interface {
|
||||
Run(ctx context.Context, sessionID string, content string, attachments ...message.Attachment) (<-chan AgentEvent, error)
|
||||
Cancel(sessionID string)
|
||||
IsSessionBusy(sessionID string) bool
|
||||
IsBusy() bool
|
||||
Update(agentName config.AgentName, modelID models.ModelID) (models.Model, error)
|
||||
CompactSession(ctx context.Context, sessionID string, force bool) error
|
||||
GetUsage(ctx context.Context, sessionID string) (*int64, error)
|
||||
EstimateContextWindowUsage(ctx context.Context, sessionID string) (float64, bool, error)
|
||||
}
|
||||
|
||||
type agent struct {
|
||||
sessions session.Service
|
||||
messages message.Service
|
||||
|
||||
tools []tools.BaseTool
|
||||
provider provider.Provider
|
||||
|
||||
titleProvider provider.Provider
|
||||
|
||||
activeRequests sync.Map
|
||||
}
|
||||
|
||||
func NewAgent(
|
||||
agentName config.AgentName,
|
||||
sessions session.Service,
|
||||
messages message.Service,
|
||||
agentTools []tools.BaseTool,
|
||||
) (Service, error) {
|
||||
agentProvider, err := createAgentProvider(agentName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var titleProvider provider.Provider
|
||||
// Only generate titles for the primary agent
|
||||
if agentName == config.AgentPrimary {
|
||||
titleProvider, err = createAgentProvider(config.AgentTitle)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
agent := &agent{
|
||||
provider: agentProvider,
|
||||
messages: messages,
|
||||
sessions: sessions,
|
||||
tools: agentTools,
|
||||
titleProvider: titleProvider,
|
||||
activeRequests: sync.Map{},
|
||||
}
|
||||
|
||||
return agent, nil
|
||||
}
|
||||
|
||||
func (a *agent) Cancel(sessionID string) {
|
||||
if cancelFunc, exists := a.activeRequests.LoadAndDelete(sessionID); exists {
|
||||
if cancel, ok := cancelFunc.(context.CancelFunc); ok {
|
||||
status.Info(fmt.Sprintf("Request cancellation initiated for session: %s", sessionID))
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *agent) IsBusy() bool {
|
||||
busy := false
|
||||
a.activeRequests.Range(func(key, value interface{}) bool {
|
||||
if cancelFunc, ok := value.(context.CancelFunc); ok {
|
||||
if cancelFunc != nil {
|
||||
busy = true
|
||||
return false // Stop iterating
|
||||
}
|
||||
}
|
||||
return true // Continue iterating
|
||||
})
|
||||
return busy
|
||||
}
|
||||
|
||||
func (a *agent) IsSessionBusy(sessionID string) bool {
|
||||
_, busy := a.activeRequests.Load(sessionID)
|
||||
return busy
|
||||
}
|
||||
|
||||
func (a *agent) generateTitle(ctx context.Context, sessionID string, content string) error {
|
||||
if content == "" {
|
||||
return nil
|
||||
}
|
||||
if a.titleProvider == nil {
|
||||
return nil
|
||||
}
|
||||
session, err := a.sessions.Get(ctx, sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
parts := []message.ContentPart{message.TextContent{Text: content}}
|
||||
response, err := a.titleProvider.SendMessages(
|
||||
ctx,
|
||||
[]message.Message{
|
||||
{
|
||||
Role: message.User,
|
||||
Parts: parts,
|
||||
},
|
||||
},
|
||||
make([]tools.BaseTool, 0),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
title := strings.TrimSpace(strings.ReplaceAll(response.Content, "\n", " "))
|
||||
if title == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
session.Title = title
|
||||
_, err = a.sessions.Update(ctx, session)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *agent) err(err error) AgentEvent {
|
||||
return AgentEvent{
|
||||
err: err,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *agent) Run(ctx context.Context, sessionID string, content string, attachments ...message.Attachment) (<-chan AgentEvent, error) {
|
||||
if !a.provider.Model().SupportsAttachments && attachments != nil {
|
||||
attachments = nil
|
||||
}
|
||||
events := make(chan AgentEvent)
|
||||
if a.IsSessionBusy(sessionID) {
|
||||
return nil, ErrSessionBusy
|
||||
}
|
||||
|
||||
genCtx, cancel := context.WithCancel(ctx)
|
||||
|
||||
a.activeRequests.Store(sessionID, cancel)
|
||||
go func() {
|
||||
slog.Debug("Request started", "sessionID", sessionID)
|
||||
defer logging.RecoverPanic("agent.Run", func() {
|
||||
events <- a.err(fmt.Errorf("panic while running the agent"))
|
||||
})
|
||||
var attachmentParts []message.ContentPart
|
||||
for _, attachment := range attachments {
|
||||
attachmentParts = append(attachmentParts, message.BinaryContent{Path: attachment.FilePath, MIMEType: attachment.MimeType, Data: attachment.Content})
|
||||
}
|
||||
result := a.processGeneration(genCtx, sessionID, content, attachmentParts)
|
||||
if result.Err() != nil && !errors.Is(result.Err(), ErrRequestCancelled) && !errors.Is(result.Err(), context.Canceled) {
|
||||
status.Error(result.Err().Error())
|
||||
}
|
||||
slog.Debug("Request completed", "sessionID", sessionID)
|
||||
a.activeRequests.Delete(sessionID)
|
||||
cancel()
|
||||
events <- result
|
||||
close(events)
|
||||
}()
|
||||
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func (a *agent) prepareMessageHistory(ctx context.Context, sessionID string) (session.Session, []message.Message, error) {
|
||||
currentSession, err := a.sessions.Get(ctx, sessionID)
|
||||
if err != nil {
|
||||
return currentSession, nil, fmt.Errorf("failed to get session: %w", err)
|
||||
}
|
||||
|
||||
var sessionMessages []message.Message
|
||||
if currentSession.Summary != "" && !currentSession.SummarizedAt.IsZero() {
|
||||
// If summary exists, only fetch messages after the summarization timestamp
|
||||
sessionMessages, err = a.messages.ListAfter(ctx, sessionID, currentSession.SummarizedAt)
|
||||
if err != nil {
|
||||
return currentSession, nil, fmt.Errorf("failed to list messages after summary: %w", err)
|
||||
}
|
||||
} else {
|
||||
// If no summary, fetch all messages
|
||||
sessionMessages, err = a.messages.List(ctx, sessionID)
|
||||
if err != nil {
|
||||
return currentSession, nil, fmt.Errorf("failed to list messages: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var messages []message.Message
|
||||
if currentSession.Summary != "" && !currentSession.SummarizedAt.IsZero() {
|
||||
// If summary exists, create a temporary message for the summary
|
||||
summaryMessage := message.Message{
|
||||
Role: message.Assistant,
|
||||
Parts: []message.ContentPart{
|
||||
message.TextContent{Text: currentSession.Summary},
|
||||
},
|
||||
}
|
||||
// Start with the summary, then add messages after the summary timestamp
|
||||
messages = append([]message.Message{summaryMessage}, sessionMessages...)
|
||||
} else {
|
||||
// If no summary, just use all messages
|
||||
messages = sessionMessages
|
||||
}
|
||||
|
||||
return currentSession, messages, nil
|
||||
}
|
||||
|
||||
func (a *agent) triggerTitleGeneration(sessionID string, content string) {
|
||||
go func() {
|
||||
defer logging.RecoverPanic("agent.Run", func() {
|
||||
status.Error("panic while generating title")
|
||||
})
|
||||
titleErr := a.generateTitle(context.Background(), sessionID, content)
|
||||
if titleErr != nil {
|
||||
status.Error(fmt.Sprintf("failed to generate title: %v", titleErr))
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (a *agent) processGeneration(ctx context.Context, sessionID, content string, attachmentParts []message.ContentPart) AgentEvent {
|
||||
currentSession, sessionMessages, err := a.prepareMessageHistory(ctx, sessionID)
|
||||
if err != nil {
|
||||
return a.err(err)
|
||||
}
|
||||
|
||||
// If this is a new session, start title generation asynchronously
|
||||
if len(sessionMessages) == 0 && currentSession.Summary == "" {
|
||||
a.triggerTitleGeneration(sessionID, content)
|
||||
}
|
||||
|
||||
userMsg, err := a.createUserMessage(ctx, sessionID, content, attachmentParts)
|
||||
if err != nil {
|
||||
return a.err(fmt.Errorf("failed to create user message: %w", err))
|
||||
}
|
||||
|
||||
messages := append(sessionMessages, userMsg)
|
||||
|
||||
for {
|
||||
// Check for cancellation before each iteration
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return a.err(ctx.Err())
|
||||
default:
|
||||
// Continue processing
|
||||
}
|
||||
|
||||
// Check if auto-compaction is needed before calling the provider
|
||||
usagePercentage, needsCompaction, errEstimate := a.EstimateContextWindowUsage(ctx, sessionID)
|
||||
if errEstimate != nil {
|
||||
slog.Warn("Failed to estimate context window usage for auto-compaction", "error", errEstimate, "sessionID", sessionID)
|
||||
} else if needsCompaction {
|
||||
status.Info(fmt.Sprintf("Context window usage is at %.2f%%. Auto-compacting conversation...", usagePercentage))
|
||||
|
||||
// Run compaction synchronously
|
||||
compactCtx, cancelCompact := context.WithTimeout(ctx, 30*time.Second) // Use appropriate context
|
||||
errCompact := a.CompactSession(compactCtx, sessionID, true)
|
||||
cancelCompact()
|
||||
|
||||
if errCompact != nil {
|
||||
status.Warn(fmt.Sprintf("Auto-compaction failed: %v. Context window usage may continue to grow.", errCompact))
|
||||
} else {
|
||||
status.Info("Auto-compaction completed successfully.")
|
||||
// After compaction, message history needs to be re-prepared.
|
||||
// The 'messages' slice needs to be updated with the new summary and subsequent messages,
|
||||
// ensuring the latest user message is correctly appended.
|
||||
_, sessionMessagesFromCompact, errPrepare := a.prepareMessageHistory(ctx, sessionID)
|
||||
if errPrepare != nil {
|
||||
return a.err(fmt.Errorf("failed to re-prepare message history after compaction: %w", errPrepare))
|
||||
}
|
||||
messages = sessionMessagesFromCompact
|
||||
|
||||
// Ensure the user message that triggered this cycle is the last one.
|
||||
// 'userMsg' was created before this loop using a.createUserMessage.
|
||||
// It should be appended to the 'messages' slice if it's not already the last element.
|
||||
if len(messages) == 0 || (len(messages) > 0 && messages[len(messages)-1].ID != userMsg.ID) {
|
||||
messages = append(messages, userMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
agentMessage, toolResults, err := a.streamAndHandleEvents(ctx, sessionID, messages)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
agentMessage.AddFinish(message.FinishReasonCanceled)
|
||||
a.messages.Update(context.Background(), agentMessage)
|
||||
return a.err(ErrRequestCancelled)
|
||||
}
|
||||
return a.err(fmt.Errorf("failed to process events: %w", err))
|
||||
}
|
||||
slog.Info("Result", "message", agentMessage.FinishReason(), "toolResults", toolResults)
|
||||
if (agentMessage.FinishReason() == message.FinishReasonToolUse) && toolResults != nil {
|
||||
// We are not done, we need to respond with the tool response
|
||||
messages = append(messages, agentMessage, *toolResults)
|
||||
continue
|
||||
}
|
||||
return AgentEvent{
|
||||
message: agentMessage,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *agent) createUserMessage(ctx context.Context, sessionID, content string, attachmentParts []message.ContentPart) (message.Message, error) {
|
||||
parts := []message.ContentPart{message.TextContent{Text: content}}
|
||||
parts = append(parts, attachmentParts...)
|
||||
return a.messages.Create(ctx, sessionID, message.CreateMessageParams{
|
||||
Role: message.User,
|
||||
Parts: parts,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *agent) createToolResponseMessage(ctx context.Context, sessionID string, toolResults []message.ToolResult) (*message.Message, error) {
|
||||
if len(toolResults) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
parts := make([]message.ContentPart, 0, len(toolResults))
|
||||
for _, tr := range toolResults {
|
||||
parts = append(parts, tr)
|
||||
}
|
||||
|
||||
msg, err := a.messages.Create(ctx, sessionID, message.CreateMessageParams{
|
||||
Role: message.Tool,
|
||||
Parts: parts,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create tool response message: %w", err)
|
||||
}
|
||||
|
||||
return &msg, nil
|
||||
}
|
||||
|
||||
func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msgHistory []message.Message) (message.Message, *message.Message, error) {
|
||||
eventChan := a.provider.StreamResponse(ctx, msgHistory, a.tools)
|
||||
|
||||
assistantMsg, err := a.messages.Create(ctx, sessionID, message.CreateMessageParams{
|
||||
Role: message.Assistant,
|
||||
Parts: []message.ContentPart{},
|
||||
Model: a.provider.Model().ID,
|
||||
})
|
||||
if err != nil {
|
||||
return assistantMsg, nil, fmt.Errorf("failed to create assistant message: %w", err)
|
||||
}
|
||||
|
||||
// Add the session and message ID into the context if needed by tools.
|
||||
ctx = context.WithValue(ctx, tools.MessageIDContextKey, assistantMsg.ID)
|
||||
ctx = context.WithValue(ctx, tools.SessionIDContextKey, sessionID)
|
||||
|
||||
// Process each event in the stream.
|
||||
for event := range eventChan {
|
||||
if processErr := a.processEvent(ctx, sessionID, &assistantMsg, event); processErr != nil {
|
||||
a.finishMessage(ctx, &assistantMsg, message.FinishReasonCanceled)
|
||||
return assistantMsg, nil, processErr
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled)
|
||||
return assistantMsg, nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// If the assistant wants to use tools, execute them
|
||||
if assistantMsg.FinishReason() == message.FinishReasonToolUse {
|
||||
toolCalls := assistantMsg.ToolCalls()
|
||||
if len(toolCalls) > 0 {
|
||||
toolResults, err := a.executeToolCalls(ctx, toolCalls)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled)
|
||||
}
|
||||
return assistantMsg, nil, err
|
||||
}
|
||||
|
||||
// Create a message with the tool results
|
||||
toolResponseMsg, err := a.createToolResponseMessage(ctx, sessionID, toolResults)
|
||||
if err != nil {
|
||||
return assistantMsg, nil, err
|
||||
}
|
||||
|
||||
return assistantMsg, toolResponseMsg, nil
|
||||
}
|
||||
}
|
||||
|
||||
return assistantMsg, nil, nil
|
||||
}
|
||||
|
||||
func (a *agent) executeToolCalls(ctx context.Context, toolCalls []message.ToolCall) ([]message.ToolResult, error) {
|
||||
toolResults := make([]message.ToolResult, len(toolCalls))
|
||||
|
||||
for i, toolCall := range toolCalls {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Make all future tool calls cancelled
|
||||
for j := i; j < len(toolCalls); j++ {
|
||||
toolResults[j] = message.ToolResult{
|
||||
ToolCallID: toolCalls[j].ID,
|
||||
Content: "Tool execution canceled by user",
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
return toolResults, ctx.Err()
|
||||
default:
|
||||
// Continue processing
|
||||
var tool tools.BaseTool
|
||||
for _, availableTools := range a.tools {
|
||||
if availableTools.Info().Name == toolCall.Name {
|
||||
tool = availableTools
|
||||
}
|
||||
}
|
||||
|
||||
// Tool not found
|
||||
if tool == nil {
|
||||
toolResults[i] = message.ToolResult{
|
||||
ToolCallID: toolCall.ID,
|
||||
Content: fmt.Sprintf("Tool not found: %s", toolCall.Name),
|
||||
IsError: true,
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
toolResult, toolErr := tool.Run(ctx, tools.ToolCall{
|
||||
ID: toolCall.ID,
|
||||
Name: toolCall.Name,
|
||||
Input: toolCall.Input,
|
||||
})
|
||||
|
||||
if toolErr != nil {
|
||||
if errors.Is(toolErr, permission.ErrorPermissionDenied) {
|
||||
toolResults[i] = message.ToolResult{
|
||||
ToolCallID: toolCall.ID,
|
||||
Content: "Permission denied",
|
||||
IsError: true,
|
||||
}
|
||||
// Cancel all remaining tool calls if permission is denied
|
||||
for j := i + 1; j < len(toolCalls); j++ {
|
||||
toolResults[j] = message.ToolResult{
|
||||
ToolCallID: toolCalls[j].ID,
|
||||
Content: "Tool execution canceled by user",
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
return toolResults, nil
|
||||
}
|
||||
|
||||
// Handle other errors
|
||||
toolResults[i] = message.ToolResult{
|
||||
ToolCallID: toolCall.ID,
|
||||
Content: toolErr.Error(),
|
||||
IsError: true,
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
toolResults[i] = message.ToolResult{
|
||||
ToolCallID: toolCall.ID,
|
||||
Content: toolResult.Content,
|
||||
Metadata: toolResult.Metadata,
|
||||
IsError: toolResult.IsError,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return toolResults, nil
|
||||
}
|
||||
|
||||
func (a *agent) finishMessage(ctx context.Context, msg *message.Message, finishReson message.FinishReason) {
|
||||
msg.AddFinish(finishReson)
|
||||
_, _ = a.messages.Update(ctx, *msg)
|
||||
}
|
||||
|
||||
func (a *agent) processEvent(ctx context.Context, sessionID string, assistantMsg *message.Message, event provider.ProviderEvent) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
// Continue processing
|
||||
}
|
||||
|
||||
switch event.Type {
|
||||
case provider.EventThinkingDelta:
|
||||
assistantMsg.AppendReasoningContent(event.Content)
|
||||
_, err := a.messages.Update(ctx, *assistantMsg)
|
||||
return err
|
||||
case provider.EventContentDelta:
|
||||
assistantMsg.AppendContent(event.Content)
|
||||
_, err := a.messages.Update(ctx, *assistantMsg)
|
||||
return err
|
||||
case provider.EventToolUseStart:
|
||||
assistantMsg.AddToolCall(*event.ToolCall)
|
||||
_, err := a.messages.Update(ctx, *assistantMsg)
|
||||
return err
|
||||
// TODO: see how to handle this
|
||||
// case provider.EventToolUseDelta:
|
||||
// tm := time.Unix(assistantMsg.UpdatedAt, 0)
|
||||
// assistantMsg.AppendToolCallInput(event.ToolCall.ID, event.ToolCall.Input)
|
||||
// if time.Since(tm) > 1000*time.Millisecond {
|
||||
// err := a.messages.Update(ctx, *assistantMsg)
|
||||
// assistantMsg.UpdatedAt = time.Now().Unix()
|
||||
// return err
|
||||
// }
|
||||
case provider.EventToolUseStop:
|
||||
assistantMsg.FinishToolCall(event.ToolCall.ID)
|
||||
_, err := a.messages.Update(ctx, *assistantMsg)
|
||||
return err
|
||||
case provider.EventError:
|
||||
if errors.Is(event.Error, context.Canceled) {
|
||||
status.Info(fmt.Sprintf("Event processing canceled for session: %s", sessionID))
|
||||
return context.Canceled
|
||||
}
|
||||
status.Error(event.Error.Error())
|
||||
return event.Error
|
||||
case provider.EventComplete:
|
||||
assistantMsg.SetToolCalls(event.Response.ToolCalls)
|
||||
assistantMsg.AddFinish(event.Response.FinishReason)
|
||||
if _, err := a.messages.Update(ctx, *assistantMsg); err != nil {
|
||||
return fmt.Errorf("failed to update message: %w", err)
|
||||
}
|
||||
return a.TrackUsage(ctx, sessionID, a.provider.Model(), event.Response.Usage)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *agent) GetUsage(ctx context.Context, sessionID string) (*int64, error) {
|
||||
session, err := a.sessions.Get(ctx, sessionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get session: %w", err)
|
||||
}
|
||||
|
||||
usage := session.PromptTokens + session.CompletionTokens
|
||||
return &usage, nil
|
||||
}
|
||||
|
||||
func (a *agent) EstimateContextWindowUsage(ctx context.Context, sessionID string) (float64, bool, error) {
|
||||
session, err := a.sessions.Get(ctx, sessionID)
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("failed to get session: %w", err)
|
||||
}
|
||||
|
||||
// Get the model's context window size
|
||||
model := a.provider.Model()
|
||||
contextWindow := model.ContextWindow
|
||||
if contextWindow <= 0 {
|
||||
// Default to a reasonable size if not specified
|
||||
contextWindow = 100000
|
||||
}
|
||||
|
||||
// Calculate current token usage
|
||||
currentTokens := session.PromptTokens + session.CompletionTokens
|
||||
|
||||
// Get the max tokens setting for the agent
|
||||
maxTokens := a.provider.MaxTokens()
|
||||
|
||||
// Calculate percentage of context window used
|
||||
usagePercentage := float64(currentTokens) / float64(contextWindow)
|
||||
|
||||
// Check if we need to auto-compact
|
||||
// Auto-compact when:
|
||||
// 1. Usage exceeds 90% of context window, OR
|
||||
// 2. Current usage + maxTokens would exceed 100% of context window
|
||||
needsCompaction := usagePercentage >= 0.9 ||
|
||||
float64(currentTokens+maxTokens) > float64(contextWindow)
|
||||
|
||||
return usagePercentage * 100, needsCompaction, nil
|
||||
}
|
||||
|
||||
func (a *agent) TrackUsage(ctx context.Context, sessionID string, model models.Model, usage provider.TokenUsage) error {
|
||||
sess, err := a.sessions.Get(ctx, sessionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get session: %w", err)
|
||||
}
|
||||
|
||||
cost := model.CostPer1MInCached/1e6*float64(usage.CacheCreationTokens) +
|
||||
model.CostPer1MOutCached/1e6*float64(usage.CacheReadTokens) +
|
||||
model.CostPer1MIn/1e6*float64(usage.InputTokens) +
|
||||
model.CostPer1MOut/1e6*float64(usage.OutputTokens)
|
||||
|
||||
sess.Cost += cost
|
||||
sess.CompletionTokens = usage.OutputTokens + usage.CacheReadTokens
|
||||
sess.PromptTokens = usage.InputTokens + usage.CacheCreationTokens
|
||||
|
||||
_, err = a.sessions.Update(ctx, sess)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *agent) Update(agentName config.AgentName, modelID models.ModelID) (models.Model, error) {
|
||||
if a.IsBusy() {
|
||||
return models.Model{}, fmt.Errorf("cannot change model while processing requests")
|
||||
}
|
||||
|
||||
if err := config.UpdateAgentModel(agentName, modelID); err != nil {
|
||||
return models.Model{}, fmt.Errorf("failed to update config: %w", err)
|
||||
}
|
||||
|
||||
provider, err := createAgentProvider(agentName)
|
||||
if err != nil {
|
||||
return models.Model{}, fmt.Errorf("failed to create provider for model %s: %w", modelID, err)
|
||||
}
|
||||
|
||||
a.provider = provider
|
||||
|
||||
return a.provider.Model(), nil
|
||||
}
|
||||
|
||||
func (a *agent) CompactSession(ctx context.Context, sessionID string, force bool) error {
|
||||
// Check if the session is busy
|
||||
if a.IsSessionBusy(sessionID) && !force {
|
||||
return ErrSessionBusy
|
||||
}
|
||||
|
||||
// Create a cancellable context
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
// Mark the session as busy during compaction
|
||||
compactionCancelFunc := func() {}
|
||||
a.activeRequests.Store(sessionID+"-compact", compactionCancelFunc)
|
||||
defer a.activeRequests.Delete(sessionID + "-compact")
|
||||
|
||||
// Fetch the session
|
||||
session, err := a.sessions.Get(ctx, sessionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get session: %w", err)
|
||||
}
|
||||
|
||||
// Fetch all messages for the session
|
||||
sessionMessages, err := a.messages.List(ctx, sessionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list messages: %w", err)
|
||||
}
|
||||
|
||||
var existingSummary string
|
||||
if session.Summary != "" && !session.SummarizedAt.IsZero() {
|
||||
// Filter messages that were created after the last summarization
|
||||
var newMessages []message.Message
|
||||
for _, msg := range sessionMessages {
|
||||
if msg.CreatedAt.After(session.SummarizedAt) {
|
||||
newMessages = append(newMessages, msg)
|
||||
}
|
||||
}
|
||||
sessionMessages = newMessages
|
||||
existingSummary = session.Summary
|
||||
}
|
||||
|
||||
// If there are no messages to summarize and no existing summary, return early
|
||||
if len(sessionMessages) == 0 && existingSummary == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
messages := []message.Message{
|
||||
message.Message{
|
||||
Role: message.System,
|
||||
Parts: []message.ContentPart{
|
||||
message.TextContent{
|
||||
Text: `You are a helpful AI assistant tasked with summarizing conversations.
|
||||
|
||||
When asked to summarize, provide a detailed but concise summary of the conversation.
|
||||
Focus on information that would be helpful for continuing the conversation, including:
|
||||
- What was done
|
||||
- What is currently being worked on
|
||||
- Which files are being modified
|
||||
- What needs to be done next
|
||||
|
||||
Your summary should be comprehensive enough to provide context but concise enough to be quickly understood.`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// If there's an existing summary, include it
|
||||
if existingSummary != "" {
|
||||
messages = append(messages, message.Message{
|
||||
Role: message.Assistant,
|
||||
Parts: []message.ContentPart{
|
||||
message.TextContent{
|
||||
Text: existingSummary,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Add all messages since the last summarized message
|
||||
messages = append(messages, sessionMessages...)
|
||||
|
||||
// Add a final user message requesting the summary
|
||||
messages = append(messages, message.Message{
|
||||
Role: message.User,
|
||||
Parts: []message.ContentPart{
|
||||
message.TextContent{
|
||||
Text: "Provide a detailed but concise summary of our conversation above. Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next.",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Call provider to get the summary
|
||||
response, err := a.provider.SendMessages(ctx, messages, a.tools)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get summary from the assistant: %w", err)
|
||||
}
|
||||
|
||||
// Extract the summary text
|
||||
summaryText := strings.TrimSpace(response.Content)
|
||||
if summaryText == "" {
|
||||
return fmt.Errorf("received empty summary from the assistant")
|
||||
}
|
||||
|
||||
// Update the session with the new summary
|
||||
session.Summary = summaryText
|
||||
session.SummarizedAt = time.Now()
|
||||
|
||||
// Save the updated session
|
||||
_, err = a.sessions.Update(ctx, session)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save session with summary: %w", err)
|
||||
}
|
||||
|
||||
// Track token usage
|
||||
err = a.TrackUsage(ctx, sessionID, a.provider.Model(), response.Usage)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to track usage: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func createAgentProvider(agentName config.AgentName) (provider.Provider, error) {
|
||||
cfg := config.Get()
|
||||
agentConfig, ok := cfg.Agents[agentName]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("agent %s not found", agentName)
|
||||
}
|
||||
model, ok := models.SupportedModels[agentConfig.Model]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("model %s not supported", agentConfig.Model)
|
||||
}
|
||||
|
||||
providerCfg, ok := cfg.Providers[model.Provider]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("provider %s not supported", model.Provider)
|
||||
}
|
||||
if providerCfg.Disabled {
|
||||
return nil, fmt.Errorf("provider %s is not enabled", model.Provider)
|
||||
}
|
||||
maxTokens := model.DefaultMaxTokens
|
||||
if agentConfig.MaxTokens > 0 {
|
||||
maxTokens = agentConfig.MaxTokens
|
||||
}
|
||||
opts := []provider.ProviderClientOption{
|
||||
provider.WithAPIKey(providerCfg.APIKey),
|
||||
provider.WithModel(model),
|
||||
provider.WithSystemMessage(prompt.GetAgentPrompt(agentName, model.Provider)),
|
||||
provider.WithMaxTokens(maxTokens),
|
||||
}
|
||||
if model.Provider == models.ProviderOpenAI && model.CanReason {
|
||||
opts = append(
|
||||
opts,
|
||||
provider.WithOpenAIOptions(
|
||||
provider.WithReasoningEffort(agentConfig.ReasoningEffort),
|
||||
),
|
||||
)
|
||||
} else if model.Provider == models.ProviderAnthropic && model.CanReason && agentName == config.AgentPrimary {
|
||||
opts = append(
|
||||
opts,
|
||||
provider.WithAnthropicOptions(
|
||||
provider.WithAnthropicShouldThinkFn(provider.DefaultShouldThinkFn),
|
||||
),
|
||||
)
|
||||
}
|
||||
agentProvider, err := provider.NewProvider(
|
||||
model.Provider,
|
||||
opts...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not create provider: %v", err)
|
||||
}
|
||||
|
||||
return agentProvider, nil
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/sst/opencode/internal/config"
|
||||
"github.com/sst/opencode/internal/llm/tools"
|
||||
"github.com/sst/opencode/internal/permission"
|
||||
"github.com/sst/opencode/internal/version"
|
||||
"log/slog"
|
||||
|
||||
"github.com/mark3labs/mcp-go/client"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
)
|
||||
|
||||
type mcpTool struct {
|
||||
mcpName string
|
||||
tool mcp.Tool
|
||||
mcpConfig config.MCPServer
|
||||
permissions permission.Service
|
||||
}
|
||||
|
||||
type MCPClient interface {
|
||||
Initialize(
|
||||
ctx context.Context,
|
||||
request mcp.InitializeRequest,
|
||||
) (*mcp.InitializeResult, error)
|
||||
ListTools(ctx context.Context, request mcp.ListToolsRequest) (*mcp.ListToolsResult, error)
|
||||
CallTool(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
func (b *mcpTool) Info() tools.ToolInfo {
|
||||
return tools.ToolInfo{
|
||||
Name: fmt.Sprintf("%s_%s", b.mcpName, b.tool.Name),
|
||||
Description: b.tool.Description,
|
||||
Parameters: b.tool.InputSchema.Properties,
|
||||
Required: b.tool.InputSchema.Required,
|
||||
}
|
||||
}
|
||||
|
||||
func runTool(ctx context.Context, c MCPClient, toolName string, input string) (tools.ToolResponse, error) {
|
||||
defer c.Close()
|
||||
initRequest := mcp.InitializeRequest{}
|
||||
initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
|
||||
initRequest.Params.ClientInfo = mcp.Implementation{
|
||||
Name: "OpenCode",
|
||||
Version: version.Version,
|
||||
}
|
||||
|
||||
_, err := c.Initialize(ctx, initRequest)
|
||||
if err != nil {
|
||||
return tools.NewTextErrorResponse(err.Error()), nil
|
||||
}
|
||||
|
||||
toolRequest := mcp.CallToolRequest{}
|
||||
toolRequest.Params.Name = toolName
|
||||
var args map[string]any
|
||||
if err = json.Unmarshal([]byte(input), &args); err != nil {
|
||||
return tools.NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
|
||||
}
|
||||
toolRequest.Params.Arguments = args
|
||||
result, err := c.CallTool(ctx, toolRequest)
|
||||
if err != nil {
|
||||
return tools.NewTextErrorResponse(err.Error()), nil
|
||||
}
|
||||
|
||||
output := ""
|
||||
for _, v := range result.Content {
|
||||
if v, ok := v.(mcp.TextContent); ok {
|
||||
output = v.Text
|
||||
} else {
|
||||
output = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
|
||||
return tools.NewTextResponse(output), nil
|
||||
}
|
||||
|
||||
func (b *mcpTool) Run(ctx context.Context, params tools.ToolCall) (tools.ToolResponse, error) {
|
||||
sessionID, messageID := tools.GetContextValues(ctx)
|
||||
if sessionID == "" || messageID == "" {
|
||||
return tools.ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
|
||||
}
|
||||
permissionDescription := fmt.Sprintf("execute %s with the following parameters: %s", b.Info().Name, params.Input)
|
||||
p := b.permissions.Request(
|
||||
ctx,
|
||||
permission.CreatePermissionRequest{
|
||||
SessionID: sessionID,
|
||||
Path: config.WorkingDirectory(),
|
||||
ToolName: b.Info().Name,
|
||||
Action: "execute",
|
||||
Description: permissionDescription,
|
||||
Params: params.Input,
|
||||
},
|
||||
)
|
||||
if !p {
|
||||
return tools.NewTextErrorResponse("permission denied"), nil
|
||||
}
|
||||
|
||||
switch b.mcpConfig.Type {
|
||||
case config.MCPStdio:
|
||||
c, err := client.NewStdioMCPClient(
|
||||
b.mcpConfig.Command,
|
||||
b.mcpConfig.Env,
|
||||
b.mcpConfig.Args...,
|
||||
)
|
||||
if err != nil {
|
||||
return tools.NewTextErrorResponse(err.Error()), nil
|
||||
}
|
||||
return runTool(ctx, c, b.tool.Name, params.Input)
|
||||
case config.MCPSse:
|
||||
c, err := client.NewSSEMCPClient(
|
||||
b.mcpConfig.URL,
|
||||
client.WithHeaders(b.mcpConfig.Headers),
|
||||
)
|
||||
if err != nil {
|
||||
return tools.NewTextErrorResponse(err.Error()), nil
|
||||
}
|
||||
return runTool(ctx, c, b.tool.Name, params.Input)
|
||||
}
|
||||
|
||||
return tools.NewTextErrorResponse("invalid mcp type"), nil
|
||||
}
|
||||
|
||||
func NewMcpTool(name string, tool mcp.Tool, permissions permission.Service, mcpConfig config.MCPServer) tools.BaseTool {
|
||||
return &mcpTool{
|
||||
mcpName: name,
|
||||
tool: tool,
|
||||
mcpConfig: mcpConfig,
|
||||
permissions: permissions,
|
||||
}
|
||||
}
|
||||
|
||||
var mcpTools []tools.BaseTool
|
||||
|
||||
func getTools(ctx context.Context, name string, m config.MCPServer, permissions permission.Service, c MCPClient) []tools.BaseTool {
|
||||
var stdioTools []tools.BaseTool
|
||||
initRequest := mcp.InitializeRequest{}
|
||||
initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
|
||||
initRequest.Params.ClientInfo = mcp.Implementation{
|
||||
Name: "OpenCode",
|
||||
Version: version.Version,
|
||||
}
|
||||
|
||||
_, err := c.Initialize(ctx, initRequest)
|
||||
if err != nil {
|
||||
slog.Error("error initializing mcp client", "error", err)
|
||||
return stdioTools
|
||||
}
|
||||
toolsRequest := mcp.ListToolsRequest{}
|
||||
tools, err := c.ListTools(ctx, toolsRequest)
|
||||
if err != nil {
|
||||
slog.Error("error listing tools", "error", err)
|
||||
return stdioTools
|
||||
}
|
||||
for _, t := range tools.Tools {
|
||||
stdioTools = append(stdioTools, NewMcpTool(name, t, permissions, m))
|
||||
}
|
||||
defer c.Close()
|
||||
return stdioTools
|
||||
}
|
||||
|
||||
func GetMcpTools(ctx context.Context, permissions permission.Service) []tools.BaseTool {
|
||||
if len(mcpTools) > 0 {
|
||||
return mcpTools
|
||||
}
|
||||
for name, m := range config.Get().MCPServers {
|
||||
switch m.Type {
|
||||
case config.MCPStdio:
|
||||
c, err := client.NewStdioMCPClient(
|
||||
m.Command,
|
||||
m.Env,
|
||||
m.Args...,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("error creating mcp client", "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
mcpTools = append(mcpTools, getTools(ctx, name, m, permissions, c)...)
|
||||
case config.MCPSse:
|
||||
c, err := client.NewSSEMCPClient(
|
||||
m.URL,
|
||||
client.WithHeaders(m.Headers),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("error creating mcp client", "error", err)
|
||||
continue
|
||||
}
|
||||
mcpTools = append(mcpTools, getTools(ctx, name, m, permissions, c)...)
|
||||
}
|
||||
}
|
||||
|
||||
return mcpTools
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/sst/opencode/internal/history"
|
||||
"github.com/sst/opencode/internal/llm/tools"
|
||||
"github.com/sst/opencode/internal/lsp"
|
||||
"github.com/sst/opencode/internal/message"
|
||||
"github.com/sst/opencode/internal/permission"
|
||||
"github.com/sst/opencode/internal/session"
|
||||
)
|
||||
|
||||
func PrimaryAgentTools(
|
||||
permissions permission.Service,
|
||||
sessions session.Service,
|
||||
messages message.Service,
|
||||
history history.Service,
|
||||
lspClients map[string]*lsp.Client,
|
||||
) []tools.BaseTool {
|
||||
ctx := context.Background()
|
||||
mcpTools := GetMcpTools(ctx, permissions)
|
||||
|
||||
return append(
|
||||
[]tools.BaseTool{
|
||||
tools.NewBashTool(permissions),
|
||||
tools.NewEditTool(lspClients, permissions, history),
|
||||
tools.NewFetchTool(permissions),
|
||||
tools.NewGlobTool(),
|
||||
tools.NewGrepTool(),
|
||||
tools.NewLsTool(),
|
||||
tools.NewViewTool(lspClients),
|
||||
tools.NewPatchTool(lspClients, permissions, history),
|
||||
tools.NewWriteTool(lspClients, permissions, history),
|
||||
tools.NewDiagnosticsTool(lspClients),
|
||||
tools.NewDefinitionTool(lspClients),
|
||||
tools.NewReferencesTool(lspClients),
|
||||
tools.NewDocSymbolsTool(lspClients),
|
||||
tools.NewWorkspaceSymbolsTool(lspClients),
|
||||
tools.NewCodeActionTool(lspClients),
|
||||
NewAgentTool(sessions, messages, lspClients),
|
||||
}, mcpTools...,
|
||||
)
|
||||
}
|
||||
|
||||
func TaskAgentTools(lspClients map[string]*lsp.Client) []tools.BaseTool {
|
||||
return []tools.BaseTool{
|
||||
tools.NewGlobTool(),
|
||||
tools.NewGrepTool(),
|
||||
tools.NewLsTool(),
|
||||
tools.NewViewTool(lspClients),
|
||||
tools.NewDefinitionTool(lspClients),
|
||||
tools.NewReferencesTool(lspClients),
|
||||
tools.NewDocSymbolsTool(lspClients),
|
||||
tools.NewWorkspaceSymbolsTool(lspClients),
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
package models
|
||||
|
||||
const (
|
||||
ProviderAnthropic ModelProvider = "anthropic"
|
||||
|
||||
// Models
|
||||
Claude35Sonnet ModelID = "claude-3.5-sonnet"
|
||||
Claude3Haiku ModelID = "claude-3-haiku"
|
||||
Claude37Sonnet ModelID = "claude-3.7-sonnet"
|
||||
Claude35Haiku ModelID = "claude-3.5-haiku"
|
||||
Claude3Opus ModelID = "claude-3-opus"
|
||||
)
|
||||
|
||||
// https://docs.anthropic.com/en/docs/about-claude/models/all-models
|
||||
var AnthropicModels = map[ModelID]Model{
|
||||
Claude35Sonnet: {
|
||||
ID: Claude35Sonnet,
|
||||
Name: "Claude 3.5 Sonnet",
|
||||
Provider: ProviderAnthropic,
|
||||
APIModel: "claude-3-5-sonnet-latest",
|
||||
CostPer1MIn: 3.0,
|
||||
CostPer1MInCached: 3.75,
|
||||
CostPer1MOutCached: 0.30,
|
||||
CostPer1MOut: 15.0,
|
||||
ContextWindow: 200000,
|
||||
DefaultMaxTokens: 5000,
|
||||
SupportsAttachments: true,
|
||||
},
|
||||
Claude3Haiku: {
|
||||
ID: Claude3Haiku,
|
||||
Name: "Claude 3 Haiku",
|
||||
Provider: ProviderAnthropic,
|
||||
APIModel: "claude-3-haiku-20240307", // doesn't support "-latest"
|
||||
CostPer1MIn: 0.25,
|
||||
CostPer1MInCached: 0.30,
|
||||
CostPer1MOutCached: 0.03,
|
||||
CostPer1MOut: 1.25,
|
||||
ContextWindow: 200000,
|
||||
DefaultMaxTokens: 4096,
|
||||
SupportsAttachments: true,
|
||||
},
|
||||
Claude37Sonnet: {
|
||||
ID: Claude37Sonnet,
|
||||
Name: "Claude 3.7 Sonnet",
|
||||
Provider: ProviderAnthropic,
|
||||
APIModel: "claude-3-7-sonnet-latest",
|
||||
CostPer1MIn: 3.0,
|
||||
CostPer1MInCached: 3.75,
|
||||
CostPer1MOutCached: 0.30,
|
||||
CostPer1MOut: 15.0,
|
||||
ContextWindow: 200000,
|
||||
DefaultMaxTokens: 50000,
|
||||
CanReason: true,
|
||||
SupportsAttachments: true,
|
||||
},
|
||||
Claude35Haiku: {
|
||||
ID: Claude35Haiku,
|
||||
Name: "Claude 3.5 Haiku",
|
||||
Provider: ProviderAnthropic,
|
||||
APIModel: "claude-3-5-haiku-latest",
|
||||
CostPer1MIn: 0.80,
|
||||
CostPer1MInCached: 1.0,
|
||||
CostPer1MOutCached: 0.08,
|
||||
CostPer1MOut: 4.0,
|
||||
ContextWindow: 200000,
|
||||
DefaultMaxTokens: 4096,
|
||||
SupportsAttachments: true,
|
||||
},
|
||||
Claude3Opus: {
|
||||
ID: Claude3Opus,
|
||||
Name: "Claude 3 Opus",
|
||||
Provider: ProviderAnthropic,
|
||||
APIModel: "claude-3-opus-latest",
|
||||
CostPer1MIn: 15.0,
|
||||
CostPer1MInCached: 18.75,
|
||||
CostPer1MOutCached: 1.50,
|
||||
CostPer1MOut: 75.0,
|
||||
ContextWindow: 200000,
|
||||
DefaultMaxTokens: 4096,
|
||||
SupportsAttachments: true,
|
||||
},
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
package models
|
||||
|
||||
const ProviderAzure ModelProvider = "azure"
|
||||
|
||||
const (
|
||||
AzureGPT41 ModelID = "azure.gpt-4.1"
|
||||
AzureGPT41Mini ModelID = "azure.gpt-4.1-mini"
|
||||
AzureGPT41Nano ModelID = "azure.gpt-4.1-nano"
|
||||
AzureGPT45Preview ModelID = "azure.gpt-4.5-preview"
|
||||
AzureGPT4o ModelID = "azure.gpt-4o"
|
||||
AzureGPT4oMini ModelID = "azure.gpt-4o-mini"
|
||||
AzureO1 ModelID = "azure.o1"
|
||||
AzureO1Mini ModelID = "azure.o1-mini"
|
||||
AzureO3 ModelID = "azure.o3"
|
||||
AzureO3Mini ModelID = "azure.o3-mini"
|
||||
AzureO4Mini ModelID = "azure.o4-mini"
|
||||
)
|
||||
|
||||
var AzureModels = map[ModelID]Model{
|
||||
AzureGPT41: {
|
||||
ID: AzureGPT41,
|
||||
Name: "Azure OpenAI – GPT 4.1",
|
||||
Provider: ProviderAzure,
|
||||
APIModel: "gpt-4.1",
|
||||
CostPer1MIn: OpenAIModels[GPT41].CostPer1MIn,
|
||||
CostPer1MInCached: OpenAIModels[GPT41].CostPer1MInCached,
|
||||
CostPer1MOut: OpenAIModels[GPT41].CostPer1MOut,
|
||||
CostPer1MOutCached: OpenAIModels[GPT41].CostPer1MOutCached,
|
||||
ContextWindow: OpenAIModels[GPT41].ContextWindow,
|
||||
DefaultMaxTokens: OpenAIModels[GPT41].DefaultMaxTokens,
|
||||
SupportsAttachments: true,
|
||||
},
|
||||
AzureGPT41Mini: {
|
||||
ID: AzureGPT41Mini,
|
||||
Name: "Azure OpenAI – GPT 4.1 mini",
|
||||
Provider: ProviderAzure,
|
||||
APIModel: "gpt-4.1-mini",
|
||||
CostPer1MIn: OpenAIModels[GPT41Mini].CostPer1MIn,
|
||||
CostPer1MInCached: OpenAIModels[GPT41Mini].CostPer1MInCached,
|
||||
CostPer1MOut: OpenAIModels[GPT41Mini].CostPer1MOut,
|
||||
CostPer1MOutCached: OpenAIModels[GPT41Mini].CostPer1MOutCached,
|
||||
ContextWindow: OpenAIModels[GPT41Mini].ContextWindow,
|
||||
DefaultMaxTokens: OpenAIModels[GPT41Mini].DefaultMaxTokens,
|
||||
SupportsAttachments: true,
|
||||
},
|
||||
AzureGPT41Nano: {
|
||||
ID: AzureGPT41Nano,
|
||||
Name: "Azure OpenAI – GPT 4.1 nano",
|
||||
Provider: ProviderAzure,
|
||||
APIModel: "gpt-4.1-nano",
|
||||
CostPer1MIn: OpenAIModels[GPT41Nano].CostPer1MIn,
|
||||
CostPer1MInCached: OpenAIModels[GPT41Nano].CostPer1MInCached,
|
||||
CostPer1MOut: OpenAIModels[GPT41Nano].CostPer1MOut,
|
||||
CostPer1MOutCached: OpenAIModels[GPT41Nano].CostPer1MOutCached,
|
||||
ContextWindow: OpenAIModels[GPT41Nano].ContextWindow,
|
||||
DefaultMaxTokens: OpenAIModels[GPT41Nano].DefaultMaxTokens,
|
||||
SupportsAttachments: true,
|
||||
},
|
||||
AzureGPT45Preview: {
|
||||
ID: AzureGPT45Preview,
|
||||
Name: "Azure OpenAI – GPT 4.5 preview",
|
||||
Provider: ProviderAzure,
|
||||
APIModel: "gpt-4.5-preview",
|
||||
CostPer1MIn: OpenAIModels[GPT45Preview].CostPer1MIn,
|
||||
CostPer1MInCached: OpenAIModels[GPT45Preview].CostPer1MInCached,
|
||||
CostPer1MOut: OpenAIModels[GPT45Preview].CostPer1MOut,
|
||||
CostPer1MOutCached: OpenAIModels[GPT45Preview].CostPer1MOutCached,
|
||||
ContextWindow: OpenAIModels[GPT45Preview].ContextWindow,
|
||||
DefaultMaxTokens: OpenAIModels[GPT45Preview].DefaultMaxTokens,
|
||||
SupportsAttachments: true,
|
||||
},
|
||||
AzureGPT4o: {
|
||||
ID: AzureGPT4o,
|
||||
Name: "Azure OpenAI – GPT-4o",
|
||||
Provider: ProviderAzure,
|
||||
APIModel: "gpt-4o",
|
||||
CostPer1MIn: OpenAIModels[GPT4o].CostPer1MIn,
|
||||
CostPer1MInCached: OpenAIModels[GPT4o].CostPer1MInCached,
|
||||
CostPer1MOut: OpenAIModels[GPT4o].CostPer1MOut,
|
||||
CostPer1MOutCached: OpenAIModels[GPT4o].CostPer1MOutCached,
|
||||
ContextWindow: OpenAIModels[GPT4o].ContextWindow,
|
||||
DefaultMaxTokens: OpenAIModels[GPT4o].DefaultMaxTokens,
|
||||
SupportsAttachments: true,
|
||||
},
|
||||
AzureGPT4oMini: {
|
||||
ID: AzureGPT4oMini,
|
||||
Name: "Azure OpenAI – GPT-4o mini",
|
||||
Provider: ProviderAzure,
|
||||
APIModel: "gpt-4o-mini",
|
||||
CostPer1MIn: OpenAIModels[GPT4oMini].CostPer1MIn,
|
||||
CostPer1MInCached: OpenAIModels[GPT4oMini].CostPer1MInCached,
|
||||
CostPer1MOut: OpenAIModels[GPT4oMini].CostPer1MOut,
|
||||
CostPer1MOutCached: OpenAIModels[GPT4oMini].CostPer1MOutCached,
|
||||
ContextWindow: OpenAIModels[GPT4oMini].ContextWindow,
|
||||
DefaultMaxTokens: OpenAIModels[GPT4oMini].DefaultMaxTokens,
|
||||
SupportsAttachments: true,
|
||||
},
|
||||
AzureO1: {
|
||||
ID: AzureO1,
|
||||
Name: "Azure OpenAI – O1",
|
||||
Provider: ProviderAzure,
|
||||
APIModel: "o1",
|
||||
CostPer1MIn: OpenAIModels[O1].CostPer1MIn,
|
||||
CostPer1MInCached: OpenAIModels[O1].CostPer1MInCached,
|
||||
CostPer1MOut: OpenAIModels[O1].CostPer1MOut,
|
||||
CostPer1MOutCached: OpenAIModels[O1].CostPer1MOutCached,
|
||||
ContextWindow: OpenAIModels[O1].ContextWindow,
|
||||
DefaultMaxTokens: OpenAIModels[O1].DefaultMaxTokens,
|
||||
CanReason: OpenAIModels[O1].CanReason,
|
||||
SupportsAttachments: true,
|
||||
},
|
||||
AzureO1Mini: {
|
||||
ID: AzureO1Mini,
|
||||
Name: "Azure OpenAI – O1 mini",
|
||||
Provider: ProviderAzure,
|
||||
APIModel: "o1-mini",
|
||||
CostPer1MIn: OpenAIModels[O1Mini].CostPer1MIn,
|
||||
CostPer1MInCached: OpenAIModels[O1Mini].CostPer1MInCached,
|
||||
CostPer1MOut: OpenAIModels[O1Mini].CostPer1MOut,
|
||||
CostPer1MOutCached: OpenAIModels[O1Mini].CostPer1MOutCached,
|
||||
ContextWindow: OpenAIModels[O1Mini].ContextWindow,
|
||||
DefaultMaxTokens: OpenAIModels[O1Mini].DefaultMaxTokens,
|
||||
CanReason: OpenAIModels[O1Mini].CanReason,
|
||||
SupportsAttachments: true,
|
||||
},
|
||||
AzureO3: {
|
||||
ID: AzureO3,
|
||||
Name: "Azure OpenAI – O3",
|
||||
Provider: ProviderAzure,
|
||||
APIModel: "o3",
|
||||
CostPer1MIn: OpenAIModels[O3].CostPer1MIn,
|
||||
CostPer1MInCached: OpenAIModels[O3].CostPer1MInCached,
|
||||
CostPer1MOut: OpenAIModels[O3].CostPer1MOut,
|
||||
CostPer1MOutCached: OpenAIModels[O3].CostPer1MOutCached,
|
||||
ContextWindow: OpenAIModels[O3].ContextWindow,
|
||||
DefaultMaxTokens: OpenAIModels[O3].DefaultMaxTokens,
|
||||
CanReason: OpenAIModels[O3].CanReason,
|
||||
SupportsAttachments: true,
|
||||
},
|
||||
AzureO3Mini: {
|
||||
ID: AzureO3Mini,
|
||||
Name: "Azure OpenAI – O3 mini",
|
||||
Provider: ProviderAzure,
|
||||
APIModel: "o3-mini",
|
||||
CostPer1MIn: OpenAIModels[O3Mini].CostPer1MIn,
|
||||
CostPer1MInCached: OpenAIModels[O3Mini].CostPer1MInCached,
|
||||
CostPer1MOut: OpenAIModels[O3Mini].CostPer1MOut,
|
||||
CostPer1MOutCached: OpenAIModels[O3Mini].CostPer1MOutCached,
|
||||
ContextWindow: OpenAIModels[O3Mini].ContextWindow,
|
||||
DefaultMaxTokens: OpenAIModels[O3Mini].DefaultMaxTokens,
|
||||
CanReason: OpenAIModels[O3Mini].CanReason,
|
||||
SupportsAttachments: false,
|
||||
},
|
||||
AzureO4Mini: {
|
||||
ID: AzureO4Mini,
|
||||
Name: "Azure OpenAI – O4 mini",
|
||||
Provider: ProviderAzure,
|
||||
APIModel: "o4-mini",
|
||||
CostPer1MIn: OpenAIModels[O4Mini].CostPer1MIn,
|
||||
CostPer1MInCached: OpenAIModels[O4Mini].CostPer1MInCached,
|
||||
CostPer1MOut: OpenAIModels[O4Mini].CostPer1MOut,
|
||||
CostPer1MOutCached: OpenAIModels[O4Mini].CostPer1MOutCached,
|
||||
ContextWindow: OpenAIModels[O4Mini].ContextWindow,
|
||||
DefaultMaxTokens: OpenAIModels[O4Mini].DefaultMaxTokens,
|
||||
CanReason: OpenAIModels[O4Mini].CanReason,
|
||||
SupportsAttachments: true,
|
||||
},
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
package models
|
||||
|
||||
const (
|
||||
ProviderGemini ModelProvider = "gemini"
|
||||
|
||||
// Models
|
||||
Gemini25Flash ModelID = "gemini-2.5-flash"
|
||||
Gemini25 ModelID = "gemini-2.5"
|
||||
Gemini20Flash ModelID = "gemini-2.0-flash"
|
||||
Gemini20FlashLite ModelID = "gemini-2.0-flash-lite"
|
||||
)
|
||||
|
||||
var GeminiModels = map[ModelID]Model{
|
||||
Gemini25Flash: {
|
||||
ID: Gemini25Flash,
|
||||
Name: "Gemini 2.5 Flash",
|
||||
Provider: ProviderGemini,
|
||||
APIModel: "gemini-2.5-flash-preview-04-17",
|
||||
CostPer1MIn: 0.15,
|
||||
CostPer1MInCached: 0,
|
||||
CostPer1MOutCached: 0,
|
||||
CostPer1MOut: 0.60,
|
||||
ContextWindow: 1000000,
|
||||
DefaultMaxTokens: 50000,
|
||||
SupportsAttachments: true,
|
||||
},
|
||||
Gemini25: {
|
||||
ID: Gemini25,
|
||||
Name: "Gemini 2.5 Pro",
|
||||
Provider: ProviderGemini,
|
||||
APIModel: "gemini-2.5-pro-preview-03-25",
|
||||
CostPer1MIn: 1.25,
|
||||
CostPer1MInCached: 0,
|
||||
CostPer1MOutCached: 0,
|
||||
CostPer1MOut: 10,
|
||||
ContextWindow: 1000000,
|
||||
DefaultMaxTokens: 50000,
|
||||
SupportsAttachments: true,
|
||||
},
|
||||
|
||||
Gemini20Flash: {
|
||||
ID: Gemini20Flash,
|
||||
Name: "Gemini 2.0 Flash",
|
||||
Provider: ProviderGemini,
|
||||
APIModel: "gemini-2.0-flash",
|
||||
CostPer1MIn: 0.10,
|
||||
CostPer1MInCached: 0,
|
||||
CostPer1MOutCached: 0,
|
||||
CostPer1MOut: 0.40,
|
||||
ContextWindow: 1000000,
|
||||
DefaultMaxTokens: 6000,
|
||||
SupportsAttachments: true,
|
||||
},
|
||||
Gemini20FlashLite: {
|
||||
ID: Gemini20FlashLite,
|
||||
Name: "Gemini 2.0 Flash Lite",
|
||||
Provider: ProviderGemini,
|
||||
APIModel: "gemini-2.0-flash-lite",
|
||||
CostPer1MIn: 0.05,
|
||||
CostPer1MInCached: 0,
|
||||
CostPer1MOutCached: 0,
|
||||
CostPer1MOut: 0.30,
|
||||
ContextWindow: 1000000,
|
||||
DefaultMaxTokens: 6000,
|
||||
SupportsAttachments: true,
|
||||
},
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
package models
|
||||
|
||||
const (
|
||||
ProviderGROQ ModelProvider = "groq"
|
||||
|
||||
// GROQ
|
||||
QWENQwq ModelID = "qwen-qwq"
|
||||
|
||||
// GROQ preview models
|
||||
Llama4Scout ModelID = "meta-llama/llama-4-scout-17b-16e-instruct"
|
||||
Llama4Maverick ModelID = "meta-llama/llama-4-maverick-17b-128e-instruct"
|
||||
Llama3_3_70BVersatile ModelID = "llama-3.3-70b-versatile"
|
||||
DeepseekR1DistillLlama70b ModelID = "deepseek-r1-distill-llama-70b"
|
||||
)
|
||||
|
||||
var GroqModels = map[ModelID]Model{
|
||||
//
|
||||
// GROQ
|
||||
QWENQwq: {
|
||||
ID: QWENQwq,
|
||||
Name: "Qwen Qwq",
|
||||
Provider: ProviderGROQ,
|
||||
APIModel: "qwen-qwq-32b",
|
||||
CostPer1MIn: 0.29,
|
||||
CostPer1MInCached: 0.275,
|
||||
CostPer1MOutCached: 0.0,
|
||||
CostPer1MOut: 0.39,
|
||||
ContextWindow: 128_000,
|
||||
DefaultMaxTokens: 50000,
|
||||
// for some reason, the groq api doesn't like the reasoningEffort parameter
|
||||
CanReason: false,
|
||||
SupportsAttachments: false,
|
||||
},
|
||||
|
||||
Llama4Scout: {
|
||||
ID: Llama4Scout,
|
||||
Name: "Llama4Scout",
|
||||
Provider: ProviderGROQ,
|
||||
APIModel: "meta-llama/llama-4-scout-17b-16e-instruct",
|
||||
CostPer1MIn: 0.11,
|
||||
CostPer1MInCached: 0,
|
||||
CostPer1MOutCached: 0,
|
||||
CostPer1MOut: 0.34,
|
||||
ContextWindow: 128_000, // 10M when?
|
||||
SupportsAttachments: true,
|
||||
},
|
||||
|
||||
Llama4Maverick: {
|
||||
ID: Llama4Maverick,
|
||||
Name: "Llama4Maverick",
|
||||
Provider: ProviderGROQ,
|
||||
APIModel: "meta-llama/llama-4-maverick-17b-128e-instruct",
|
||||
CostPer1MIn: 0.20,
|
||||
CostPer1MInCached: 0,
|
||||
CostPer1MOutCached: 0,
|
||||
CostPer1MOut: 0.20,
|
||||
ContextWindow: 128_000,
|
||||
SupportsAttachments: true,
|
||||
},
|
||||
|
||||
Llama3_3_70BVersatile: {
|
||||
ID: Llama3_3_70BVersatile,
|
||||
Name: "Llama3_3_70BVersatile",
|
||||
Provider: ProviderGROQ,
|
||||
APIModel: "llama-3.3-70b-versatile",
|
||||
CostPer1MIn: 0.59,
|
||||
CostPer1MInCached: 0,
|
||||
CostPer1MOutCached: 0,
|
||||
CostPer1MOut: 0.79,
|
||||
ContextWindow: 128_000,
|
||||
SupportsAttachments: false,
|
||||
},
|
||||
|
||||
DeepseekR1DistillLlama70b: {
|
||||
ID: DeepseekR1DistillLlama70b,
|
||||
Name: "DeepseekR1DistillLlama70b",
|
||||
Provider: ProviderGROQ,
|
||||
APIModel: "deepseek-r1-distill-llama-70b",
|
||||
CostPer1MIn: 0.75,
|
||||
CostPer1MInCached: 0,
|
||||
CostPer1MOutCached: 0,
|
||||
CostPer1MOut: 0.99,
|
||||
ContextWindow: 128_000,
|
||||
CanReason: true,
|
||||
SupportsAttachments: false,
|
||||
},
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
package models
|
||||
|
||||
import "maps"
|
||||
|
||||
type (
|
||||
ModelID string
|
||||
ModelProvider string
|
||||
)
|
||||
|
||||
type Model struct {
|
||||
ID ModelID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Provider ModelProvider `json:"provider"`
|
||||
APIModel string `json:"api_model"`
|
||||
CostPer1MIn float64 `json:"cost_per_1m_in"`
|
||||
CostPer1MOut float64 `json:"cost_per_1m_out"`
|
||||
CostPer1MInCached float64 `json:"cost_per_1m_in_cached"`
|
||||
CostPer1MOutCached float64 `json:"cost_per_1m_out_cached"`
|
||||
ContextWindow int64 `json:"context_window"`
|
||||
DefaultMaxTokens int64 `json:"default_max_tokens"`
|
||||
CanReason bool `json:"can_reason"`
|
||||
SupportsAttachments bool `json:"supports_attachments"`
|
||||
}
|
||||
|
||||
// Model IDs
|
||||
const ( // GEMINI
|
||||
// Bedrock
|
||||
BedrockClaude37Sonnet ModelID = "bedrock.claude-3.7-sonnet"
|
||||
)
|
||||
|
||||
const (
|
||||
ProviderBedrock ModelProvider = "bedrock"
|
||||
// ForTests
|
||||
ProviderMock ModelProvider = "__mock"
|
||||
)
|
||||
|
||||
// Providers in order of popularity
|
||||
var ProviderPopularity = map[ModelProvider]int{
|
||||
ProviderAnthropic: 1,
|
||||
ProviderOpenAI: 2,
|
||||
ProviderGemini: 3,
|
||||
ProviderGROQ: 4,
|
||||
ProviderOpenRouter: 5,
|
||||
ProviderBedrock: 6,
|
||||
ProviderAzure: 7,
|
||||
}
|
||||
|
||||
var SupportedModels = map[ModelID]Model{
|
||||
//
|
||||
// // GEMINI
|
||||
// GEMINI25: {
|
||||
// ID: GEMINI25,
|
||||
// Name: "Gemini 2.5 Pro",
|
||||
// Provider: ProviderGemini,
|
||||
// APIModel: "gemini-2.5-pro-exp-03-25",
|
||||
// CostPer1MIn: 0,
|
||||
// CostPer1MInCached: 0,
|
||||
// CostPer1MOutCached: 0,
|
||||
// CostPer1MOut: 0,
|
||||
// },
|
||||
//
|
||||
// GRMINI20Flash: {
|
||||
// ID: GRMINI20Flash,
|
||||
// Name: "Gemini 2.0 Flash",
|
||||
// Provider: ProviderGemini,
|
||||
// APIModel: "gemini-2.0-flash",
|
||||
// CostPer1MIn: 0.1,
|
||||
// CostPer1MInCached: 0,
|
||||
// CostPer1MOutCached: 0.025,
|
||||
// CostPer1MOut: 0.4,
|
||||
// },
|
||||
//
|
||||
// // Bedrock
|
||||
BedrockClaude37Sonnet: {
|
||||
ID: BedrockClaude37Sonnet,
|
||||
Name: "Bedrock: Claude 3.7 Sonnet",
|
||||
Provider: ProviderBedrock,
|
||||
APIModel: "anthropic.claude-3-7-sonnet-20250219-v1:0",
|
||||
CostPer1MIn: 3.0,
|
||||
CostPer1MInCached: 3.75,
|
||||
CostPer1MOutCached: 0.30,
|
||||
CostPer1MOut: 15.0,
|
||||
ContextWindow: 200_000,
|
||||
DefaultMaxTokens: 50_000,
|
||||
CanReason: true,
|
||||
SupportsAttachments: true,
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
maps.Copy(SupportedModels, AnthropicModels)
|
||||
maps.Copy(SupportedModels, OpenAIModels)
|
||||
maps.Copy(SupportedModels, GeminiModels)
|
||||
maps.Copy(SupportedModels, GroqModels)
|
||||
maps.Copy(SupportedModels, AzureModels)
|
||||
maps.Copy(SupportedModels, OpenRouterModels)
|
||||
maps.Copy(SupportedModels, XAIModels)
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
package models
|
||||
|
||||
const (
|
||||
ProviderOpenAI ModelProvider = "openai"
|
||||
|
||||
GPT41 ModelID = "gpt-4.1"
|
||||
GPT41Mini ModelID = "gpt-4.1-mini"
|
||||
GPT41Nano ModelID = "gpt-4.1-nano"
|
||||
GPT45Preview ModelID = "gpt-4.5-preview"
|
||||
GPT4o ModelID = "gpt-4o"
|
||||
GPT4oMini ModelID = "gpt-4o-mini"
|
||||
O1 ModelID = "o1"
|
||||
O1Pro ModelID = "o1-pro"
|
||||
O1Mini ModelID = "o1-mini"
|
||||
O3 ModelID = "o3"
|
||||
O3Mini ModelID = "o3-mini"
|
||||
O4Mini ModelID = "o4-mini"
|
||||
)
|
||||
|
||||
var OpenAIModels = map[ModelID]Model{
|
||||
GPT41: {
|
||||
ID: GPT41,
|
||||
Name: "GPT 4.1",
|
||||
Provider: ProviderOpenAI,
|
||||
APIModel: "gpt-4.1",
|
||||
CostPer1MIn: 2.00,
|
||||
CostPer1MInCached: 0.50,
|
||||
CostPer1MOutCached: 0.0,
|
||||
CostPer1MOut: 8.00,
|
||||
ContextWindow: 1_047_576,
|
||||
DefaultMaxTokens: 20000,
|
||||
SupportsAttachments: true,
|
||||
},
|
||||
GPT41Mini: {
|
||||
ID: GPT41Mini,
|
||||
Name: "GPT 4.1 mini",
|
||||
Provider: ProviderOpenAI,
|
||||
APIModel: "gpt-4.1",
|
||||
CostPer1MIn: 0.40,
|
||||
CostPer1MInCached: 0.10,
|
||||
CostPer1MOutCached: 0.0,
|
||||
CostPer1MOut: 1.60,
|
||||
ContextWindow: 200_000,
|
||||
DefaultMaxTokens: 20000,
|
||||
SupportsAttachments: true,
|
||||
},
|
||||
GPT41Nano: {
|
||||
ID: GPT41Nano,
|
||||
Name: "GPT 4.1 nano",
|
||||
Provider: ProviderOpenAI,
|
||||
APIModel: "gpt-4.1-nano",
|
||||
CostPer1MIn: 0.10,
|
||||
CostPer1MInCached: 0.025,
|
||||
CostPer1MOutCached: 0.0,
|
||||
CostPer1MOut: 0.40,
|
||||
ContextWindow: 1_047_576,
|
||||
DefaultMaxTokens: 20000,
|
||||
SupportsAttachments: true,
|
||||
},
|
||||
GPT45Preview: {
|
||||
ID: GPT45Preview,
|
||||
Name: "GPT 4.5 preview",
|
||||
Provider: ProviderOpenAI,
|
||||
APIModel: "gpt-4.5-preview",
|
||||
CostPer1MIn: 75.00,
|
||||
CostPer1MInCached: 37.50,
|
||||
CostPer1MOutCached: 0.0,
|
||||
CostPer1MOut: 150.00,
|
||||
ContextWindow: 128_000,
|
||||
DefaultMaxTokens: 15000,
|
||||
SupportsAttachments: true,
|
||||
},
|
||||
GPT4o: {
|
||||
ID: GPT4o,
|
||||
Name: "GPT 4o",
|
||||
Provider: ProviderOpenAI,
|
||||
APIModel: "gpt-4o",
|
||||
CostPer1MIn: 2.50,
|
||||
CostPer1MInCached: 1.25,
|
||||
CostPer1MOutCached: 0.0,
|
||||
CostPer1MOut: 10.00,
|
||||
ContextWindow: 128_000,
|
||||
DefaultMaxTokens: 4096,
|
||||
SupportsAttachments: true,
|
||||
},
|
||||
GPT4oMini: {
|
||||
ID: GPT4oMini,
|
||||
Name: "GPT 4o mini",
|
||||
Provider: ProviderOpenAI,
|
||||
APIModel: "gpt-4o-mini",
|
||||
CostPer1MIn: 0.15,
|
||||
CostPer1MInCached: 0.075,
|
||||
CostPer1MOutCached: 0.0,
|
||||
CostPer1MOut: 0.60,
|
||||
ContextWindow: 128_000,
|
||||
SupportsAttachments: true,
|
||||
},
|
||||
O1: {
|
||||
ID: O1,
|
||||
Name: "O1",
|
||||
Provider: ProviderOpenAI,
|
||||
APIModel: "o1",
|
||||
CostPer1MIn: 15.00,
|
||||
CostPer1MInCached: 7.50,
|
||||
CostPer1MOutCached: 0.0,
|
||||
CostPer1MOut: 60.00,
|
||||
ContextWindow: 200_000,
|
||||
DefaultMaxTokens: 50000,
|
||||
CanReason: true,
|
||||
SupportsAttachments: true,
|
||||
},
|
||||
O1Pro: {
|
||||
ID: O1Pro,
|
||||
Name: "o1 pro",
|
||||
Provider: ProviderOpenAI,
|
||||
APIModel: "o1-pro",
|
||||
CostPer1MIn: 150.00,
|
||||
CostPer1MInCached: 0.0,
|
||||
CostPer1MOutCached: 0.0,
|
||||
CostPer1MOut: 600.00,
|
||||
ContextWindow: 200_000,
|
||||
DefaultMaxTokens: 50000,
|
||||
CanReason: true,
|
||||
SupportsAttachments: true,
|
||||
},
|
||||
O1Mini: {
|
||||
ID: O1Mini,
|
||||
Name: "o1 mini",
|
||||
Provider: ProviderOpenAI,
|
||||
APIModel: "o1-mini",
|
||||
CostPer1MIn: 1.10,
|
||||
CostPer1MInCached: 0.55,
|
||||
CostPer1MOutCached: 0.0,
|
||||
CostPer1MOut: 4.40,
|
||||
ContextWindow: 128_000,
|
||||
DefaultMaxTokens: 50000,
|
||||
CanReason: true,
|
||||
SupportsAttachments: true,
|
||||
},
|
||||
O3: {
|
||||
ID: O3,
|
||||
Name: "o3",
|
||||
Provider: ProviderOpenAI,
|
||||
APIModel: "o3",
|
||||
CostPer1MIn: 10.00,
|
||||
CostPer1MInCached: 2.50,
|
||||
CostPer1MOutCached: 0.0,
|
||||
CostPer1MOut: 40.00,
|
||||
ContextWindow: 200_000,
|
||||
CanReason: true,
|
||||
SupportsAttachments: true,
|
||||
},
|
||||
O3Mini: {
|
||||
ID: O3Mini,
|
||||
Name: "o3 mini",
|
||||
Provider: ProviderOpenAI,
|
||||
APIModel: "o3-mini",
|
||||
CostPer1MIn: 1.10,
|
||||
CostPer1MInCached: 0.55,
|
||||
CostPer1MOutCached: 0.0,
|
||||
CostPer1MOut: 4.40,
|
||||
ContextWindow: 200_000,
|
||||
DefaultMaxTokens: 50000,
|
||||
CanReason: true,
|
||||
SupportsAttachments: false,
|
||||
},
|
||||
O4Mini: {
|
||||
ID: O4Mini,
|
||||
Name: "o4 mini",
|
||||
Provider: ProviderOpenAI,
|
||||
APIModel: "o4-mini",
|
||||
CostPer1MIn: 1.10,
|
||||
CostPer1MInCached: 0.275,
|
||||
CostPer1MOutCached: 0.0,
|
||||
CostPer1MOut: 4.40,
|
||||
ContextWindow: 128_000,
|
||||
DefaultMaxTokens: 50000,
|
||||
CanReason: true,
|
||||
SupportsAttachments: true,
|
||||
},
|
||||
}
|
||||
@@ -1,327 +0,0 @@
|
||||
package models
|
||||
|
||||
const (
|
||||
ProviderOpenRouter ModelProvider = "openrouter"
|
||||
|
||||
OpenRouterGPT41 ModelID = "openrouter.gpt-4.1"
|
||||
OpenRouterGPT41Mini ModelID = "openrouter.gpt-4.1-mini"
|
||||
OpenRouterGPT41Nano ModelID = "openrouter.gpt-4.1-nano"
|
||||
OpenRouterGPT45Preview ModelID = "openrouter.gpt-4.5-preview"
|
||||
OpenRouterGPT4o ModelID = "openrouter.gpt-4o"
|
||||
OpenRouterGPT4oMini ModelID = "openrouter.gpt-4o-mini"
|
||||
OpenRouterO1 ModelID = "openrouter.o1"
|
||||
OpenRouterO1Pro ModelID = "openrouter.o1-pro"
|
||||
OpenRouterO1Mini ModelID = "openrouter.o1-mini"
|
||||
OpenRouterO3 ModelID = "openrouter.o3"
|
||||
OpenRouterO3Mini ModelID = "openrouter.o3-mini"
|
||||
OpenRouterO4Mini ModelID = "openrouter.o4-mini"
|
||||
OpenRouterGemini25Flash ModelID = "openrouter.gemini-2.5-flash"
|
||||
OpenRouterGemini25 ModelID = "openrouter.gemini-2.5"
|
||||
OpenRouterClaude35Sonnet ModelID = "openrouter.claude-3.5-sonnet"
|
||||
OpenRouterClaude3Haiku ModelID = "openrouter.claude-3-haiku"
|
||||
OpenRouterClaude37Sonnet ModelID = "openrouter.claude-3.7-sonnet"
|
||||
OpenRouterClaude35Haiku ModelID = "openrouter.claude-3.5-haiku"
|
||||
OpenRouterClaude3Opus ModelID = "openrouter.claude-3-opus"
|
||||
OpenRouterQwen235B ModelID = "openrouter.qwen-3-235b"
|
||||
OpenRouterQwen32B ModelID = "openrouter.qwen-3-32b"
|
||||
OpenRouterQwen30B ModelID = "openrouter.qwen-3-30b"
|
||||
OpenRouterQwen14B ModelID = "openrouter.qwen-3-14b"
|
||||
OpenRouterQwen8B ModelID = "openrouter.qwen-3-8b"
|
||||
)
|
||||
|
||||
var OpenRouterModels = map[ModelID]Model{
|
||||
OpenRouterGPT41: {
|
||||
ID: OpenRouterGPT41,
|
||||
Name: "OpenRouter: GPT 4.1",
|
||||
Provider: ProviderOpenRouter,
|
||||
APIModel: "openai/gpt-4.1",
|
||||
CostPer1MIn: OpenAIModels[GPT41].CostPer1MIn,
|
||||
CostPer1MInCached: OpenAIModels[GPT41].CostPer1MInCached,
|
||||
CostPer1MOut: OpenAIModels[GPT41].CostPer1MOut,
|
||||
CostPer1MOutCached: OpenAIModels[GPT41].CostPer1MOutCached,
|
||||
ContextWindow: OpenAIModels[GPT41].ContextWindow,
|
||||
DefaultMaxTokens: OpenAIModels[GPT41].DefaultMaxTokens,
|
||||
},
|
||||
OpenRouterGPT41Mini: {
|
||||
ID: OpenRouterGPT41Mini,
|
||||
Name: "OpenRouter: GPT 4.1 mini",
|
||||
Provider: ProviderOpenRouter,
|
||||
APIModel: "openai/gpt-4.1-mini",
|
||||
CostPer1MIn: OpenAIModels[GPT41Mini].CostPer1MIn,
|
||||
CostPer1MInCached: OpenAIModels[GPT41Mini].CostPer1MInCached,
|
||||
CostPer1MOut: OpenAIModels[GPT41Mini].CostPer1MOut,
|
||||
CostPer1MOutCached: OpenAIModels[GPT41Mini].CostPer1MOutCached,
|
||||
ContextWindow: OpenAIModels[GPT41Mini].ContextWindow,
|
||||
DefaultMaxTokens: OpenAIModels[GPT41Mini].DefaultMaxTokens,
|
||||
},
|
||||
OpenRouterGPT41Nano: {
|
||||
ID: OpenRouterGPT41Nano,
|
||||
Name: "OpenRouter: GPT 4.1 nano",
|
||||
Provider: ProviderOpenRouter,
|
||||
APIModel: "openai/gpt-4.1-nano",
|
||||
CostPer1MIn: OpenAIModels[GPT41Nano].CostPer1MIn,
|
||||
CostPer1MInCached: OpenAIModels[GPT41Nano].CostPer1MInCached,
|
||||
CostPer1MOut: OpenAIModels[GPT41Nano].CostPer1MOut,
|
||||
CostPer1MOutCached: OpenAIModels[GPT41Nano].CostPer1MOutCached,
|
||||
ContextWindow: OpenAIModels[GPT41Nano].ContextWindow,
|
||||
DefaultMaxTokens: OpenAIModels[GPT41Nano].DefaultMaxTokens,
|
||||
},
|
||||
OpenRouterGPT45Preview: {
|
||||
ID: OpenRouterGPT45Preview,
|
||||
Name: "OpenRouter: GPT 4.5 preview",
|
||||
Provider: ProviderOpenRouter,
|
||||
APIModel: "openai/gpt-4.5-preview",
|
||||
CostPer1MIn: OpenAIModels[GPT45Preview].CostPer1MIn,
|
||||
CostPer1MInCached: OpenAIModels[GPT45Preview].CostPer1MInCached,
|
||||
CostPer1MOut: OpenAIModels[GPT45Preview].CostPer1MOut,
|
||||
CostPer1MOutCached: OpenAIModels[GPT45Preview].CostPer1MOutCached,
|
||||
ContextWindow: OpenAIModels[GPT45Preview].ContextWindow,
|
||||
DefaultMaxTokens: OpenAIModels[GPT45Preview].DefaultMaxTokens,
|
||||
},
|
||||
OpenRouterGPT4o: {
|
||||
ID: OpenRouterGPT4o,
|
||||
Name: "OpenRouter: GPT 4o",
|
||||
Provider: ProviderOpenRouter,
|
||||
APIModel: "openai/gpt-4o",
|
||||
CostPer1MIn: OpenAIModels[GPT4o].CostPer1MIn,
|
||||
CostPer1MInCached: OpenAIModels[GPT4o].CostPer1MInCached,
|
||||
CostPer1MOut: OpenAIModels[GPT4o].CostPer1MOut,
|
||||
CostPer1MOutCached: OpenAIModels[GPT4o].CostPer1MOutCached,
|
||||
ContextWindow: OpenAIModels[GPT4o].ContextWindow,
|
||||
DefaultMaxTokens: OpenAIModels[GPT4o].DefaultMaxTokens,
|
||||
},
|
||||
OpenRouterGPT4oMini: {
|
||||
ID: OpenRouterGPT4oMini,
|
||||
Name: "OpenRouter: GPT 4o mini",
|
||||
Provider: ProviderOpenRouter,
|
||||
APIModel: "openai/gpt-4o-mini",
|
||||
CostPer1MIn: OpenAIModels[GPT4oMini].CostPer1MIn,
|
||||
CostPer1MInCached: OpenAIModels[GPT4oMini].CostPer1MInCached,
|
||||
CostPer1MOut: OpenAIModels[GPT4oMini].CostPer1MOut,
|
||||
CostPer1MOutCached: OpenAIModels[GPT4oMini].CostPer1MOutCached,
|
||||
ContextWindow: OpenAIModels[GPT4oMini].ContextWindow,
|
||||
},
|
||||
OpenRouterO1: {
|
||||
ID: OpenRouterO1,
|
||||
Name: "OpenRouter: O1",
|
||||
Provider: ProviderOpenRouter,
|
||||
APIModel: "openai/o1",
|
||||
CostPer1MIn: OpenAIModels[O1].CostPer1MIn,
|
||||
CostPer1MInCached: OpenAIModels[O1].CostPer1MInCached,
|
||||
CostPer1MOut: OpenAIModels[O1].CostPer1MOut,
|
||||
CostPer1MOutCached: OpenAIModels[O1].CostPer1MOutCached,
|
||||
ContextWindow: OpenAIModels[O1].ContextWindow,
|
||||
DefaultMaxTokens: OpenAIModels[O1].DefaultMaxTokens,
|
||||
CanReason: OpenAIModels[O1].CanReason,
|
||||
},
|
||||
OpenRouterO1Pro: {
|
||||
ID: OpenRouterO1Pro,
|
||||
Name: "OpenRouter: o1 pro",
|
||||
Provider: ProviderOpenRouter,
|
||||
APIModel: "openai/o1-pro",
|
||||
CostPer1MIn: OpenAIModels[O1Pro].CostPer1MIn,
|
||||
CostPer1MInCached: OpenAIModels[O1Pro].CostPer1MInCached,
|
||||
CostPer1MOut: OpenAIModels[O1Pro].CostPer1MOut,
|
||||
CostPer1MOutCached: OpenAIModels[O1Pro].CostPer1MOutCached,
|
||||
ContextWindow: OpenAIModels[O1Pro].ContextWindow,
|
||||
DefaultMaxTokens: OpenAIModels[O1Pro].DefaultMaxTokens,
|
||||
CanReason: OpenAIModels[O1Pro].CanReason,
|
||||
},
|
||||
OpenRouterO1Mini: {
|
||||
ID: OpenRouterO1Mini,
|
||||
Name: "OpenRouter: o1 mini",
|
||||
Provider: ProviderOpenRouter,
|
||||
APIModel: "openai/o1-mini",
|
||||
CostPer1MIn: OpenAIModels[O1Mini].CostPer1MIn,
|
||||
CostPer1MInCached: OpenAIModels[O1Mini].CostPer1MInCached,
|
||||
CostPer1MOut: OpenAIModels[O1Mini].CostPer1MOut,
|
||||
CostPer1MOutCached: OpenAIModels[O1Mini].CostPer1MOutCached,
|
||||
ContextWindow: OpenAIModels[O1Mini].ContextWindow,
|
||||
DefaultMaxTokens: OpenAIModels[O1Mini].DefaultMaxTokens,
|
||||
CanReason: OpenAIModels[O1Mini].CanReason,
|
||||
},
|
||||
OpenRouterO3: {
|
||||
ID: OpenRouterO3,
|
||||
Name: "OpenRouter: o3",
|
||||
Provider: ProviderOpenRouter,
|
||||
APIModel: "openai/o3",
|
||||
CostPer1MIn: OpenAIModels[O3].CostPer1MIn,
|
||||
CostPer1MInCached: OpenAIModels[O3].CostPer1MInCached,
|
||||
CostPer1MOut: OpenAIModels[O3].CostPer1MOut,
|
||||
CostPer1MOutCached: OpenAIModels[O3].CostPer1MOutCached,
|
||||
ContextWindow: OpenAIModels[O3].ContextWindow,
|
||||
DefaultMaxTokens: OpenAIModels[O3].DefaultMaxTokens,
|
||||
CanReason: OpenAIModels[O3].CanReason,
|
||||
},
|
||||
OpenRouterO3Mini: {
|
||||
ID: OpenRouterO3Mini,
|
||||
Name: "OpenRouter: o3 mini",
|
||||
Provider: ProviderOpenRouter,
|
||||
APIModel: "openai/o3-mini-high",
|
||||
CostPer1MIn: OpenAIModels[O3Mini].CostPer1MIn,
|
||||
CostPer1MInCached: OpenAIModels[O3Mini].CostPer1MInCached,
|
||||
CostPer1MOut: OpenAIModels[O3Mini].CostPer1MOut,
|
||||
CostPer1MOutCached: OpenAIModels[O3Mini].CostPer1MOutCached,
|
||||
ContextWindow: OpenAIModels[O3Mini].ContextWindow,
|
||||
DefaultMaxTokens: OpenAIModels[O3Mini].DefaultMaxTokens,
|
||||
CanReason: OpenAIModels[O3Mini].CanReason,
|
||||
},
|
||||
OpenRouterO4Mini: {
|
||||
ID: OpenRouterO4Mini,
|
||||
Name: "OpenRouter: o4 mini",
|
||||
Provider: ProviderOpenRouter,
|
||||
APIModel: "openai/o4-mini-high",
|
||||
CostPer1MIn: OpenAIModels[O4Mini].CostPer1MIn,
|
||||
CostPer1MInCached: OpenAIModels[O4Mini].CostPer1MInCached,
|
||||
CostPer1MOut: OpenAIModels[O4Mini].CostPer1MOut,
|
||||
CostPer1MOutCached: OpenAIModels[O4Mini].CostPer1MOutCached,
|
||||
ContextWindow: OpenAIModels[O4Mini].ContextWindow,
|
||||
DefaultMaxTokens: OpenAIModels[O4Mini].DefaultMaxTokens,
|
||||
CanReason: OpenAIModels[O4Mini].CanReason,
|
||||
},
|
||||
OpenRouterGemini25Flash: {
|
||||
ID: OpenRouterGemini25Flash,
|
||||
Name: "OpenRouter: Gemini 2.5 Flash",
|
||||
Provider: ProviderOpenRouter,
|
||||
APIModel: "google/gemini-2.5-flash-preview:thinking",
|
||||
CostPer1MIn: GeminiModels[Gemini25Flash].CostPer1MIn,
|
||||
CostPer1MInCached: GeminiModels[Gemini25Flash].CostPer1MInCached,
|
||||
CostPer1MOut: GeminiModels[Gemini25Flash].CostPer1MOut,
|
||||
CostPer1MOutCached: GeminiModels[Gemini25Flash].CostPer1MOutCached,
|
||||
ContextWindow: GeminiModels[Gemini25Flash].ContextWindow,
|
||||
DefaultMaxTokens: GeminiModels[Gemini25Flash].DefaultMaxTokens,
|
||||
},
|
||||
OpenRouterGemini25: {
|
||||
ID: OpenRouterGemini25,
|
||||
Name: "OpenRouter: Gemini 2.5 Pro",
|
||||
Provider: ProviderOpenRouter,
|
||||
APIModel: "google/gemini-2.5-pro-preview-03-25",
|
||||
CostPer1MIn: GeminiModels[Gemini25].CostPer1MIn,
|
||||
CostPer1MInCached: GeminiModels[Gemini25].CostPer1MInCached,
|
||||
CostPer1MOut: GeminiModels[Gemini25].CostPer1MOut,
|
||||
CostPer1MOutCached: GeminiModels[Gemini25].CostPer1MOutCached,
|
||||
ContextWindow: GeminiModels[Gemini25].ContextWindow,
|
||||
DefaultMaxTokens: GeminiModels[Gemini25].DefaultMaxTokens,
|
||||
},
|
||||
OpenRouterClaude35Sonnet: {
|
||||
ID: OpenRouterClaude35Sonnet,
|
||||
Name: "OpenRouter: Claude 3.5 Sonnet",
|
||||
Provider: ProviderOpenRouter,
|
||||
APIModel: "anthropic/claude-3.5-sonnet",
|
||||
CostPer1MIn: AnthropicModels[Claude35Sonnet].CostPer1MIn,
|
||||
CostPer1MInCached: AnthropicModels[Claude35Sonnet].CostPer1MInCached,
|
||||
CostPer1MOut: AnthropicModels[Claude35Sonnet].CostPer1MOut,
|
||||
CostPer1MOutCached: AnthropicModels[Claude35Sonnet].CostPer1MOutCached,
|
||||
ContextWindow: AnthropicModels[Claude35Sonnet].ContextWindow,
|
||||
DefaultMaxTokens: AnthropicModels[Claude35Sonnet].DefaultMaxTokens,
|
||||
},
|
||||
OpenRouterClaude3Haiku: {
|
||||
ID: OpenRouterClaude3Haiku,
|
||||
Name: "OpenRouter: Claude 3 Haiku",
|
||||
Provider: ProviderOpenRouter,
|
||||
APIModel: "anthropic/claude-3-haiku",
|
||||
CostPer1MIn: AnthropicModels[Claude3Haiku].CostPer1MIn,
|
||||
CostPer1MInCached: AnthropicModels[Claude3Haiku].CostPer1MInCached,
|
||||
CostPer1MOut: AnthropicModels[Claude3Haiku].CostPer1MOut,
|
||||
CostPer1MOutCached: AnthropicModels[Claude3Haiku].CostPer1MOutCached,
|
||||
ContextWindow: AnthropicModels[Claude3Haiku].ContextWindow,
|
||||
DefaultMaxTokens: AnthropicModels[Claude3Haiku].DefaultMaxTokens,
|
||||
},
|
||||
OpenRouterClaude37Sonnet: {
|
||||
ID: OpenRouterClaude37Sonnet,
|
||||
Name: "OpenRouter: Claude 3.7 Sonnet",
|
||||
Provider: ProviderOpenRouter,
|
||||
APIModel: "anthropic/claude-3.7-sonnet",
|
||||
CostPer1MIn: AnthropicModels[Claude37Sonnet].CostPer1MIn,
|
||||
CostPer1MInCached: AnthropicModels[Claude37Sonnet].CostPer1MInCached,
|
||||
CostPer1MOut: AnthropicModels[Claude37Sonnet].CostPer1MOut,
|
||||
CostPer1MOutCached: AnthropicModels[Claude37Sonnet].CostPer1MOutCached,
|
||||
ContextWindow: AnthropicModels[Claude37Sonnet].ContextWindow,
|
||||
DefaultMaxTokens: AnthropicModels[Claude37Sonnet].DefaultMaxTokens,
|
||||
CanReason: AnthropicModels[Claude37Sonnet].CanReason,
|
||||
},
|
||||
OpenRouterClaude35Haiku: {
|
||||
ID: OpenRouterClaude35Haiku,
|
||||
Name: "OpenRouter: Claude 3.5 Haiku",
|
||||
Provider: ProviderOpenRouter,
|
||||
APIModel: "anthropic/claude-3.5-haiku",
|
||||
CostPer1MIn: AnthropicModels[Claude35Haiku].CostPer1MIn,
|
||||
CostPer1MInCached: AnthropicModels[Claude35Haiku].CostPer1MInCached,
|
||||
CostPer1MOut: AnthropicModels[Claude35Haiku].CostPer1MOut,
|
||||
CostPer1MOutCached: AnthropicModels[Claude35Haiku].CostPer1MOutCached,
|
||||
ContextWindow: AnthropicModels[Claude35Haiku].ContextWindow,
|
||||
DefaultMaxTokens: AnthropicModels[Claude35Haiku].DefaultMaxTokens,
|
||||
},
|
||||
OpenRouterClaude3Opus: {
|
||||
ID: OpenRouterClaude3Opus,
|
||||
Name: "OpenRouter: Claude 3 Opus",
|
||||
Provider: ProviderOpenRouter,
|
||||
APIModel: "anthropic/claude-3-opus",
|
||||
CostPer1MIn: AnthropicModels[Claude3Opus].CostPer1MIn,
|
||||
CostPer1MInCached: AnthropicModels[Claude3Opus].CostPer1MInCached,
|
||||
CostPer1MOut: AnthropicModels[Claude3Opus].CostPer1MOut,
|
||||
CostPer1MOutCached: AnthropicModels[Claude3Opus].CostPer1MOutCached,
|
||||
ContextWindow: AnthropicModels[Claude3Opus].ContextWindow,
|
||||
DefaultMaxTokens: AnthropicModels[Claude3Opus].DefaultMaxTokens,
|
||||
},
|
||||
OpenRouterQwen235B: {
|
||||
ID: OpenRouterQwen235B,
|
||||
Name: "OpenRouter: Qwen3 235B A22B",
|
||||
Provider: ProviderOpenRouter,
|
||||
APIModel: "qwen/qwen3-235b-a22b",
|
||||
CostPer1MIn: 0.1,
|
||||
CostPer1MInCached: 0.1,
|
||||
CostPer1MOut: 0.1,
|
||||
CostPer1MOutCached: 0.1,
|
||||
ContextWindow: 40960,
|
||||
DefaultMaxTokens: 4096,
|
||||
},
|
||||
OpenRouterQwen32B: {
|
||||
ID: OpenRouterQwen32B,
|
||||
Name: "OpenRouter: Qwen3 32B",
|
||||
Provider: ProviderOpenRouter,
|
||||
APIModel: "qwen/qwen3-32b",
|
||||
CostPer1MIn: 0.1,
|
||||
CostPer1MInCached: 0.1,
|
||||
CostPer1MOut: 0.3,
|
||||
CostPer1MOutCached: 0.3,
|
||||
ContextWindow: 40960,
|
||||
DefaultMaxTokens: 4096,
|
||||
},
|
||||
OpenRouterQwen30B: {
|
||||
ID: OpenRouterQwen30B,
|
||||
Name: "OpenRouter: Qwen3 30B A3B",
|
||||
Provider: ProviderOpenRouter,
|
||||
APIModel: "qwen/qwen3-30b-a3b",
|
||||
CostPer1MIn: 0.1,
|
||||
CostPer1MInCached: 0.1,
|
||||
CostPer1MOut: 0.3,
|
||||
CostPer1MOutCached: 0.3,
|
||||
ContextWindow: 40960,
|
||||
DefaultMaxTokens: 4096,
|
||||
},
|
||||
OpenRouterQwen14B: {
|
||||
ID: OpenRouterQwen14B,
|
||||
Name: "OpenRouter: Qwen3 14B",
|
||||
Provider: ProviderOpenRouter,
|
||||
APIModel: "qwen/qwen3-14b",
|
||||
CostPer1MIn: 0.7,
|
||||
CostPer1MInCached: 0.7,
|
||||
CostPer1MOut: 0.24,
|
||||
CostPer1MOutCached: 0.24,
|
||||
ContextWindow: 40960,
|
||||
DefaultMaxTokens: 4096,
|
||||
},
|
||||
OpenRouterQwen8B: {
|
||||
ID: OpenRouterQwen8B,
|
||||
Name: "OpenRouter: Qwen3 8B",
|
||||
Provider: ProviderOpenRouter,
|
||||
APIModel: "qwen/qwen3-8b",
|
||||
CostPer1MIn: 0.35,
|
||||
CostPer1MInCached: 0.35,
|
||||
CostPer1MOut: 0.138,
|
||||
CostPer1MOutCached: 0.138,
|
||||
ContextWindow: 128000,
|
||||
DefaultMaxTokens: 4096,
|
||||
},
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
package models
|
||||
|
||||
const (
|
||||
ProviderXAI ModelProvider = "xai"
|
||||
|
||||
XAIGrok3Beta ModelID = "grok-3-beta"
|
||||
XAIGrok3MiniBeta ModelID = "grok-3-mini-beta"
|
||||
XAIGrok3FastBeta ModelID = "grok-3-fast-beta"
|
||||
XAiGrok3MiniFastBeta ModelID = "grok-3-mini-fast-beta"
|
||||
)
|
||||
|
||||
var XAIModels = map[ModelID]Model{
|
||||
XAIGrok3Beta: {
|
||||
ID: XAIGrok3Beta,
|
||||
Name: "Grok3 Beta",
|
||||
Provider: ProviderXAI,
|
||||
APIModel: "grok-3-beta",
|
||||
CostPer1MIn: 3.0,
|
||||
CostPer1MInCached: 0,
|
||||
CostPer1MOut: 15,
|
||||
CostPer1MOutCached: 0,
|
||||
ContextWindow: 131_072,
|
||||
DefaultMaxTokens: 20_000,
|
||||
},
|
||||
XAIGrok3MiniBeta: {
|
||||
ID: XAIGrok3MiniBeta,
|
||||
Name: "Grok3 Mini Beta",
|
||||
Provider: ProviderXAI,
|
||||
APIModel: "grok-3-mini-beta",
|
||||
CostPer1MIn: 0.3,
|
||||
CostPer1MInCached: 0,
|
||||
CostPer1MOut: 0.5,
|
||||
CostPer1MOutCached: 0,
|
||||
ContextWindow: 131_072,
|
||||
DefaultMaxTokens: 20_000,
|
||||
},
|
||||
XAIGrok3FastBeta: {
|
||||
ID: XAIGrok3FastBeta,
|
||||
Name: "Grok3 Fast Beta",
|
||||
Provider: ProviderXAI,
|
||||
APIModel: "grok-3-fast-beta",
|
||||
CostPer1MIn: 5,
|
||||
CostPer1MInCached: 0,
|
||||
CostPer1MOut: 25,
|
||||
CostPer1MOutCached: 0,
|
||||
ContextWindow: 131_072,
|
||||
DefaultMaxTokens: 20_000,
|
||||
},
|
||||
XAiGrok3MiniFastBeta: {
|
||||
ID: XAiGrok3MiniFastBeta,
|
||||
Name: "Grok3 Mini Fast Beta",
|
||||
Provider: ProviderXAI,
|
||||
APIModel: "grok-3-mini-fast-beta",
|
||||
CostPer1MIn: 0.6,
|
||||
CostPer1MInCached: 0,
|
||||
CostPer1MOut: 4.0,
|
||||
CostPer1MOutCached: 0,
|
||||
ContextWindow: 131_072,
|
||||
DefaultMaxTokens: 20_000,
|
||||
},
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
package prompt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/sst/opencode/internal/config"
|
||||
"github.com/sst/opencode/internal/llm/models"
|
||||
"github.com/sst/opencode/internal/llm/tools"
|
||||
)
|
||||
|
||||
func PrimaryPrompt(provider models.ModelProvider) string {
|
||||
basePrompt := baseAnthropicPrimaryPrompt
|
||||
switch provider {
|
||||
case models.ProviderOpenAI:
|
||||
basePrompt = baseOpenAIPrimaryPrompt
|
||||
}
|
||||
envInfo := getEnvironmentInfo()
|
||||
|
||||
return fmt.Sprintf("%s\n\n%s\n%s", basePrompt, envInfo, lspInformation())
|
||||
}
|
||||
|
||||
const baseOpenAIPrimaryPrompt = `
|
||||
You are operating as and within the OpenCode CLI, a terminal-based agentic coding assistant built by OpenAI. It wraps OpenAI models to enable natural language interaction with a local codebase. You are expected to be precise, safe, and helpful.
|
||||
|
||||
You can:
|
||||
- Receive user prompts, project context, and files.
|
||||
- Stream responses and emit function calls (e.g., shell commands, code edits).
|
||||
- Apply patches, run commands, and manage user approvals based on policy.
|
||||
- Work inside a sandboxed, git-backed workspace with rollback support.
|
||||
- Log telemetry so sessions can be replayed or inspected later.
|
||||
- More details on your functionality are available at "opencode --help"
|
||||
|
||||
|
||||
You are an agent - please keep going until the user's query is completely resolved, before ending your turn and yielding back to the user. Only terminate your turn when you are sure that the problem is solved. If you are not sure about file content or codebase structure pertaining to the user's request, use your tools to read files and gather the relevant information: do NOT guess or make up an answer.
|
||||
|
||||
Please resolve the user's task by editing and testing the code files in your current code execution session. You are a deployed coding agent. Your session allows for you to modify and run code. The repo(s) are already cloned in your working directory, and you must fully solve the problem for your answer to be considered correct.
|
||||
|
||||
You MUST adhere to the following criteria when executing the task:
|
||||
- Working on the repo(s) in the current environment is allowed, even if they are proprietary.
|
||||
- Analyzing code for vulnerabilities is allowed.
|
||||
- Showing user code and tool call details is allowed.
|
||||
- User instructions may overwrite the *CODING GUIDELINES* section in this developer message.
|
||||
- If completing the user's task requires writing or modifying files:
|
||||
- Your code and final answer should follow these *CODING GUIDELINES*:
|
||||
- Fix the problem at the root cause rather than applying surface-level patches, when possible.
|
||||
- Avoid unneeded complexity in your solution.
|
||||
- Ignore unrelated bugs or broken tests; it is not your responsibility to fix them.
|
||||
- Update documentation as necessary.
|
||||
- Keep changes consistent with the style of the existing codebase. Changes should be minimal and focused on the task.
|
||||
- Use "git log" and "git blame" to search the history of the codebase if additional context is required; internet access is disabled.
|
||||
- NEVER add copyright or license headers unless specifically requested.
|
||||
- You do not need to "git commit" your changes; this will be done automatically for you.
|
||||
- Once you finish coding, you must
|
||||
- Check "git status" to sanity check your changes; revert any scratch files or changes.
|
||||
- Remove all inline comments you added as much as possible, even if they look normal. Check using "git diff". Inline comments must be generally avoided, unless active maintainers of the repo, after long careful study of the code and the issue, will still misinterpret the code without the comments.
|
||||
- Check if you accidentally add copyright or license headers. If so, remove them.
|
||||
- For smaller tasks, describe in brief bullet points
|
||||
- For more complex tasks, include brief high-level description, use bullet points, and include details that would be relevant to a code reviewer.
|
||||
- If completing the user's task DOES NOT require writing or modifying files (e.g., the user asks a question about the code base):
|
||||
- Respond in a friendly tune as a remote teammate, who is knowledgeable, capable and eager to help with coding.
|
||||
- When your task involves writing or modifying files:
|
||||
- Do NOT tell the user to "save the file" or "copy the code into a file" if you already created or modified the file using "apply_patch". Instead, reference the file as already saved.
|
||||
- Do NOT show the full contents of large files you have already written, unless the user explicitly asks for them.
|
||||
- When doing things with paths, always use use the full path, if the working directory is /abc/xyz and you want to edit the file abc.go in the working dir refer to it as /abc/xyz/abc.go.
|
||||
- If you send a path not including the working dir, the working dir will be prepended to it.
|
||||
- Remember the user does not see the full output of tools
|
||||
`
|
||||
|
||||
const baseAnthropicPrimaryPrompt = `You are OpenCode, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
|
||||
|
||||
IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure.
|
||||
|
||||
# Memory
|
||||
If the current working directory contains a file called OpenCode.md, it will be automatically added to your context. This file serves multiple purposes:
|
||||
1. Storing frequently used bash commands (build, test, lint, etc.) so you can use them without searching each time
|
||||
2. Recording the user's code style preferences (naming conventions, preferred libraries, etc.)
|
||||
3. Maintaining useful information about the codebase structure and organization
|
||||
|
||||
When you spend time searching for commands to typecheck, lint, build, or test, you should ask the user if it's okay to add those commands to CONTEXT.md. Similarly, when learning about code style preferences or important codebase information, ask if it's okay to add that to CONTEXT.md so you can remember it for next time.
|
||||
|
||||
# Tone and style
|
||||
You should be concise, direct, and to the point. When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system).
|
||||
Remember that your output will be displayed on a command line interface. Your responses can use Github-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
|
||||
Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session.
|
||||
If you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences.
|
||||
IMPORTANT: You should minimize output tokens as much as possible while maintaining helpfulness, quality, and accuracy. Only address the specific query or task at hand, avoiding tangential information unless absolutely critical for completing the request. If you can answer in 1-3 sentences or a short paragraph, please do.
|
||||
IMPORTANT: You should NOT answer with unnecessary preamble or postamble (such as explaining your code or summarizing your action), unless the user asks you to.
|
||||
IMPORTANT: Keep your responses short, since they will be displayed on a command line interface. You MUST answer concisely with fewer than 4 lines (not including tool use or code generation), unless user asks for detail. Answer the user's question directly, without elaboration, explanation, or details. One word answers are best. Avoid introductions, conclusions, and explanations. You MUST avoid text before/after your response, such as "The answer is <answer>.", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...". Here are some examples to demonstrate appropriate verbosity:
|
||||
<example>
|
||||
user: 2 + 2
|
||||
assistant: 4
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: what is 2+2?
|
||||
assistant: 4
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: is 11 a prime number?
|
||||
assistant: yes
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: what command should I run to list files in the current directory?
|
||||
assistant: ls
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: what command should I run to watch files in the current directory?
|
||||
assistant: [use the ls tool to list the files in the current directory, then read docs/commands in the relevant file to find out how to watch files]
|
||||
npm run dev
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: How many golf balls fit inside a jetta?
|
||||
assistant: 150000
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: what files are in the directory src/?
|
||||
assistant: [runs ls and sees foo.c, bar.c, baz.c]
|
||||
user: which file contains the implementation of foo?
|
||||
assistant: src/foo.c
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: write tests for new feature
|
||||
assistant: [uses grep and glob search tools to find where similar tests are defined, uses concurrent read file tool use blocks in one tool call to read relevant files at the same time, uses edit/patch file tool to write new tests]
|
||||
</example>
|
||||
|
||||
# Proactiveness
|
||||
You are allowed to be proactive, but only when the user asks you to do something. You should strive to strike a balance between:
|
||||
1. Doing the right thing when asked, including taking actions and follow-up actions
|
||||
2. Not surprising the user with actions you take without asking
|
||||
For example, if the user asks you how to approach something, you should do your best to answer their question first, and not immediately jump into taking actions.
|
||||
3. Do not add additional code explanation summary unless requested by the user. After working on a file, just stop, rather than providing an explanation of what you did.
|
||||
|
||||
# Following conventions
|
||||
When making changes to files, first understand the file's code conventions. Mimic code style, use existing libraries and utilities, and follow existing patterns.
|
||||
- NEVER assume that a given library is available, even if it is well known. Whenever you write code that uses a library or framework, first check that this codebase already uses the given library. For example, you might look at neighboring files, or check the package.json (or cargo.toml, and so on depending on the language).
|
||||
- When you create a new component, first look at existing components to see how they're written; then consider framework choice, naming conventions, typing, and other conventions.
|
||||
- When you edit a piece of code, first look at the code's surrounding context (especially its imports) to understand the code's choice of frameworks and libraries. Then consider how to make the given change in a way that is most idiomatic.
|
||||
- Always follow security best practices. Never introduce code that exposes or logs secrets and keys. Never commit secrets or keys to the repository.
|
||||
|
||||
# Code style
|
||||
- Do not add comments to the code you write, unless the user asks you to, or the code is complex and requires additional context.
|
||||
|
||||
# Doing tasks
|
||||
The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:
|
||||
1. Use the available search tools to understand the codebase and the user's query. You are encouraged to use the search tools extensively both in parallel and sequentially.
|
||||
2. Implement the solution using all tools available to you
|
||||
3. Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach.
|
||||
4. VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to opencode.md so that you will know to run it next time.
|
||||
|
||||
NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
|
||||
|
||||
# Tool usage policy
|
||||
- When doing file search, prefer to use the Agent tool in order to reduce context usage.
|
||||
- If you intend to call multiple tools and there are no dependencies between the calls, make all of the independent calls in the same function_calls block.
|
||||
- IMPORTANT: The user does not see the full output of the tool responses, so if you need the output of the tool for the response make sure to summarize it for the user.
|
||||
|
||||
You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.`
|
||||
|
||||
func getEnvironmentInfo() string {
|
||||
cwd := config.WorkingDirectory()
|
||||
isGit := isGitRepo(cwd)
|
||||
platform := runtime.GOOS
|
||||
date := time.Now().Format("1/2/2006")
|
||||
ls := tools.NewLsTool()
|
||||
r, _ := ls.Run(context.Background(), tools.ToolCall{
|
||||
Input: `{"path":"."}`,
|
||||
})
|
||||
return fmt.Sprintf(`Here is useful information about the environment you are running in:
|
||||
<env>
|
||||
Working directory: %s
|
||||
Is directory a git repo: %s
|
||||
Platform: %s
|
||||
Today's date: %s
|
||||
</env>
|
||||
<project>
|
||||
%s
|
||||
</project>
|
||||
`, cwd, boolToYesNo(isGit), platform, date, r.Content)
|
||||
}
|
||||
|
||||
func isGitRepo(dir string) bool {
|
||||
_, err := os.Stat(filepath.Join(dir, ".git"))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func lspInformation() string {
|
||||
cfg := config.Get()
|
||||
hasLSP := false
|
||||
for _, v := range cfg.LSP {
|
||||
if !v.Disabled {
|
||||
hasLSP = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasLSP {
|
||||
return ""
|
||||
}
|
||||
return `# LSP Information
|
||||
Tools that support it will also include useful diagnostics such as linting and typechecking.
|
||||
- These diagnostics will be automatically enabled when you run the tool, and will be displayed in the output at the bottom within the <file_diagnostics></file_diagnostics> and <project_diagnostics></project_diagnostics> tags.
|
||||
- Take necessary actions to fix the issues.
|
||||
- You should ignore diagnostics of files that you did not change or are not related or caused by your changes unless the user explicitly asks you to fix them.
|
||||
`
|
||||
}
|
||||
|
||||
func boolToYesNo(b bool) string {
|
||||
if b {
|
||||
return "Yes"
|
||||
}
|
||||
return "No"
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
package prompt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/sst/opencode/internal/config"
|
||||
"github.com/sst/opencode/internal/llm/models"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
func GetAgentPrompt(agentName config.AgentName, provider models.ModelProvider) string {
|
||||
basePrompt := ""
|
||||
switch agentName {
|
||||
case config.AgentPrimary:
|
||||
basePrompt = PrimaryPrompt(provider)
|
||||
case config.AgentTitle:
|
||||
basePrompt = TitlePrompt(provider)
|
||||
case config.AgentTask:
|
||||
basePrompt = TaskPrompt(provider)
|
||||
default:
|
||||
basePrompt = "You are a helpful assistant"
|
||||
}
|
||||
|
||||
if agentName == config.AgentPrimary || agentName == config.AgentTask {
|
||||
// Add context from project-specific instruction files if they exist
|
||||
contextContent := getContextFromPaths()
|
||||
slog.Debug("Context content", "Context", contextContent)
|
||||
if contextContent != "" {
|
||||
return fmt.Sprintf("%s\n\n# Project-Specific Context\n Make sure to follow the instructions in the context below\n%s", basePrompt, contextContent)
|
||||
}
|
||||
}
|
||||
return basePrompt
|
||||
}
|
||||
|
||||
var (
|
||||
onceContext sync.Once
|
||||
contextContent string
|
||||
)
|
||||
|
||||
func getContextFromPaths() string {
|
||||
onceContext.Do(func() {
|
||||
var (
|
||||
cfg = config.Get()
|
||||
workDir = cfg.WorkingDir
|
||||
contextPaths = cfg.ContextPaths
|
||||
)
|
||||
|
||||
contextContent = processContextPaths(workDir, contextPaths)
|
||||
})
|
||||
|
||||
return contextContent
|
||||
}
|
||||
|
||||
func processContextPaths(workDir string, paths []string) string {
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
resultCh = make(chan string)
|
||||
)
|
||||
|
||||
// Track processed files to avoid duplicates
|
||||
processedFiles := make(map[string]bool)
|
||||
var processedMutex sync.Mutex
|
||||
|
||||
for _, path := range paths {
|
||||
wg.Add(1)
|
||||
go func(p string) {
|
||||
defer wg.Done()
|
||||
|
||||
if strings.HasSuffix(p, "/") {
|
||||
filepath.WalkDir(filepath.Join(workDir, p), func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !d.IsDir() {
|
||||
// Check if we've already processed this file (case-insensitive)
|
||||
processedMutex.Lock()
|
||||
lowerPath := strings.ToLower(path)
|
||||
if !processedFiles[lowerPath] {
|
||||
processedFiles[lowerPath] = true
|
||||
processedMutex.Unlock()
|
||||
|
||||
if result := processFile(path); result != "" {
|
||||
resultCh <- result
|
||||
}
|
||||
} else {
|
||||
processedMutex.Unlock()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
} else {
|
||||
fullPath := filepath.Join(workDir, p)
|
||||
|
||||
// Check if we've already processed this file (case-insensitive)
|
||||
processedMutex.Lock()
|
||||
lowerPath := strings.ToLower(fullPath)
|
||||
if !processedFiles[lowerPath] {
|
||||
processedFiles[lowerPath] = true
|
||||
processedMutex.Unlock()
|
||||
|
||||
result := processFile(fullPath)
|
||||
if result != "" {
|
||||
resultCh <- result
|
||||
}
|
||||
} else {
|
||||
processedMutex.Unlock()
|
||||
}
|
||||
}
|
||||
}(path)
|
||||
}
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(resultCh)
|
||||
}()
|
||||
|
||||
results := make([]string, 0)
|
||||
for result := range resultCh {
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
return strings.Join(results, "\n")
|
||||
}
|
||||
|
||||
func processFile(filePath string) string {
|
||||
content, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return "# From:" + filePath + "\n" + string(content)
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
package prompt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/sst/opencode/internal/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetContextFromPaths(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
lvl := new(slog.LevelVar)
|
||||
lvl.Set(slog.LevelDebug)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
_, err := config.Load(tmpDir, false, lvl)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load config: %v", err)
|
||||
}
|
||||
cfg := config.Get()
|
||||
cfg.WorkingDir = tmpDir
|
||||
cfg.ContextPaths = []string{
|
||||
"file.txt",
|
||||
"directory/",
|
||||
}
|
||||
testFiles := []string{
|
||||
"file.txt",
|
||||
"directory/file_a.txt",
|
||||
"directory/file_b.txt",
|
||||
"directory/file_c.txt",
|
||||
}
|
||||
|
||||
createTestFiles(t, tmpDir, testFiles)
|
||||
|
||||
context := getContextFromPaths()
|
||||
expectedContext := fmt.Sprintf("# From:%s/file.txt\nfile.txt: test content\n# From:%s/directory/file_a.txt\ndirectory/file_a.txt: test content\n# From:%s/directory/file_b.txt\ndirectory/file_b.txt: test content\n# From:%s/directory/file_c.txt\ndirectory/file_c.txt: test content", tmpDir, tmpDir, tmpDir, tmpDir)
|
||||
assert.Equal(t, expectedContext, context)
|
||||
}
|
||||
|
||||
func createTestFiles(t *testing.T, tmpDir string, testFiles []string) {
|
||||
t.Helper()
|
||||
for _, path := range testFiles {
|
||||
fullPath := filepath.Join(tmpDir, path)
|
||||
if path[len(path)-1] == '/' {
|
||||
err := os.MkdirAll(fullPath, 0755)
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
dir := filepath.Dir(fullPath)
|
||||
err := os.MkdirAll(dir, 0755)
|
||||
require.NoError(t, err)
|
||||
err = os.WriteFile(fullPath, []byte(path+": test content"), 0644)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package prompt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/sst/opencode/internal/llm/models"
|
||||
)
|
||||
|
||||
func TaskPrompt(_ models.ModelProvider) string {
|
||||
agentPrompt := `You are an agent for OpenCode. Given the user's prompt, you should use the tools available to you to answer the user's question.
|
||||
Notes:
|
||||
1. IMPORTANT: You should be concise, direct, and to the point, since your responses will be displayed on a command line interface. Answer the user's question directly, without elaboration, explanation, or details. One word answers are best. Avoid introductions, conclusions, and explanations. You MUST avoid text before/after your response, such as "The answer is <answer>.", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...".
|
||||
2. When relevant, share file names and code snippets relevant to the query
|
||||
3. Any file paths you return in your final response MUST be absolute. DO NOT use relative paths.`
|
||||
|
||||
return fmt.Sprintf("%s\n%s\n", agentPrompt, getEnvironmentInfo())
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package prompt
|
||||
|
||||
import "github.com/sst/opencode/internal/llm/models"
|
||||
|
||||
func TitlePrompt(_ models.ModelProvider) string {
|
||||
return `you will generate a short title based on the first message a user begins a conversation with
|
||||
- ensure it is not more than 50 characters long
|
||||
- the title should be a summary of the user's message
|
||||
- it should be one line long
|
||||
- do not use quotes or colons
|
||||
- the entire text you return will be used as the title
|
||||
- never return anything that is more than one sentence (one line) long`
|
||||
}
|
||||
@@ -1,470 +0,0 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/anthropics/anthropic-sdk-go"
|
||||
"github.com/anthropics/anthropic-sdk-go/bedrock"
|
||||
"github.com/anthropics/anthropic-sdk-go/option"
|
||||
"github.com/sst/opencode/internal/config"
|
||||
"github.com/sst/opencode/internal/llm/models"
|
||||
"github.com/sst/opencode/internal/llm/tools"
|
||||
"github.com/sst/opencode/internal/message"
|
||||
"github.com/sst/opencode/internal/status"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
type anthropicOptions struct {
|
||||
useBedrock bool
|
||||
disableCache bool
|
||||
shouldThink func(userMessage string) bool
|
||||
}
|
||||
|
||||
type AnthropicOption func(*anthropicOptions)
|
||||
|
||||
type anthropicClient struct {
|
||||
providerOptions providerClientOptions
|
||||
options anthropicOptions
|
||||
client anthropic.Client
|
||||
}
|
||||
|
||||
type AnthropicClient ProviderClient
|
||||
|
||||
func newAnthropicClient(opts providerClientOptions) AnthropicClient {
|
||||
anthropicOpts := anthropicOptions{}
|
||||
for _, o := range opts.anthropicOptions {
|
||||
o(&anthropicOpts)
|
||||
}
|
||||
|
||||
anthropicClientOptions := []option.RequestOption{}
|
||||
if opts.apiKey != "" {
|
||||
anthropicClientOptions = append(anthropicClientOptions, option.WithAPIKey(opts.apiKey))
|
||||
}
|
||||
if anthropicOpts.useBedrock {
|
||||
anthropicClientOptions = append(anthropicClientOptions, bedrock.WithLoadDefaultConfig(context.Background()))
|
||||
}
|
||||
|
||||
client := anthropic.NewClient(anthropicClientOptions...)
|
||||
return &anthropicClient{
|
||||
providerOptions: opts,
|
||||
options: anthropicOpts,
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *anthropicClient) convertMessages(messages []message.Message) (anthropicMessages []anthropic.MessageParam) {
|
||||
for i, msg := range messages {
|
||||
cache := false
|
||||
if i > len(messages)-3 {
|
||||
cache = true
|
||||
}
|
||||
switch msg.Role {
|
||||
case message.User:
|
||||
content := anthropic.NewTextBlock(msg.Content().String())
|
||||
if cache && !a.options.disableCache {
|
||||
content.OfRequestTextBlock.CacheControl = anthropic.CacheControlEphemeralParam{
|
||||
Type: "ephemeral",
|
||||
}
|
||||
}
|
||||
var contentBlocks []anthropic.ContentBlockParamUnion
|
||||
contentBlocks = append(contentBlocks, content)
|
||||
for _, binaryContent := range msg.BinaryContent() {
|
||||
base64Image := binaryContent.String(models.ProviderAnthropic)
|
||||
imageBlock := anthropic.NewImageBlockBase64(binaryContent.MIMEType, base64Image)
|
||||
contentBlocks = append(contentBlocks, imageBlock)
|
||||
}
|
||||
anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(contentBlocks...))
|
||||
|
||||
case message.Assistant:
|
||||
blocks := []anthropic.ContentBlockParamUnion{}
|
||||
|
||||
if msg.Content() != nil {
|
||||
content := msg.Content().String()
|
||||
if strings.TrimSpace(content) != "" {
|
||||
block := anthropic.NewTextBlock(content)
|
||||
if cache && !a.options.disableCache {
|
||||
block.OfRequestTextBlock.CacheControl = anthropic.CacheControlEphemeralParam{
|
||||
Type: "ephemeral",
|
||||
}
|
||||
}
|
||||
blocks = append(blocks, block)
|
||||
}
|
||||
}
|
||||
|
||||
for _, toolCall := range msg.ToolCalls() {
|
||||
var inputMap map[string]any
|
||||
err := json.Unmarshal([]byte(toolCall.Input), &inputMap)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
blocks = append(blocks, anthropic.ContentBlockParamOfRequestToolUseBlock(toolCall.ID, inputMap, toolCall.Name))
|
||||
}
|
||||
|
||||
if len(blocks) == 0 {
|
||||
slog.Warn("There is a message without content, investigate, this should not happen")
|
||||
continue
|
||||
}
|
||||
anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...))
|
||||
|
||||
case message.Tool:
|
||||
results := make([]anthropic.ContentBlockParamUnion, len(msg.ToolResults()))
|
||||
for i, toolResult := range msg.ToolResults() {
|
||||
results[i] = anthropic.NewToolResultBlock(toolResult.ToolCallID, toolResult.Content, toolResult.IsError)
|
||||
}
|
||||
anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(results...))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (a *anthropicClient) convertTools(tools []tools.BaseTool) []anthropic.ToolUnionParam {
|
||||
anthropicTools := make([]anthropic.ToolUnionParam, len(tools))
|
||||
|
||||
for i, tool := range tools {
|
||||
info := tool.Info()
|
||||
toolParam := anthropic.ToolParam{
|
||||
Name: info.Name,
|
||||
Description: anthropic.String(info.Description),
|
||||
InputSchema: anthropic.ToolInputSchemaParam{
|
||||
Properties: info.Parameters,
|
||||
// TODO: figure out how we can tell claude the required fields?
|
||||
},
|
||||
}
|
||||
|
||||
if i == len(tools)-1 && !a.options.disableCache {
|
||||
toolParam.CacheControl = anthropic.CacheControlEphemeralParam{
|
||||
Type: "ephemeral",
|
||||
}
|
||||
}
|
||||
|
||||
anthropicTools[i] = anthropic.ToolUnionParam{OfTool: &toolParam}
|
||||
}
|
||||
|
||||
return anthropicTools
|
||||
}
|
||||
|
||||
func (a *anthropicClient) finishReason(reason string) message.FinishReason {
|
||||
switch reason {
|
||||
case "end_turn":
|
||||
return message.FinishReasonEndTurn
|
||||
case "max_tokens":
|
||||
return message.FinishReasonMaxTokens
|
||||
case "tool_use":
|
||||
return message.FinishReasonToolUse
|
||||
case "stop_sequence":
|
||||
return message.FinishReasonEndTurn
|
||||
default:
|
||||
return message.FinishReasonUnknown
|
||||
}
|
||||
}
|
||||
|
||||
func (a *anthropicClient) preparedMessages(messages []anthropic.MessageParam, tools []anthropic.ToolUnionParam) anthropic.MessageNewParams {
|
||||
var thinkingParam anthropic.ThinkingConfigParamUnion
|
||||
lastMessage := messages[len(messages)-1]
|
||||
isUser := lastMessage.Role == anthropic.MessageParamRoleUser
|
||||
messageContent := ""
|
||||
temperature := anthropic.Float(0)
|
||||
if isUser {
|
||||
for _, m := range lastMessage.Content {
|
||||
if m.OfRequestTextBlock != nil && m.OfRequestTextBlock.Text != "" {
|
||||
messageContent = m.OfRequestTextBlock.Text
|
||||
}
|
||||
}
|
||||
if messageContent != "" && a.options.shouldThink != nil && a.options.shouldThink(messageContent) {
|
||||
thinkingParam = anthropic.ThinkingConfigParamUnion{
|
||||
OfThinkingConfigEnabled: &anthropic.ThinkingConfigEnabledParam{
|
||||
BudgetTokens: int64(float64(a.providerOptions.maxTokens) * 0.8),
|
||||
Type: "enabled",
|
||||
},
|
||||
}
|
||||
temperature = anthropic.Float(1)
|
||||
}
|
||||
}
|
||||
|
||||
return anthropic.MessageNewParams{
|
||||
Model: anthropic.Model(a.providerOptions.model.APIModel),
|
||||
MaxTokens: a.providerOptions.maxTokens,
|
||||
Temperature: temperature,
|
||||
Messages: messages,
|
||||
Tools: tools,
|
||||
Thinking: thinkingParam,
|
||||
System: []anthropic.TextBlockParam{
|
||||
{
|
||||
Text: a.providerOptions.systemMessage,
|
||||
CacheControl: anthropic.CacheControlEphemeralParam{
|
||||
Type: "ephemeral",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *anthropicClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (resposne *ProviderResponse, err error) {
|
||||
preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools))
|
||||
cfg := config.Get()
|
||||
if cfg.Debug {
|
||||
jsonData, _ := json.Marshal(preparedMessages)
|
||||
slog.Debug("Prepared messages", "messages", string(jsonData))
|
||||
}
|
||||
|
||||
attempts := 0
|
||||
for {
|
||||
attempts++
|
||||
anthropicResponse, err := a.client.Messages.New(
|
||||
ctx,
|
||||
preparedMessages,
|
||||
)
|
||||
// If there is an error we are going to see if we can retry the call
|
||||
if err != nil {
|
||||
slog.Error("Error in Anthropic API call", "error", err)
|
||||
retry, after, retryErr := a.shouldRetry(attempts, err)
|
||||
if retryErr != nil {
|
||||
return nil, retryErr
|
||||
}
|
||||
if retry {
|
||||
status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries))
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(time.Duration(after) * time.Millisecond):
|
||||
continue
|
||||
}
|
||||
}
|
||||
return nil, retryErr
|
||||
}
|
||||
|
||||
content := ""
|
||||
for _, block := range anthropicResponse.Content {
|
||||
if text, ok := block.AsAny().(anthropic.TextBlock); ok {
|
||||
content += text.Text
|
||||
}
|
||||
}
|
||||
|
||||
return &ProviderResponse{
|
||||
Content: content,
|
||||
ToolCalls: a.toolCalls(*anthropicResponse),
|
||||
Usage: a.usage(*anthropicResponse),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (a *anthropicClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
|
||||
preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools))
|
||||
cfg := config.Get()
|
||||
if cfg.Debug {
|
||||
jsonData, _ := json.Marshal(preparedMessages)
|
||||
slog.Debug("Prepared messages", "messages", string(jsonData))
|
||||
}
|
||||
attempts := 0
|
||||
eventChan := make(chan ProviderEvent)
|
||||
go func() {
|
||||
for {
|
||||
attempts++
|
||||
anthropicStream := a.client.Messages.NewStreaming(
|
||||
ctx,
|
||||
preparedMessages,
|
||||
)
|
||||
accumulatedMessage := anthropic.Message{}
|
||||
|
||||
currentToolCallID := ""
|
||||
for anthropicStream.Next() {
|
||||
event := anthropicStream.Current()
|
||||
err := accumulatedMessage.Accumulate(event)
|
||||
if err != nil {
|
||||
slog.Warn("Error accumulating message", "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
switch event := event.AsAny().(type) {
|
||||
case anthropic.ContentBlockStartEvent:
|
||||
if event.ContentBlock.Type == "text" {
|
||||
eventChan <- ProviderEvent{Type: EventContentStart}
|
||||
} else if event.ContentBlock.Type == "tool_use" {
|
||||
currentToolCallID = event.ContentBlock.ID
|
||||
eventChan <- ProviderEvent{
|
||||
Type: EventToolUseStart,
|
||||
ToolCall: &message.ToolCall{
|
||||
ID: event.ContentBlock.ID,
|
||||
Name: event.ContentBlock.Name,
|
||||
Finished: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
case anthropic.ContentBlockDeltaEvent:
|
||||
if event.Delta.Type == "thinking_delta" && event.Delta.Thinking != "" {
|
||||
eventChan <- ProviderEvent{
|
||||
Type: EventThinkingDelta,
|
||||
Thinking: event.Delta.Thinking,
|
||||
}
|
||||
} else if event.Delta.Type == "text_delta" && event.Delta.Text != "" {
|
||||
eventChan <- ProviderEvent{
|
||||
Type: EventContentDelta,
|
||||
Content: event.Delta.Text,
|
||||
}
|
||||
} else if event.Delta.Type == "input_json_delta" {
|
||||
if currentToolCallID != "" {
|
||||
eventChan <- ProviderEvent{
|
||||
Type: EventToolUseDelta,
|
||||
ToolCall: &message.ToolCall{
|
||||
ID: currentToolCallID,
|
||||
Finished: false,
|
||||
Input: event.Delta.JSON.PartialJSON.Raw(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
case anthropic.ContentBlockStopEvent:
|
||||
if currentToolCallID != "" {
|
||||
eventChan <- ProviderEvent{
|
||||
Type: EventToolUseStop,
|
||||
ToolCall: &message.ToolCall{
|
||||
ID: currentToolCallID,
|
||||
},
|
||||
}
|
||||
currentToolCallID = ""
|
||||
} else {
|
||||
eventChan <- ProviderEvent{Type: EventContentStop}
|
||||
}
|
||||
|
||||
case anthropic.MessageStopEvent:
|
||||
content := ""
|
||||
for _, block := range accumulatedMessage.Content {
|
||||
if text, ok := block.AsAny().(anthropic.TextBlock); ok {
|
||||
content += text.Text
|
||||
}
|
||||
}
|
||||
|
||||
eventChan <- ProviderEvent{
|
||||
Type: EventComplete,
|
||||
Response: &ProviderResponse{
|
||||
Content: content,
|
||||
ToolCalls: a.toolCalls(accumulatedMessage),
|
||||
Usage: a.usage(accumulatedMessage),
|
||||
FinishReason: a.finishReason(string(accumulatedMessage.StopReason)),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err := anthropicStream.Err()
|
||||
if err == nil || errors.Is(err, io.EOF) {
|
||||
close(eventChan)
|
||||
return
|
||||
}
|
||||
// If there is an error we are going to see if we can retry the call
|
||||
retry, after, retryErr := a.shouldRetry(attempts, err)
|
||||
if retryErr != nil {
|
||||
eventChan <- ProviderEvent{Type: EventError, Error: retryErr}
|
||||
close(eventChan)
|
||||
return
|
||||
}
|
||||
if retry {
|
||||
status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries))
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// context cancelled
|
||||
if ctx.Err() != nil {
|
||||
eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
|
||||
}
|
||||
close(eventChan)
|
||||
return
|
||||
case <-time.After(time.Duration(after) * time.Millisecond):
|
||||
continue
|
||||
}
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
|
||||
}
|
||||
|
||||
close(eventChan)
|
||||
return
|
||||
}
|
||||
}()
|
||||
return eventChan
|
||||
}
|
||||
|
||||
func (a *anthropicClient) shouldRetry(attempts int, err error) (bool, int64, error) {
|
||||
var apierr *anthropic.Error
|
||||
if !errors.As(err, &apierr) {
|
||||
return false, 0, err
|
||||
}
|
||||
|
||||
if apierr.StatusCode != 429 && apierr.StatusCode != 529 {
|
||||
return false, 0, err
|
||||
}
|
||||
|
||||
if attempts > maxRetries {
|
||||
return false, 0, fmt.Errorf("maximum retry attempts reached for rate limit: %d retries", maxRetries)
|
||||
}
|
||||
|
||||
retryMs := 0
|
||||
retryAfterValues := apierr.Response.Header.Values("Retry-After")
|
||||
|
||||
backoffMs := 2000 * (1 << (attempts - 1))
|
||||
jitterMs := int(float64(backoffMs) * 0.2)
|
||||
retryMs = backoffMs + jitterMs
|
||||
if len(retryAfterValues) > 0 {
|
||||
if _, err := fmt.Sscanf(retryAfterValues[0], "%d", &retryMs); err == nil {
|
||||
retryMs = retryMs * 1000
|
||||
}
|
||||
}
|
||||
return true, int64(retryMs), nil
|
||||
}
|
||||
|
||||
func (a *anthropicClient) toolCalls(msg anthropic.Message) []message.ToolCall {
|
||||
var toolCalls []message.ToolCall
|
||||
|
||||
for _, block := range msg.Content {
|
||||
switch variant := block.AsAny().(type) {
|
||||
case anthropic.ToolUseBlock:
|
||||
toolCall := message.ToolCall{
|
||||
ID: variant.ID,
|
||||
Name: variant.Name,
|
||||
Input: string(variant.Input),
|
||||
Type: string(variant.Type),
|
||||
Finished: true,
|
||||
}
|
||||
toolCalls = append(toolCalls, toolCall)
|
||||
}
|
||||
}
|
||||
|
||||
return toolCalls
|
||||
}
|
||||
|
||||
func (a *anthropicClient) usage(msg anthropic.Message) TokenUsage {
|
||||
return TokenUsage{
|
||||
InputTokens: msg.Usage.InputTokens,
|
||||
OutputTokens: msg.Usage.OutputTokens,
|
||||
CacheCreationTokens: msg.Usage.CacheCreationInputTokens,
|
||||
CacheReadTokens: msg.Usage.CacheReadInputTokens,
|
||||
}
|
||||
}
|
||||
|
||||
func WithAnthropicBedrock(useBedrock bool) AnthropicOption {
|
||||
return func(options *anthropicOptions) {
|
||||
options.useBedrock = useBedrock
|
||||
}
|
||||
}
|
||||
|
||||
func WithAnthropicDisableCache() AnthropicOption {
|
||||
return func(options *anthropicOptions) {
|
||||
options.disableCache = true
|
||||
}
|
||||
}
|
||||
|
||||
func DefaultShouldThinkFn(s string) bool {
|
||||
return strings.Contains(strings.ToLower(s), "think")
|
||||
}
|
||||
|
||||
func WithAnthropicShouldThinkFn(fn func(string) bool) AnthropicOption {
|
||||
return func(options *anthropicOptions) {
|
||||
options.shouldThink = fn
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
|
||||
"github.com/openai/openai-go"
|
||||
"github.com/openai/openai-go/azure"
|
||||
"github.com/openai/openai-go/option"
|
||||
)
|
||||
|
||||
type azureClient struct {
|
||||
*openaiClient
|
||||
}
|
||||
|
||||
type AzureClient ProviderClient
|
||||
|
||||
func newAzureClient(opts providerClientOptions) AzureClient {
|
||||
|
||||
endpoint := os.Getenv("AZURE_OPENAI_ENDPOINT") // ex: https://foo.openai.azure.com
|
||||
apiVersion := os.Getenv("AZURE_OPENAI_API_VERSION") // ex: 2025-04-01-preview
|
||||
|
||||
if endpoint == "" || apiVersion == "" {
|
||||
return &azureClient{openaiClient: newOpenAIClient(opts).(*openaiClient)}
|
||||
}
|
||||
|
||||
reqOpts := []option.RequestOption{
|
||||
azure.WithEndpoint(endpoint, apiVersion),
|
||||
}
|
||||
|
||||
if opts.apiKey != "" || os.Getenv("AZURE_OPENAI_API_KEY") != "" {
|
||||
key := opts.apiKey
|
||||
if key == "" {
|
||||
key = os.Getenv("AZURE_OPENAI_API_KEY")
|
||||
}
|
||||
reqOpts = append(reqOpts, azure.WithAPIKey(key))
|
||||
} else if cred, err := azidentity.NewDefaultAzureCredential(nil); err == nil {
|
||||
reqOpts = append(reqOpts, azure.WithTokenCredential(cred))
|
||||
}
|
||||
|
||||
base := &openaiClient{
|
||||
providerOptions: opts,
|
||||
client: openai.NewClient(reqOpts...),
|
||||
}
|
||||
|
||||
return &azureClient{openaiClient: base}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/sst/opencode/internal/llm/tools"
|
||||
"github.com/sst/opencode/internal/message"
|
||||
)
|
||||
|
||||
type bedrockOptions struct {
|
||||
// Bedrock specific options can be added here
|
||||
}
|
||||
|
||||
type BedrockOption func(*bedrockOptions)
|
||||
|
||||
type bedrockClient struct {
|
||||
providerOptions providerClientOptions
|
||||
options bedrockOptions
|
||||
childProvider ProviderClient
|
||||
}
|
||||
|
||||
type BedrockClient ProviderClient
|
||||
|
||||
func newBedrockClient(opts providerClientOptions) BedrockClient {
|
||||
bedrockOpts := bedrockOptions{}
|
||||
// Apply bedrock specific options if they are added in the future
|
||||
|
||||
// Get AWS region from environment
|
||||
region := os.Getenv("AWS_REGION")
|
||||
if region == "" {
|
||||
region = os.Getenv("AWS_DEFAULT_REGION")
|
||||
}
|
||||
|
||||
if region == "" {
|
||||
region = "us-east-1" // default region
|
||||
}
|
||||
if len(region) < 2 {
|
||||
return &bedrockClient{
|
||||
providerOptions: opts,
|
||||
options: bedrockOpts,
|
||||
childProvider: nil, // Will cause an error when used
|
||||
}
|
||||
}
|
||||
|
||||
// Prefix the model name with region
|
||||
regionPrefix := region[:2]
|
||||
modelName := opts.model.APIModel
|
||||
opts.model.APIModel = fmt.Sprintf("%s.%s", regionPrefix, modelName)
|
||||
|
||||
// Determine which provider to use based on the model
|
||||
if strings.Contains(string(opts.model.APIModel), "anthropic") {
|
||||
// Create Anthropic client with Bedrock configuration
|
||||
anthropicOpts := opts
|
||||
anthropicOpts.anthropicOptions = append(anthropicOpts.anthropicOptions,
|
||||
WithAnthropicBedrock(true),
|
||||
WithAnthropicDisableCache(),
|
||||
)
|
||||
return &bedrockClient{
|
||||
providerOptions: opts,
|
||||
options: bedrockOpts,
|
||||
childProvider: newAnthropicClient(anthropicOpts),
|
||||
}
|
||||
}
|
||||
|
||||
// Return client with nil childProvider if model is not supported
|
||||
// This will cause an error when used
|
||||
return &bedrockClient{
|
||||
providerOptions: opts,
|
||||
options: bedrockOpts,
|
||||
childProvider: nil,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *bedrockClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) {
|
||||
if b.childProvider == nil {
|
||||
return nil, errors.New("unsupported model for bedrock provider")
|
||||
}
|
||||
return b.childProvider.send(ctx, messages, tools)
|
||||
}
|
||||
|
||||
func (b *bedrockClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
|
||||
eventChan := make(chan ProviderEvent)
|
||||
|
||||
if b.childProvider == nil {
|
||||
go func() {
|
||||
eventChan <- ProviderEvent{
|
||||
Type: EventError,
|
||||
Error: errors.New("unsupported model for bedrock provider"),
|
||||
}
|
||||
close(eventChan)
|
||||
}()
|
||||
return eventChan
|
||||
}
|
||||
|
||||
return b.childProvider.stream(ctx, messages, tools)
|
||||
}
|
||||
@@ -1,547 +0,0 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/sst/opencode/internal/config"
|
||||
"github.com/sst/opencode/internal/llm/tools"
|
||||
"github.com/sst/opencode/internal/message"
|
||||
"github.com/sst/opencode/internal/status"
|
||||
"google.golang.org/genai"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
type geminiOptions struct {
|
||||
disableCache bool
|
||||
}
|
||||
|
||||
type GeminiOption func(*geminiOptions)
|
||||
|
||||
type geminiClient struct {
|
||||
providerOptions providerClientOptions
|
||||
options geminiOptions
|
||||
client *genai.Client
|
||||
}
|
||||
|
||||
type GeminiClient ProviderClient
|
||||
|
||||
func newGeminiClient(opts providerClientOptions) GeminiClient {
|
||||
geminiOpts := geminiOptions{}
|
||||
for _, o := range opts.geminiOptions {
|
||||
o(&geminiOpts)
|
||||
}
|
||||
|
||||
client, err := genai.NewClient(context.Background(), &genai.ClientConfig{APIKey: opts.apiKey, Backend: genai.BackendGeminiAPI})
|
||||
if err != nil {
|
||||
slog.Error("Failed to create Gemini client", "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
return &geminiClient{
|
||||
providerOptions: opts,
|
||||
options: geminiOpts,
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
func (g *geminiClient) convertMessages(messages []message.Message) []*genai.Content {
|
||||
var history []*genai.Content
|
||||
for _, msg := range messages {
|
||||
switch msg.Role {
|
||||
case message.User:
|
||||
var parts []*genai.Part
|
||||
parts = append(parts, &genai.Part{Text: msg.Content().String()})
|
||||
for _, binaryContent := range msg.BinaryContent() {
|
||||
imageFormat := strings.Split(binaryContent.MIMEType, "/")
|
||||
parts = append(parts, &genai.Part{InlineData: &genai.Blob{
|
||||
MIMEType: imageFormat[1],
|
||||
Data: binaryContent.Data,
|
||||
}})
|
||||
}
|
||||
history = append(history, &genai.Content{
|
||||
Parts: parts,
|
||||
Role: "user",
|
||||
})
|
||||
case message.Assistant:
|
||||
content := &genai.Content{
|
||||
Role: "model",
|
||||
Parts: []*genai.Part{},
|
||||
}
|
||||
|
||||
if msg.Content().String() != "" {
|
||||
content.Parts = append(content.Parts, &genai.Part{Text: msg.Content().String()})
|
||||
}
|
||||
|
||||
if len(msg.ToolCalls()) > 0 {
|
||||
for _, call := range msg.ToolCalls() {
|
||||
args, _ := parseJsonToMap(call.Input)
|
||||
content.Parts = append(content.Parts, &genai.Part{
|
||||
FunctionCall: &genai.FunctionCall{
|
||||
Name: call.Name,
|
||||
Args: args,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
history = append(history, content)
|
||||
|
||||
case message.Tool:
|
||||
for _, result := range msg.ToolResults() {
|
||||
response := map[string]interface{}{"result": result.Content}
|
||||
parsed, err := parseJsonToMap(result.Content)
|
||||
if err == nil {
|
||||
response = parsed
|
||||
}
|
||||
|
||||
var toolCall message.ToolCall
|
||||
for _, m := range messages {
|
||||
if m.Role == message.Assistant {
|
||||
for _, call := range m.ToolCalls() {
|
||||
if call.ID == result.ToolCallID {
|
||||
toolCall = call
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
history = append(history, &genai.Content{
|
||||
Parts: []*genai.Part{
|
||||
{
|
||||
FunctionResponse: &genai.FunctionResponse{
|
||||
Name: toolCall.Name,
|
||||
Response: response,
|
||||
},
|
||||
},
|
||||
},
|
||||
Role: "function",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return history
|
||||
}
|
||||
|
||||
func (g *geminiClient) convertTools(tools []tools.BaseTool) []*genai.Tool {
|
||||
geminiTool := &genai.Tool{}
|
||||
geminiTool.FunctionDeclarations = make([]*genai.FunctionDeclaration, 0, len(tools))
|
||||
|
||||
for _, tool := range tools {
|
||||
info := tool.Info()
|
||||
declaration := &genai.FunctionDeclaration{
|
||||
Name: info.Name,
|
||||
Description: info.Description,
|
||||
Parameters: &genai.Schema{
|
||||
Type: genai.TypeObject,
|
||||
Properties: convertSchemaProperties(info.Parameters),
|
||||
Required: info.Required,
|
||||
},
|
||||
}
|
||||
|
||||
geminiTool.FunctionDeclarations = append(geminiTool.FunctionDeclarations, declaration)
|
||||
}
|
||||
|
||||
return []*genai.Tool{geminiTool}
|
||||
}
|
||||
|
||||
func (g *geminiClient) finishReason(reason genai.FinishReason) message.FinishReason {
|
||||
switch {
|
||||
case reason == genai.FinishReasonStop:
|
||||
return message.FinishReasonEndTurn
|
||||
case reason == genai.FinishReasonMaxTokens:
|
||||
return message.FinishReasonMaxTokens
|
||||
default:
|
||||
return message.FinishReasonUnknown
|
||||
}
|
||||
}
|
||||
|
||||
func (g *geminiClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) {
|
||||
// Convert messages
|
||||
geminiMessages := g.convertMessages(messages)
|
||||
|
||||
cfg := config.Get()
|
||||
if cfg.Debug {
|
||||
jsonData, _ := json.Marshal(geminiMessages)
|
||||
slog.Debug("Prepared messages", "messages", string(jsonData))
|
||||
}
|
||||
|
||||
history := geminiMessages[:len(geminiMessages)-1] // All but last message
|
||||
lastMsg := geminiMessages[len(geminiMessages)-1]
|
||||
chat, _ := g.client.Chats.Create(ctx, g.providerOptions.model.APIModel, &genai.GenerateContentConfig{
|
||||
MaxOutputTokens: int32(g.providerOptions.maxTokens),
|
||||
SystemInstruction: &genai.Content{
|
||||
Parts: []*genai.Part{{Text: g.providerOptions.systemMessage}},
|
||||
},
|
||||
Tools: g.convertTools(tools),
|
||||
}, history)
|
||||
|
||||
attempts := 0
|
||||
for {
|
||||
attempts++
|
||||
var toolCalls []message.ToolCall
|
||||
|
||||
var lastMsgParts []genai.Part
|
||||
for _, part := range lastMsg.Parts {
|
||||
lastMsgParts = append(lastMsgParts, *part)
|
||||
}
|
||||
resp, err := chat.SendMessage(ctx, lastMsgParts...)
|
||||
// If there is an error we are going to see if we can retry the call
|
||||
if err != nil {
|
||||
retry, after, retryErr := g.shouldRetry(attempts, err)
|
||||
if retryErr != nil {
|
||||
return nil, retryErr
|
||||
}
|
||||
if retry {
|
||||
status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries))
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(time.Duration(after) * time.Millisecond):
|
||||
continue
|
||||
}
|
||||
}
|
||||
return nil, retryErr
|
||||
}
|
||||
|
||||
content := ""
|
||||
|
||||
if len(resp.Candidates) > 0 && resp.Candidates[0].Content != nil {
|
||||
for _, part := range resp.Candidates[0].Content.Parts {
|
||||
switch {
|
||||
case part.Text != "":
|
||||
content = string(part.Text)
|
||||
case part.FunctionCall != nil:
|
||||
id := "call_" + uuid.New().String()
|
||||
args, _ := json.Marshal(part.FunctionCall.Args)
|
||||
toolCalls = append(toolCalls, message.ToolCall{
|
||||
ID: id,
|
||||
Name: part.FunctionCall.Name,
|
||||
Input: string(args),
|
||||
Type: "function",
|
||||
Finished: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
finishReason := message.FinishReasonEndTurn
|
||||
if len(resp.Candidates) > 0 {
|
||||
finishReason = g.finishReason(resp.Candidates[0].FinishReason)
|
||||
}
|
||||
if len(toolCalls) > 0 {
|
||||
finishReason = message.FinishReasonToolUse
|
||||
}
|
||||
|
||||
return &ProviderResponse{
|
||||
Content: content,
|
||||
ToolCalls: toolCalls,
|
||||
Usage: g.usage(resp),
|
||||
FinishReason: finishReason,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (g *geminiClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
|
||||
// Convert messages
|
||||
geminiMessages := g.convertMessages(messages)
|
||||
|
||||
cfg := config.Get()
|
||||
if cfg.Debug {
|
||||
jsonData, _ := json.Marshal(geminiMessages)
|
||||
slog.Debug("Prepared messages", "messages", string(jsonData))
|
||||
}
|
||||
|
||||
history := geminiMessages[:len(geminiMessages)-1] // All but last message
|
||||
lastMsg := geminiMessages[len(geminiMessages)-1]
|
||||
chat, _ := g.client.Chats.Create(ctx, g.providerOptions.model.APIModel, &genai.GenerateContentConfig{
|
||||
MaxOutputTokens: int32(g.providerOptions.maxTokens),
|
||||
SystemInstruction: &genai.Content{
|
||||
Parts: []*genai.Part{{Text: g.providerOptions.systemMessage}},
|
||||
},
|
||||
Tools: g.convertTools(tools),
|
||||
}, history)
|
||||
|
||||
attempts := 0
|
||||
eventChan := make(chan ProviderEvent)
|
||||
|
||||
go func() {
|
||||
defer close(eventChan)
|
||||
|
||||
for {
|
||||
attempts++
|
||||
|
||||
currentContent := ""
|
||||
toolCalls := []message.ToolCall{}
|
||||
var finalResp *genai.GenerateContentResponse
|
||||
|
||||
eventChan <- ProviderEvent{Type: EventContentStart}
|
||||
|
||||
var lastMsgParts []genai.Part
|
||||
|
||||
for _, part := range lastMsg.Parts {
|
||||
lastMsgParts = append(lastMsgParts, *part)
|
||||
}
|
||||
for resp, err := range chat.SendMessageStream(ctx, lastMsgParts...) {
|
||||
if err != nil {
|
||||
retry, after, retryErr := g.shouldRetry(attempts, err)
|
||||
if retryErr != nil {
|
||||
eventChan <- ProviderEvent{Type: EventError, Error: retryErr}
|
||||
return
|
||||
}
|
||||
if retry {
|
||||
status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries))
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if ctx.Err() != nil {
|
||||
eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
|
||||
}
|
||||
|
||||
return
|
||||
case <-time.After(time.Duration(after) * time.Millisecond):
|
||||
break
|
||||
}
|
||||
} else {
|
||||
eventChan <- ProviderEvent{Type: EventError, Error: err}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
finalResp = resp
|
||||
|
||||
if len(resp.Candidates) > 0 && resp.Candidates[0].Content != nil {
|
||||
for _, part := range resp.Candidates[0].Content.Parts {
|
||||
switch {
|
||||
case part.Text != "":
|
||||
delta := string(part.Text)
|
||||
if delta != "" {
|
||||
eventChan <- ProviderEvent{
|
||||
Type: EventContentDelta,
|
||||
Content: delta,
|
||||
}
|
||||
currentContent += delta
|
||||
}
|
||||
case part.FunctionCall != nil:
|
||||
id := "call_" + uuid.New().String()
|
||||
args, _ := json.Marshal(part.FunctionCall.Args)
|
||||
newCall := message.ToolCall{
|
||||
ID: id,
|
||||
Name: part.FunctionCall.Name,
|
||||
Input: string(args),
|
||||
Type: "function",
|
||||
Finished: true,
|
||||
}
|
||||
|
||||
isNew := true
|
||||
for _, existing := range toolCalls {
|
||||
if existing.Name == newCall.Name && existing.Input == newCall.Input {
|
||||
isNew = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if isNew {
|
||||
toolCalls = append(toolCalls, newCall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
eventChan <- ProviderEvent{Type: EventContentStop}
|
||||
|
||||
if finalResp != nil {
|
||||
|
||||
finishReason := message.FinishReasonEndTurn
|
||||
if len(finalResp.Candidates) > 0 {
|
||||
finishReason = g.finishReason(finalResp.Candidates[0].FinishReason)
|
||||
}
|
||||
if len(toolCalls) > 0 {
|
||||
finishReason = message.FinishReasonToolUse
|
||||
}
|
||||
eventChan <- ProviderEvent{
|
||||
Type: EventComplete,
|
||||
Response: &ProviderResponse{
|
||||
Content: currentContent,
|
||||
ToolCalls: toolCalls,
|
||||
Usage: g.usage(finalResp),
|
||||
FinishReason: finishReason,
|
||||
},
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
}()
|
||||
|
||||
return eventChan
|
||||
}
|
||||
|
||||
func (g *geminiClient) shouldRetry(attempts int, err error) (bool, int64, error) {
|
||||
// Check if error is a rate limit error
|
||||
if attempts > maxRetries {
|
||||
return false, 0, fmt.Errorf("maximum retry attempts reached for rate limit: %d retries", maxRetries)
|
||||
}
|
||||
|
||||
// Gemini doesn't have a standard error type we can check against
|
||||
// So we'll check the error message for rate limit indicators
|
||||
if errors.Is(err, io.EOF) {
|
||||
return false, 0, err
|
||||
}
|
||||
|
||||
errMsg := err.Error()
|
||||
isRateLimit := false
|
||||
|
||||
// Check for common rate limit error messages
|
||||
if contains(errMsg, "rate limit", "quota exceeded", "too many requests") {
|
||||
isRateLimit = true
|
||||
}
|
||||
|
||||
if !isRateLimit {
|
||||
return false, 0, err
|
||||
}
|
||||
|
||||
// Calculate backoff with jitter
|
||||
backoffMs := 2000 * (1 << (attempts - 1))
|
||||
jitterMs := int(float64(backoffMs) * 0.2)
|
||||
retryMs := backoffMs + jitterMs
|
||||
|
||||
return true, int64(retryMs), nil
|
||||
}
|
||||
|
||||
func (g *geminiClient) toolCalls(resp *genai.GenerateContentResponse) []message.ToolCall {
|
||||
var toolCalls []message.ToolCall
|
||||
|
||||
if len(resp.Candidates) > 0 && resp.Candidates[0].Content != nil {
|
||||
for _, part := range resp.Candidates[0].Content.Parts {
|
||||
if part.FunctionCall != nil {
|
||||
id := "call_" + uuid.New().String()
|
||||
args, _ := json.Marshal(part.FunctionCall.Args)
|
||||
toolCalls = append(toolCalls, message.ToolCall{
|
||||
ID: id,
|
||||
Name: part.FunctionCall.Name,
|
||||
Input: string(args),
|
||||
Type: "function",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return toolCalls
|
||||
}
|
||||
|
||||
func (g *geminiClient) usage(resp *genai.GenerateContentResponse) TokenUsage {
|
||||
if resp == nil || resp.UsageMetadata == nil {
|
||||
return TokenUsage{}
|
||||
}
|
||||
|
||||
return TokenUsage{
|
||||
InputTokens: int64(resp.UsageMetadata.PromptTokenCount),
|
||||
OutputTokens: int64(resp.UsageMetadata.CandidatesTokenCount),
|
||||
CacheCreationTokens: 0, // Not directly provided by Gemini
|
||||
CacheReadTokens: int64(resp.UsageMetadata.CachedContentTokenCount),
|
||||
}
|
||||
}
|
||||
|
||||
func WithGeminiDisableCache() GeminiOption {
|
||||
return func(options *geminiOptions) {
|
||||
options.disableCache = true
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
func parseJsonToMap(jsonStr string) (map[string]interface{}, error) {
|
||||
var result map[string]interface{}
|
||||
err := json.Unmarshal([]byte(jsonStr), &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func convertSchemaProperties(parameters map[string]interface{}) map[string]*genai.Schema {
|
||||
properties := make(map[string]*genai.Schema)
|
||||
|
||||
for name, param := range parameters {
|
||||
properties[name] = convertToSchema(param)
|
||||
}
|
||||
|
||||
return properties
|
||||
}
|
||||
|
||||
func convertToSchema(param interface{}) *genai.Schema {
|
||||
schema := &genai.Schema{Type: genai.TypeString}
|
||||
|
||||
paramMap, ok := param.(map[string]interface{})
|
||||
if !ok {
|
||||
return schema
|
||||
}
|
||||
|
||||
if desc, ok := paramMap["description"].(string); ok {
|
||||
schema.Description = desc
|
||||
}
|
||||
|
||||
typeVal, hasType := paramMap["type"]
|
||||
if !hasType {
|
||||
return schema
|
||||
}
|
||||
|
||||
typeStr, ok := typeVal.(string)
|
||||
if !ok {
|
||||
return schema
|
||||
}
|
||||
|
||||
schema.Type = mapJSONTypeToGenAI(typeStr)
|
||||
|
||||
switch typeStr {
|
||||
case "array":
|
||||
schema.Items = processArrayItems(paramMap)
|
||||
case "object":
|
||||
if props, ok := paramMap["properties"].(map[string]interface{}); ok {
|
||||
schema.Properties = convertSchemaProperties(props)
|
||||
}
|
||||
}
|
||||
|
||||
return schema
|
||||
}
|
||||
|
||||
func processArrayItems(paramMap map[string]interface{}) *genai.Schema {
|
||||
items, ok := paramMap["items"].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
return convertToSchema(items)
|
||||
}
|
||||
|
||||
func mapJSONTypeToGenAI(jsonType string) genai.Type {
|
||||
switch jsonType {
|
||||
case "string":
|
||||
return genai.TypeString
|
||||
case "number":
|
||||
return genai.TypeNumber
|
||||
case "integer":
|
||||
return genai.TypeInteger
|
||||
case "boolean":
|
||||
return genai.TypeBoolean
|
||||
case "array":
|
||||
return genai.TypeArray
|
||||
case "object":
|
||||
return genai.TypeObject
|
||||
default:
|
||||
return genai.TypeString // Default to string for unknown types
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s string, substrs ...string) bool {
|
||||
for _, substr := range substrs {
|
||||
if strings.Contains(strings.ToLower(s), strings.ToLower(substr)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,434 +0,0 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/openai/openai-go"
|
||||
"github.com/openai/openai-go/option"
|
||||
"github.com/openai/openai-go/shared"
|
||||
"github.com/sst/opencode/internal/config"
|
||||
"github.com/sst/opencode/internal/llm/models"
|
||||
"github.com/sst/opencode/internal/llm/tools"
|
||||
"github.com/sst/opencode/internal/message"
|
||||
"github.com/sst/opencode/internal/status"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
type openaiOptions struct {
|
||||
baseURL string
|
||||
disableCache bool
|
||||
reasoningEffort string
|
||||
extraHeaders map[string]string
|
||||
}
|
||||
|
||||
type OpenAIOption func(*openaiOptions)
|
||||
|
||||
type openaiClient struct {
|
||||
providerOptions providerClientOptions
|
||||
options openaiOptions
|
||||
client openai.Client
|
||||
}
|
||||
|
||||
type OpenAIClient ProviderClient
|
||||
|
||||
func newOpenAIClient(opts providerClientOptions) OpenAIClient {
|
||||
openaiOpts := openaiOptions{
|
||||
reasoningEffort: "medium",
|
||||
}
|
||||
for _, o := range opts.openaiOptions {
|
||||
o(&openaiOpts)
|
||||
}
|
||||
|
||||
openaiClientOptions := []option.RequestOption{}
|
||||
if opts.apiKey != "" {
|
||||
openaiClientOptions = append(openaiClientOptions, option.WithAPIKey(opts.apiKey))
|
||||
}
|
||||
if openaiOpts.baseURL != "" {
|
||||
openaiClientOptions = append(openaiClientOptions, option.WithBaseURL(openaiOpts.baseURL))
|
||||
}
|
||||
|
||||
if openaiOpts.extraHeaders != nil {
|
||||
for key, value := range openaiOpts.extraHeaders {
|
||||
openaiClientOptions = append(openaiClientOptions, option.WithHeader(key, value))
|
||||
}
|
||||
}
|
||||
|
||||
client := openai.NewClient(openaiClientOptions...)
|
||||
return &openaiClient{
|
||||
providerOptions: opts,
|
||||
options: openaiOpts,
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessages []openai.ChatCompletionMessageParamUnion) {
|
||||
// Add system message first
|
||||
openaiMessages = append(openaiMessages, openai.SystemMessage(o.providerOptions.systemMessage))
|
||||
|
||||
for _, msg := range messages {
|
||||
switch msg.Role {
|
||||
case message.User:
|
||||
var content []openai.ChatCompletionContentPartUnionParam
|
||||
textBlock := openai.ChatCompletionContentPartTextParam{Text: msg.Content().String()}
|
||||
content = append(content, openai.ChatCompletionContentPartUnionParam{OfText: &textBlock})
|
||||
for _, binaryContent := range msg.BinaryContent() {
|
||||
imageURL := openai.ChatCompletionContentPartImageImageURLParam{URL: binaryContent.String(models.ProviderOpenAI)}
|
||||
imageBlock := openai.ChatCompletionContentPartImageParam{ImageURL: imageURL}
|
||||
|
||||
content = append(content, openai.ChatCompletionContentPartUnionParam{OfImageURL: &imageBlock})
|
||||
}
|
||||
|
||||
openaiMessages = append(openaiMessages, openai.UserMessage(content))
|
||||
|
||||
case message.Assistant:
|
||||
assistantMsg := openai.ChatCompletionAssistantMessageParam{
|
||||
Role: "assistant",
|
||||
}
|
||||
|
||||
if msg.Content().String() != "" {
|
||||
assistantMsg.Content = openai.ChatCompletionAssistantMessageParamContentUnion{
|
||||
OfString: openai.String(msg.Content().String()),
|
||||
}
|
||||
}
|
||||
|
||||
if len(msg.ToolCalls()) > 0 {
|
||||
assistantMsg.ToolCalls = make([]openai.ChatCompletionMessageToolCallParam, len(msg.ToolCalls()))
|
||||
for i, call := range msg.ToolCalls() {
|
||||
assistantMsg.ToolCalls[i] = openai.ChatCompletionMessageToolCallParam{
|
||||
ID: call.ID,
|
||||
Type: "function",
|
||||
Function: openai.ChatCompletionMessageToolCallFunctionParam{
|
||||
Name: call.Name,
|
||||
Arguments: call.Input,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
openaiMessages = append(openaiMessages, openai.ChatCompletionMessageParamUnion{
|
||||
OfAssistant: &assistantMsg,
|
||||
})
|
||||
|
||||
case message.Tool:
|
||||
for _, result := range msg.ToolResults() {
|
||||
openaiMessages = append(openaiMessages,
|
||||
openai.ToolMessage(result.Content, result.ToolCallID),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (o *openaiClient) convertTools(tools []tools.BaseTool) []openai.ChatCompletionToolParam {
|
||||
openaiTools := make([]openai.ChatCompletionToolParam, len(tools))
|
||||
|
||||
for i, tool := range tools {
|
||||
info := tool.Info()
|
||||
openaiTools[i] = openai.ChatCompletionToolParam{
|
||||
Function: openai.FunctionDefinitionParam{
|
||||
Name: info.Name,
|
||||
Description: openai.String(info.Description),
|
||||
Parameters: openai.FunctionParameters{
|
||||
"type": "object",
|
||||
"properties": info.Parameters,
|
||||
"required": info.Required,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return openaiTools
|
||||
}
|
||||
|
||||
func (o *openaiClient) finishReason(reason string) message.FinishReason {
|
||||
switch reason {
|
||||
case "stop":
|
||||
return message.FinishReasonEndTurn
|
||||
case "length":
|
||||
return message.FinishReasonMaxTokens
|
||||
case "tool_calls":
|
||||
return message.FinishReasonToolUse
|
||||
default:
|
||||
return message.FinishReasonUnknown
|
||||
}
|
||||
}
|
||||
|
||||
func (o *openaiClient) preparedParams(messages []openai.ChatCompletionMessageParamUnion, tools []openai.ChatCompletionToolParam) openai.ChatCompletionNewParams {
|
||||
params := openai.ChatCompletionNewParams{
|
||||
Model: openai.ChatModel(o.providerOptions.model.APIModel),
|
||||
Messages: messages,
|
||||
Tools: tools,
|
||||
}
|
||||
|
||||
if o.providerOptions.model.CanReason == true {
|
||||
params.MaxCompletionTokens = openai.Int(o.providerOptions.maxTokens)
|
||||
switch o.options.reasoningEffort {
|
||||
case "low":
|
||||
params.ReasoningEffort = shared.ReasoningEffortLow
|
||||
case "medium":
|
||||
params.ReasoningEffort = shared.ReasoningEffortMedium
|
||||
case "high":
|
||||
params.ReasoningEffort = shared.ReasoningEffortHigh
|
||||
default:
|
||||
params.ReasoningEffort = shared.ReasoningEffortMedium
|
||||
}
|
||||
} else {
|
||||
params.MaxTokens = openai.Int(o.providerOptions.maxTokens)
|
||||
}
|
||||
|
||||
if o.providerOptions.model.Provider == models.ProviderOpenRouter {
|
||||
params.WithExtraFields(map[string]any{
|
||||
"provider": map[string]any{
|
||||
"require_parameters": true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
func (o *openaiClient) send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (response *ProviderResponse, err error) {
|
||||
params := o.preparedParams(o.convertMessages(messages), o.convertTools(tools))
|
||||
cfg := config.Get()
|
||||
if cfg.Debug {
|
||||
jsonData, _ := json.Marshal(params)
|
||||
slog.Debug("Prepared messages", "messages", string(jsonData))
|
||||
}
|
||||
attempts := 0
|
||||
for {
|
||||
attempts++
|
||||
openaiResponse, err := o.client.Chat.Completions.New(
|
||||
ctx,
|
||||
params,
|
||||
)
|
||||
// If there is an error we are going to see if we can retry the call
|
||||
if err != nil {
|
||||
retry, after, retryErr := o.shouldRetry(attempts, err)
|
||||
if retryErr != nil {
|
||||
return nil, retryErr
|
||||
}
|
||||
if retry {
|
||||
status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries))
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(time.Duration(after) * time.Millisecond):
|
||||
continue
|
||||
}
|
||||
}
|
||||
return nil, retryErr
|
||||
}
|
||||
|
||||
content := ""
|
||||
if openaiResponse.Choices[0].Message.Content != "" {
|
||||
content = openaiResponse.Choices[0].Message.Content
|
||||
}
|
||||
|
||||
toolCalls := o.toolCalls(*openaiResponse)
|
||||
finishReason := o.finishReason(string(openaiResponse.Choices[0].FinishReason))
|
||||
|
||||
if len(toolCalls) > 0 {
|
||||
finishReason = message.FinishReasonToolUse
|
||||
}
|
||||
|
||||
return &ProviderResponse{
|
||||
Content: content,
|
||||
ToolCalls: toolCalls,
|
||||
Usage: o.usage(*openaiResponse),
|
||||
FinishReason: finishReason,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (o *openaiClient) stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
|
||||
params := o.preparedParams(o.convertMessages(messages), o.convertTools(tools))
|
||||
params.StreamOptions = openai.ChatCompletionStreamOptionsParam{
|
||||
IncludeUsage: openai.Bool(true),
|
||||
}
|
||||
|
||||
cfg := config.Get()
|
||||
if cfg.Debug {
|
||||
jsonData, _ := json.Marshal(params)
|
||||
slog.Debug("Prepared messages", "messages", string(jsonData))
|
||||
}
|
||||
|
||||
attempts := 0
|
||||
eventChan := make(chan ProviderEvent)
|
||||
|
||||
go func() {
|
||||
for {
|
||||
attempts++
|
||||
openaiStream := o.client.Chat.Completions.NewStreaming(
|
||||
ctx,
|
||||
params,
|
||||
)
|
||||
|
||||
acc := openai.ChatCompletionAccumulator{}
|
||||
currentContent := ""
|
||||
toolCalls := make([]message.ToolCall, 0)
|
||||
|
||||
for openaiStream.Next() {
|
||||
chunk := openaiStream.Current()
|
||||
acc.AddChunk(chunk)
|
||||
|
||||
for _, choice := range chunk.Choices {
|
||||
if choice.Delta.Content != "" {
|
||||
eventChan <- ProviderEvent{
|
||||
Type: EventContentDelta,
|
||||
Content: choice.Delta.Content,
|
||||
}
|
||||
currentContent += choice.Delta.Content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err := openaiStream.Err()
|
||||
if err == nil || errors.Is(err, io.EOF) {
|
||||
// Stream completed successfully
|
||||
finishReason := o.finishReason(string(acc.ChatCompletion.Choices[0].FinishReason))
|
||||
if len(acc.ChatCompletion.Choices[0].Message.ToolCalls) > 0 {
|
||||
toolCalls = append(toolCalls, o.toolCalls(acc.ChatCompletion)...)
|
||||
}
|
||||
if len(toolCalls) > 0 {
|
||||
finishReason = message.FinishReasonToolUse
|
||||
}
|
||||
|
||||
eventChan <- ProviderEvent{
|
||||
Type: EventComplete,
|
||||
Response: &ProviderResponse{
|
||||
Content: currentContent,
|
||||
ToolCalls: toolCalls,
|
||||
Usage: o.usage(acc.ChatCompletion),
|
||||
FinishReason: finishReason,
|
||||
},
|
||||
}
|
||||
close(eventChan)
|
||||
return
|
||||
}
|
||||
|
||||
// If there is an error we are going to see if we can retry the call
|
||||
retry, after, retryErr := o.shouldRetry(attempts, err)
|
||||
if retryErr != nil {
|
||||
eventChan <- ProviderEvent{Type: EventError, Error: retryErr}
|
||||
close(eventChan)
|
||||
return
|
||||
}
|
||||
if retry {
|
||||
status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries))
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// context cancelled
|
||||
if ctx.Err() == nil {
|
||||
eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
|
||||
}
|
||||
close(eventChan)
|
||||
return
|
||||
case <-time.After(time.Duration(after) * time.Millisecond):
|
||||
continue
|
||||
}
|
||||
}
|
||||
eventChan <- ProviderEvent{Type: EventError, Error: retryErr}
|
||||
close(eventChan)
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
return eventChan
|
||||
}
|
||||
|
||||
func (o *openaiClient) shouldRetry(attempts int, err error) (bool, int64, error) {
|
||||
var apierr *openai.Error
|
||||
if !errors.As(err, &apierr) {
|
||||
return false, 0, err
|
||||
}
|
||||
|
||||
if apierr.StatusCode != 429 && apierr.StatusCode != 500 {
|
||||
return false, 0, err
|
||||
}
|
||||
|
||||
if attempts > maxRetries {
|
||||
return false, 0, fmt.Errorf("maximum retry attempts reached for rate limit: %d retries", maxRetries)
|
||||
}
|
||||
|
||||
retryMs := 0
|
||||
retryAfterValues := apierr.Response.Header.Values("Retry-After")
|
||||
|
||||
backoffMs := 2000 * (1 << (attempts - 1))
|
||||
jitterMs := int(float64(backoffMs) * 0.2)
|
||||
retryMs = backoffMs + jitterMs
|
||||
if len(retryAfterValues) > 0 {
|
||||
if _, err := fmt.Sscanf(retryAfterValues[0], "%d", &retryMs); err == nil {
|
||||
retryMs = retryMs * 1000
|
||||
}
|
||||
}
|
||||
return true, int64(retryMs), nil
|
||||
}
|
||||
|
||||
func (o *openaiClient) toolCalls(completion openai.ChatCompletion) []message.ToolCall {
|
||||
var toolCalls []message.ToolCall
|
||||
|
||||
if len(completion.Choices) > 0 && len(completion.Choices[0].Message.ToolCalls) > 0 {
|
||||
for _, call := range completion.Choices[0].Message.ToolCalls {
|
||||
toolCall := message.ToolCall{
|
||||
ID: call.ID,
|
||||
Name: call.Function.Name,
|
||||
Input: call.Function.Arguments,
|
||||
Type: "function",
|
||||
Finished: true,
|
||||
}
|
||||
toolCalls = append(toolCalls, toolCall)
|
||||
}
|
||||
}
|
||||
|
||||
return toolCalls
|
||||
}
|
||||
|
||||
func (o *openaiClient) usage(completion openai.ChatCompletion) TokenUsage {
|
||||
cachedTokens := completion.Usage.PromptTokensDetails.CachedTokens
|
||||
inputTokens := completion.Usage.PromptTokens - cachedTokens
|
||||
|
||||
return TokenUsage{
|
||||
InputTokens: inputTokens,
|
||||
OutputTokens: completion.Usage.CompletionTokens,
|
||||
CacheCreationTokens: 0, // OpenAI doesn't provide this directly
|
||||
CacheReadTokens: cachedTokens,
|
||||
}
|
||||
}
|
||||
|
||||
func WithOpenAIBaseURL(baseURL string) OpenAIOption {
|
||||
return func(options *openaiOptions) {
|
||||
options.baseURL = baseURL
|
||||
}
|
||||
}
|
||||
|
||||
func WithOpenAIExtraHeaders(headers map[string]string) OpenAIOption {
|
||||
return func(options *openaiOptions) {
|
||||
options.extraHeaders = headers
|
||||
}
|
||||
}
|
||||
|
||||
func WithOpenAIDisableCache() OpenAIOption {
|
||||
return func(options *openaiOptions) {
|
||||
options.disableCache = true
|
||||
}
|
||||
}
|
||||
|
||||
func WithReasoningEffort(effort string) OpenAIOption {
|
||||
return func(options *openaiOptions) {
|
||||
defaultReasoningEffort := "medium"
|
||||
switch effort {
|
||||
case "low", "medium", "high":
|
||||
defaultReasoningEffort = effort
|
||||
default:
|
||||
slog.Warn("Invalid reasoning effort, using default: medium")
|
||||
}
|
||||
options.reasoningEffort = defaultReasoningEffort
|
||||
}
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/sst/opencode/internal/llm/models"
|
||||
"github.com/sst/opencode/internal/llm/tools"
|
||||
"github.com/sst/opencode/internal/message"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
type EventType string
|
||||
|
||||
const maxRetries = 8
|
||||
|
||||
const (
|
||||
EventContentStart EventType = "content_start"
|
||||
EventToolUseStart EventType = "tool_use_start"
|
||||
EventToolUseDelta EventType = "tool_use_delta"
|
||||
EventToolUseStop EventType = "tool_use_stop"
|
||||
EventContentDelta EventType = "content_delta"
|
||||
EventThinkingDelta EventType = "thinking_delta"
|
||||
EventContentStop EventType = "content_stop"
|
||||
EventComplete EventType = "complete"
|
||||
EventError EventType = "error"
|
||||
EventWarning EventType = "warning"
|
||||
)
|
||||
|
||||
type TokenUsage struct {
|
||||
InputTokens int64
|
||||
OutputTokens int64
|
||||
CacheCreationTokens int64
|
||||
CacheReadTokens int64
|
||||
}
|
||||
|
||||
type ProviderResponse struct {
|
||||
Content string
|
||||
ToolCalls []message.ToolCall
|
||||
Usage TokenUsage
|
||||
FinishReason message.FinishReason
|
||||
}
|
||||
|
||||
type ProviderEvent struct {
|
||||
Type EventType
|
||||
|
||||
Content string
|
||||
Thinking string
|
||||
Response *ProviderResponse
|
||||
ToolCall *message.ToolCall
|
||||
Error error
|
||||
}
|
||||
type Provider interface {
|
||||
SendMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error)
|
||||
|
||||
StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent
|
||||
|
||||
Model() models.Model
|
||||
|
||||
MaxTokens() int64
|
||||
}
|
||||
|
||||
type providerClientOptions struct {
|
||||
apiKey string
|
||||
model models.Model
|
||||
maxTokens int64
|
||||
systemMessage string
|
||||
|
||||
anthropicOptions []AnthropicOption
|
||||
openaiOptions []OpenAIOption
|
||||
geminiOptions []GeminiOption
|
||||
bedrockOptions []BedrockOption
|
||||
}
|
||||
|
||||
type ProviderClientOption func(*providerClientOptions)
|
||||
|
||||
type ProviderClient interface {
|
||||
send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error)
|
||||
stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent
|
||||
}
|
||||
|
||||
type baseProvider[C ProviderClient] struct {
|
||||
options providerClientOptions
|
||||
client C
|
||||
}
|
||||
|
||||
func NewProvider(providerName models.ModelProvider, opts ...ProviderClientOption) (Provider, error) {
|
||||
clientOptions := providerClientOptions{}
|
||||
for _, o := range opts {
|
||||
o(&clientOptions)
|
||||
}
|
||||
switch providerName {
|
||||
case models.ProviderAnthropic:
|
||||
return &baseProvider[AnthropicClient]{
|
||||
options: clientOptions,
|
||||
client: newAnthropicClient(clientOptions),
|
||||
}, nil
|
||||
case models.ProviderOpenAI:
|
||||
return &baseProvider[OpenAIClient]{
|
||||
options: clientOptions,
|
||||
client: newOpenAIClient(clientOptions),
|
||||
}, nil
|
||||
case models.ProviderGemini:
|
||||
return &baseProvider[GeminiClient]{
|
||||
options: clientOptions,
|
||||
client: newGeminiClient(clientOptions),
|
||||
}, nil
|
||||
case models.ProviderBedrock:
|
||||
return &baseProvider[BedrockClient]{
|
||||
options: clientOptions,
|
||||
client: newBedrockClient(clientOptions),
|
||||
}, nil
|
||||
case models.ProviderGROQ:
|
||||
clientOptions.openaiOptions = append(clientOptions.openaiOptions,
|
||||
WithOpenAIBaseURL("https://api.groq.com/openai/v1"),
|
||||
)
|
||||
return &baseProvider[OpenAIClient]{
|
||||
options: clientOptions,
|
||||
client: newOpenAIClient(clientOptions),
|
||||
}, nil
|
||||
case models.ProviderAzure:
|
||||
return &baseProvider[AzureClient]{
|
||||
options: clientOptions,
|
||||
client: newAzureClient(clientOptions),
|
||||
}, nil
|
||||
case models.ProviderOpenRouter:
|
||||
clientOptions.openaiOptions = append(clientOptions.openaiOptions,
|
||||
WithOpenAIBaseURL("https://openrouter.ai/api/v1"),
|
||||
WithOpenAIExtraHeaders(map[string]string{
|
||||
"HTTP-Referer": "opencode.ai",
|
||||
"X-Title": "OpenCode",
|
||||
}),
|
||||
)
|
||||
return &baseProvider[OpenAIClient]{
|
||||
options: clientOptions,
|
||||
client: newOpenAIClient(clientOptions),
|
||||
}, nil
|
||||
case models.ProviderXAI:
|
||||
clientOptions.openaiOptions = append(clientOptions.openaiOptions,
|
||||
WithOpenAIBaseURL("https://api.x.ai/v1"),
|
||||
)
|
||||
return &baseProvider[OpenAIClient]{
|
||||
options: clientOptions,
|
||||
client: newOpenAIClient(clientOptions),
|
||||
}, nil
|
||||
|
||||
case models.ProviderMock:
|
||||
// TODO: implement mock client for test
|
||||
panic("not implemented")
|
||||
}
|
||||
return nil, fmt.Errorf("provider not supported: %s", providerName)
|
||||
}
|
||||
|
||||
func (p *baseProvider[C]) cleanMessages(messages []message.Message) (cleaned []message.Message) {
|
||||
for _, msg := range messages {
|
||||
// The message has no content
|
||||
if len(msg.Parts) == 0 {
|
||||
continue
|
||||
}
|
||||
cleaned = append(cleaned, msg)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (p *baseProvider[C]) SendMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) {
|
||||
messages = p.cleanMessages(messages)
|
||||
response, err := p.client.send(ctx, messages, tools)
|
||||
if err == nil && response != nil {
|
||||
slog.Debug("API request token usage",
|
||||
"model", p.options.model.Name,
|
||||
"input_tokens", response.Usage.InputTokens,
|
||||
"output_tokens", response.Usage.OutputTokens,
|
||||
"cache_creation_tokens", response.Usage.CacheCreationTokens,
|
||||
"cache_read_tokens", response.Usage.CacheReadTokens,
|
||||
"total_tokens", response.Usage.InputTokens+response.Usage.OutputTokens)
|
||||
}
|
||||
return response, err
|
||||
}
|
||||
|
||||
func (p *baseProvider[C]) Model() models.Model {
|
||||
return p.options.model
|
||||
}
|
||||
|
||||
func (p *baseProvider[C]) MaxTokens() int64 {
|
||||
return p.options.maxTokens
|
||||
}
|
||||
|
||||
func (p *baseProvider[C]) StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
|
||||
messages = p.cleanMessages(messages)
|
||||
eventChan := p.client.stream(ctx, messages, tools)
|
||||
|
||||
// Create a new channel to intercept events
|
||||
wrappedChan := make(chan ProviderEvent)
|
||||
|
||||
go func() {
|
||||
defer close(wrappedChan)
|
||||
|
||||
for event := range eventChan {
|
||||
// Pass the event through
|
||||
wrappedChan <- event
|
||||
|
||||
// Log token usage when we get the complete event
|
||||
if event.Type == EventComplete && event.Response != nil {
|
||||
slog.Debug("API streaming request token usage",
|
||||
"model", p.options.model.Name,
|
||||
"input_tokens", event.Response.Usage.InputTokens,
|
||||
"output_tokens", event.Response.Usage.OutputTokens,
|
||||
"cache_creation_tokens", event.Response.Usage.CacheCreationTokens,
|
||||
"cache_read_tokens", event.Response.Usage.CacheReadTokens,
|
||||
"total_tokens", event.Response.Usage.InputTokens+event.Response.Usage.OutputTokens)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return wrappedChan
|
||||
}
|
||||
|
||||
func WithAPIKey(apiKey string) ProviderClientOption {
|
||||
return func(options *providerClientOptions) {
|
||||
options.apiKey = apiKey
|
||||
}
|
||||
}
|
||||
|
||||
func WithModel(model models.Model) ProviderClientOption {
|
||||
return func(options *providerClientOptions) {
|
||||
options.model = model
|
||||
}
|
||||
}
|
||||
|
||||
func WithMaxTokens(maxTokens int64) ProviderClientOption {
|
||||
return func(options *providerClientOptions) {
|
||||
options.maxTokens = maxTokens
|
||||
}
|
||||
}
|
||||
|
||||
func WithSystemMessage(systemMessage string) ProviderClientOption {
|
||||
return func(options *providerClientOptions) {
|
||||
options.systemMessage = systemMessage
|
||||
}
|
||||
}
|
||||
|
||||
func WithAnthropicOptions(anthropicOptions ...AnthropicOption) ProviderClientOption {
|
||||
return func(options *providerClientOptions) {
|
||||
options.anthropicOptions = anthropicOptions
|
||||
}
|
||||
}
|
||||
|
||||
func WithOpenAIOptions(openaiOptions ...OpenAIOption) ProviderClientOption {
|
||||
return func(options *providerClientOptions) {
|
||||
options.openaiOptions = openaiOptions
|
||||
}
|
||||
}
|
||||
|
||||
func WithGeminiOptions(geminiOptions ...GeminiOption) ProviderClientOption {
|
||||
return func(options *providerClientOptions) {
|
||||
options.geminiOptions = geminiOptions
|
||||
}
|
||||
}
|
||||
|
||||
func WithBedrockOptions(bedrockOptions ...BedrockOption) ProviderClientOption {
|
||||
return func(options *providerClientOptions) {
|
||||
options.bedrockOptions = bedrockOptions
|
||||
}
|
||||
}
|
||||
@@ -1,348 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sst/opencode/internal/config"
|
||||
"github.com/sst/opencode/internal/llm/tools/shell"
|
||||
"github.com/sst/opencode/internal/permission"
|
||||
)
|
||||
|
||||
type BashParams struct {
|
||||
Command string `json:"command"`
|
||||
Timeout int `json:"timeout"`
|
||||
}
|
||||
|
||||
type BashPermissionsParams struct {
|
||||
Command string `json:"command"`
|
||||
Timeout int `json:"timeout"`
|
||||
}
|
||||
|
||||
type BashResponseMetadata struct {
|
||||
StartTime int64 `json:"start_time"`
|
||||
EndTime int64 `json:"end_time"`
|
||||
}
|
||||
type bashTool struct {
|
||||
permissions permission.Service
|
||||
}
|
||||
|
||||
const (
|
||||
BashToolName = "bash"
|
||||
|
||||
DefaultTimeout = 1 * 60 * 1000 // 1 minutes in milliseconds
|
||||
MaxTimeout = 10 * 60 * 1000 // 10 minutes in milliseconds
|
||||
MaxOutputLength = 30000
|
||||
)
|
||||
|
||||
var bannedCommands = []string{
|
||||
"alias", "curl", "curlie", "wget", "axel", "aria2c",
|
||||
"nc", "telnet", "lynx", "w3m", "links", "httpie", "xh",
|
||||
"http-prompt", "chrome", "firefox", "safari",
|
||||
}
|
||||
|
||||
var safeReadOnlyCommands = []string{
|
||||
"ls", "echo", "pwd", "date", "cal", "uptime", "whoami", "id", "groups", "env", "printenv", "set", "unset", "which", "type", "whereis",
|
||||
"whatis", "uname", "hostname", "df", "du", "free", "top", "ps", "kill", "killall", "nice", "nohup", "time", "timeout",
|
||||
|
||||
"git status", "git log", "git diff", "git show", "git branch", "git tag", "git remote", "git ls-files", "git ls-remote",
|
||||
"git rev-parse", "git config --get", "git config --list", "git describe", "git blame", "git grep", "git shortlog",
|
||||
|
||||
"go version", "go help", "go list", "go env", "go doc", "go vet", "go fmt", "go mod", "go test", "go build", "go run", "go install", "go clean",
|
||||
}
|
||||
|
||||
func bashDescription() string {
|
||||
bannedCommandsStr := strings.Join(bannedCommands, ", ")
|
||||
return fmt.Sprintf(`Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.
|
||||
|
||||
Before executing the command, please follow these steps:
|
||||
|
||||
1. Directory Verification:
|
||||
- If the command will create new directories or files, first use the LS tool to verify the parent directory exists and is the correct location
|
||||
- For example, before running "mkdir foo/bar", first use LS to check that "foo" exists and is the intended parent directory
|
||||
|
||||
2. Security Check:
|
||||
- For security and to limit the threat of a prompt injection attack, some commands are limited or banned. If you use a disallowed command, you will receive an error message explaining the restriction. Explain the error to the User.
|
||||
- Verify that the command is not one of the banned commands: %s.
|
||||
|
||||
3. Command Execution:
|
||||
- After ensuring proper quoting, execute the command.
|
||||
- Capture the output of the command.
|
||||
|
||||
4. Output Processing:
|
||||
- If the output exceeds %d characters, output will be truncated before being returned to you.
|
||||
- Prepare the output for display to the user.
|
||||
|
||||
5. Return Result:
|
||||
- Provide the processed output of the command.
|
||||
- If any errors occurred during execution, include those in the output.
|
||||
|
||||
Usage notes:
|
||||
- The command argument is required.
|
||||
- You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 30 minutes.
|
||||
- VERY IMPORTANT: You MUST avoid using search commands like 'find' and 'grep'. Instead use Grep, Glob, or Agent tools to search. You MUST avoid read tools like 'cat', 'head', 'tail', and 'ls', and use FileRead and LS tools to read files.
|
||||
- When issuing multiple commands, use the ';' or '&&' operator to separate them. DO NOT use newlines (newlines are ok in quoted strings).
|
||||
- IMPORTANT: All commands share the same shell session. Shell state (environment variables, virtual environments, current directory, etc.) persist between commands. For example, if you set an environment variable as part of a command, the environment variable will persist for subsequent commands.
|
||||
- Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of 'cd'. You may use 'cd' if the User explicitly requests it.
|
||||
<good-example>
|
||||
pytest /foo/bar/tests
|
||||
</good-example>
|
||||
<bad-example>
|
||||
cd /foo/bar && pytest tests
|
||||
</bad-example>
|
||||
|
||||
# Committing changes with git
|
||||
|
||||
When the user asks you to create a new git commit, follow these steps carefully:
|
||||
|
||||
1. Start with a single message that contains exactly three tool_use blocks that do the following (it is VERY IMPORTANT that you send these tool_use blocks in a single message, otherwise it will feel slow to the user!):
|
||||
- Run a git status command to see all untracked files.
|
||||
- Run a git diff command to see both staged and unstaged changes that will be committed.
|
||||
- Run a git log command to see recent commit messages, so that you can follow this repository's commit message style.
|
||||
|
||||
2. Use the git context at the start of this conversation to determine which files are relevant to your commit. Add relevant untracked files to the staging area. Do not commit files that were already modified at the start of this conversation, if they are not relevant to your commit.
|
||||
|
||||
3. Analyze all staged changes (both previously staged and newly added) and draft a commit message. Wrap your analysis process in <commit_analysis> tags:
|
||||
|
||||
<commit_analysis>
|
||||
- List the files that have been changed or added
|
||||
- Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.)
|
||||
- Brainstorm the purpose or motivation behind these changes
|
||||
- Do not use tools to explore code, beyond what is available in the git context
|
||||
- Assess the impact of these changes on the overall project
|
||||
- Check for any sensitive information that shouldn't be committed
|
||||
- Draft a concise (1-2 sentences) commit message that focuses on the "why" rather than the "what"
|
||||
- Ensure your language is clear, concise, and to the point
|
||||
- Ensure the message accurately reflects the changes and their purpose (i.e. "add" means a wholly new feature, "update" means an enhancement to an existing feature, "fix" means a bug fix, etc.)
|
||||
- Ensure the message is not generic (avoid words like "Update" or "Fix" without context)
|
||||
- Review the draft message to ensure it accurately reflects the changes and their purpose
|
||||
</commit_analysis>
|
||||
|
||||
4. Create the commit with a message ending with:
|
||||
🤖 Generated with opencode
|
||||
Co-Authored-By: opencode <noreply@opencode.ai>
|
||||
|
||||
- In order to ensure good formatting, ALWAYS pass the commit message via a HEREDOC, a la this example:
|
||||
<example>
|
||||
git commit -m "$(cat <<'EOF'
|
||||
Commit message here.
|
||||
|
||||
🤖 Generated with opencode
|
||||
Co-Authored-By: opencode <noreply@opencode.ai>
|
||||
EOF
|
||||
)"
|
||||
</example>
|
||||
|
||||
5. If the commit fails due to pre-commit hook changes, retry the commit ONCE to include these automated changes. If it fails again, it usually means a pre-commit hook is preventing the commit. If the commit succeeds but you notice that files were modified by the pre-commit hook, you MUST amend your commit to include them.
|
||||
|
||||
6. Finally, run git status to make sure the commit succeeded.
|
||||
|
||||
Important notes:
|
||||
- When possible, combine the "git add" and "git commit" commands into a single "git commit -am" command, to speed things up
|
||||
- However, be careful not to stage files (e.g. with 'git add .') for commits that aren't part of the change, they may have untracked files they want to keep around, but not commit.
|
||||
- NEVER update the git config
|
||||
- DO NOT push to the remote repository
|
||||
- IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported.
|
||||
- If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit
|
||||
- Ensure your commit message is meaningful and concise. It should explain the purpose of the changes, not just describe them.
|
||||
- Return an empty response - the user will see the git output directly
|
||||
|
||||
# Creating pull requests
|
||||
Use the gh command via the Bash tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a Github URL use the gh command to get the information needed.
|
||||
|
||||
IMPORTANT: When the user asks you to create a pull request, follow these steps carefully:
|
||||
|
||||
1. Understand the current state of the branch. Remember to send a single message that contains multiple tool_use blocks (it is VERY IMPORTANT that you do this in a single message, otherwise it will feel slow to the user!):
|
||||
- Run a git status command to see all untracked files.
|
||||
- Run a git diff command to see both staged and unstaged changes that will be committed.
|
||||
- Check if the current branch tracks a remote branch and is up to date with the remote, so you know if you need to push to the remote
|
||||
- Run a git log command and 'git diff main...HEAD' to understand the full commit history for the current branch (from the time it diverged from the 'main' branch.)
|
||||
|
||||
2. Create new branch if needed
|
||||
|
||||
3. Commit changes if needed
|
||||
|
||||
4. Push to remote with -u flag if needed
|
||||
|
||||
5. Analyze all changes that will be included in the pull request, making sure to look at all relevant commits (not just the latest commit, but all commits that will be included in the pull request!), and draft a pull request summary. Wrap your analysis process in <pr_analysis> tags:
|
||||
|
||||
<pr_analysis>
|
||||
- List the commits since diverging from the main branch
|
||||
- Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.)
|
||||
- Brainstorm the purpose or motivation behind these changes
|
||||
- Assess the impact of these changes on the overall project
|
||||
- Do not use tools to explore code, beyond what is available in the git context
|
||||
- Check for any sensitive information that shouldn't be committed
|
||||
- Draft a concise (1-2 bullet points) pull request summary that focuses on the "why" rather than the "what"
|
||||
- Ensure the summary accurately reflects all changes since diverging from the main branch
|
||||
- Ensure your language is clear, concise, and to the point
|
||||
- Ensure the summary accurately reflects the changes and their purpose (ie. "add" means a wholly new feature, "update" means an enhancement to an existing feature, "fix" means a bug fix, etc.)
|
||||
- Ensure the summary is not generic (avoid words like "Update" or "Fix" without context)
|
||||
- Review the draft summary to ensure it accurately reflects the changes and their purpose
|
||||
</pr_analysis>
|
||||
|
||||
6. Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting.
|
||||
<example>
|
||||
gh pr create --title "the pr title" --body "$(cat <<'EOF'
|
||||
## Summary
|
||||
<1-3 bullet points>
|
||||
|
||||
## Test plan
|
||||
[Checklist of TODOs for testing the pull request...]
|
||||
|
||||
🤖 Generated with opencode
|
||||
EOF
|
||||
)"
|
||||
</example>
|
||||
|
||||
Important:
|
||||
- Return an empty response - the user will see the gh output directly
|
||||
- Never update git config`, bannedCommandsStr, MaxOutputLength)
|
||||
}
|
||||
|
||||
func NewBashTool(permission permission.Service) BaseTool {
|
||||
return &bashTool{
|
||||
permissions: permission,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *bashTool) Info() ToolInfo {
|
||||
return ToolInfo{
|
||||
Name: BashToolName,
|
||||
Description: bashDescription(),
|
||||
Parameters: map[string]any{
|
||||
"command": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The command to execute",
|
||||
},
|
||||
"timeout": map[string]any{
|
||||
"type": "number",
|
||||
"description": "Optional timeout in milliseconds (max 600000)",
|
||||
},
|
||||
},
|
||||
Required: []string{"command"},
|
||||
}
|
||||
}
|
||||
|
||||
func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
|
||||
var params BashParams
|
||||
if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil {
|
||||
return NewTextErrorResponse("invalid parameters"), nil
|
||||
}
|
||||
|
||||
if params.Timeout > MaxTimeout {
|
||||
params.Timeout = MaxTimeout
|
||||
} else if params.Timeout <= 0 {
|
||||
params.Timeout = DefaultTimeout
|
||||
}
|
||||
|
||||
if params.Command == "" {
|
||||
return NewTextErrorResponse("missing command"), nil
|
||||
}
|
||||
|
||||
baseCmd := strings.Fields(params.Command)[0]
|
||||
for _, banned := range bannedCommands {
|
||||
if strings.EqualFold(baseCmd, banned) {
|
||||
return NewTextErrorResponse(fmt.Sprintf("command '%s' is not allowed", baseCmd)), nil
|
||||
}
|
||||
}
|
||||
|
||||
isSafeReadOnly := false
|
||||
cmdLower := strings.ToLower(params.Command)
|
||||
|
||||
for _, safe := range safeReadOnlyCommands {
|
||||
if strings.HasPrefix(cmdLower, strings.ToLower(safe)) {
|
||||
if len(cmdLower) == len(safe) || cmdLower[len(safe)] == ' ' || cmdLower[len(safe)] == '-' {
|
||||
isSafeReadOnly = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sessionID, messageID := GetContextValues(ctx)
|
||||
if sessionID == "" || messageID == "" {
|
||||
return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
|
||||
}
|
||||
if !isSafeReadOnly {
|
||||
p := b.permissions.Request(
|
||||
ctx,
|
||||
permission.CreatePermissionRequest{
|
||||
SessionID: sessionID,
|
||||
Path: config.WorkingDirectory(),
|
||||
ToolName: BashToolName,
|
||||
Action: "execute",
|
||||
Description: fmt.Sprintf("Execute command: %s", params.Command),
|
||||
Params: BashPermissionsParams{
|
||||
Command: params.Command,
|
||||
},
|
||||
},
|
||||
)
|
||||
if !p {
|
||||
return ToolResponse{}, permission.ErrorPermissionDenied
|
||||
}
|
||||
}
|
||||
startTime := time.Now()
|
||||
shell := shell.GetPersistentShell(config.WorkingDirectory())
|
||||
stdout, stderr, exitCode, interrupted, err := shell.Exec(ctx, params.Command, params.Timeout)
|
||||
if err != nil {
|
||||
return ToolResponse{}, fmt.Errorf("error executing command: %w", err)
|
||||
}
|
||||
|
||||
stdout = truncateOutput(stdout)
|
||||
stderr = truncateOutput(stderr)
|
||||
|
||||
errorMessage := stderr
|
||||
if interrupted {
|
||||
if errorMessage != "" {
|
||||
errorMessage += "\n"
|
||||
}
|
||||
errorMessage += "Command was aborted before completion"
|
||||
} else if exitCode != 0 {
|
||||
if errorMessage != "" {
|
||||
errorMessage += "\n"
|
||||
}
|
||||
errorMessage += fmt.Sprintf("Exit code %d", exitCode)
|
||||
}
|
||||
|
||||
hasBothOutputs := stdout != "" && stderr != ""
|
||||
|
||||
if hasBothOutputs {
|
||||
stdout += "\n"
|
||||
}
|
||||
|
||||
if errorMessage != "" {
|
||||
stdout += "\n" + errorMessage
|
||||
}
|
||||
|
||||
metadata := BashResponseMetadata{
|
||||
StartTime: startTime.UnixMilli(),
|
||||
EndTime: time.Now().UnixMilli(),
|
||||
}
|
||||
if stdout == "" {
|
||||
return WithResponseMetadata(NewTextResponse("no output"), metadata), nil
|
||||
}
|
||||
return WithResponseMetadata(NewTextResponse(stdout), metadata), nil
|
||||
}
|
||||
|
||||
func truncateOutput(content string) string {
|
||||
if len(content) <= MaxOutputLength {
|
||||
return content
|
||||
}
|
||||
|
||||
halfLength := MaxOutputLength / 2
|
||||
start := content[:halfLength]
|
||||
end := content[len(content)-halfLength:]
|
||||
|
||||
truncatedLinesCount := countLines(content[halfLength : len(content)-halfLength])
|
||||
return fmt.Sprintf("%s\n\n... [%d lines truncated] ...\n\n%s", start, truncatedLinesCount, end)
|
||||
}
|
||||
|
||||
func countLines(s string) int {
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
return len(strings.Split(s, "\n"))
|
||||
}
|
||||
@@ -1,492 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sst/opencode/internal/config"
|
||||
"github.com/sst/opencode/internal/diff"
|
||||
"github.com/sst/opencode/internal/history"
|
||||
"github.com/sst/opencode/internal/lsp"
|
||||
"github.com/sst/opencode/internal/permission"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
type EditParams struct {
|
||||
FilePath string `json:"file_path"`
|
||||
OldString string `json:"old_string"`
|
||||
NewString string `json:"new_string"`
|
||||
}
|
||||
|
||||
type EditPermissionsParams struct {
|
||||
FilePath string `json:"file_path"`
|
||||
Diff string `json:"diff"`
|
||||
}
|
||||
|
||||
type EditResponseMetadata struct {
|
||||
Diff string `json:"diff"`
|
||||
Additions int `json:"additions"`
|
||||
Removals int `json:"removals"`
|
||||
}
|
||||
|
||||
type editTool struct {
|
||||
lspClients map[string]*lsp.Client
|
||||
permissions permission.Service
|
||||
history history.Service
|
||||
}
|
||||
|
||||
const (
|
||||
EditToolName = "edit"
|
||||
editDescription = `Edits files by replacing text, creating new files, or deleting content. For moving or renaming files, use the Bash tool with the 'mv' command instead. For larger file edits, use the FileWrite tool to overwrite files.
|
||||
|
||||
Before using this tool:
|
||||
|
||||
1. Use the FileRead tool to understand the file's contents and context
|
||||
|
||||
2. Verify the directory path is correct (only applicable when creating new files):
|
||||
- Use the LS tool to verify the parent directory exists and is the correct location
|
||||
|
||||
To make a file edit, provide the following:
|
||||
1. file_path: The absolute path to the file to modify (must be absolute, not relative)
|
||||
2. old_string: The text to replace (must be unique within the file, and must match the file contents exactly, including all whitespace and indentation)
|
||||
3. new_string: The edited text to replace the old_string
|
||||
|
||||
Special cases:
|
||||
- To create a new file: provide file_path and new_string, leave old_string empty
|
||||
- To delete content: provide file_path and old_string, leave new_string empty
|
||||
|
||||
The tool will replace ONE occurrence of old_string with new_string in the specified file.
|
||||
|
||||
CRITICAL REQUIREMENTS FOR USING THIS TOOL:
|
||||
|
||||
1. UNIQUENESS: The old_string MUST uniquely identify the specific instance you want to change. This means:
|
||||
- Include AT LEAST 3-5 lines of context BEFORE the change point
|
||||
- Include AT LEAST 3-5 lines of context AFTER the change point
|
||||
- Include all whitespace, indentation, and surrounding code exactly as it appears in the file
|
||||
|
||||
2. SINGLE INSTANCE: This tool can only change ONE instance at a time. If you need to change multiple instances:
|
||||
- Make separate calls to this tool for each instance
|
||||
- Each call must uniquely identify its specific instance using extensive context
|
||||
|
||||
3. VERIFICATION: Before using this tool:
|
||||
- Check how many instances of the target text exist in the file
|
||||
- If multiple instances exist, gather enough context to uniquely identify each one
|
||||
- Plan separate tool calls for each instance
|
||||
|
||||
WARNING: If you do not follow these requirements:
|
||||
- The tool will fail if old_string matches multiple locations
|
||||
- The tool will fail if old_string doesn't match exactly (including whitespace)
|
||||
- You may change the wrong instance if you don't include enough context
|
||||
|
||||
When making edits:
|
||||
- Ensure the edit results in idiomatic, correct code
|
||||
- Do not leave the code in a broken state
|
||||
- Always use absolute file paths (starting with /)
|
||||
|
||||
Remember: when making multiple file edits in a row to the same file, you should prefer to send all edits in a single message with multiple calls to this tool, rather than multiple messages with a single call each.`
|
||||
)
|
||||
|
||||
func NewEditTool(lspClients map[string]*lsp.Client, permissions permission.Service, files history.Service) BaseTool {
|
||||
return &editTool{
|
||||
lspClients: lspClients,
|
||||
permissions: permissions,
|
||||
history: files,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *editTool) Info() ToolInfo {
|
||||
return ToolInfo{
|
||||
Name: EditToolName,
|
||||
Description: editDescription,
|
||||
Parameters: map[string]any{
|
||||
"file_path": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The absolute path to the file to modify",
|
||||
},
|
||||
"old_string": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The text to replace",
|
||||
},
|
||||
"new_string": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The text to replace it with",
|
||||
},
|
||||
},
|
||||
Required: []string{"file_path", "old_string", "new_string"},
|
||||
}
|
||||
}
|
||||
|
||||
func (e *editTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
|
||||
var params EditParams
|
||||
if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil {
|
||||
return NewTextErrorResponse("invalid parameters"), nil
|
||||
}
|
||||
|
||||
if params.FilePath == "" {
|
||||
return NewTextErrorResponse("file_path is required"), nil
|
||||
}
|
||||
|
||||
if !filepath.IsAbs(params.FilePath) {
|
||||
wd := config.WorkingDirectory()
|
||||
params.FilePath = filepath.Join(wd, params.FilePath)
|
||||
}
|
||||
|
||||
var response ToolResponse
|
||||
var err error
|
||||
|
||||
if params.OldString == "" {
|
||||
response, err = e.createNewFile(ctx, params.FilePath, params.NewString)
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
}
|
||||
|
||||
if params.NewString == "" {
|
||||
response, err = e.deleteContent(ctx, params.FilePath, params.OldString)
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
}
|
||||
|
||||
response, err = e.replaceContent(ctx, params.FilePath, params.OldString, params.NewString)
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
if response.IsError {
|
||||
// Return early if there was an error during content replacement
|
||||
// This prevents unnecessary LSP diagnostics processing
|
||||
return response, nil
|
||||
}
|
||||
|
||||
waitForLspDiagnostics(ctx, params.FilePath, e.lspClients)
|
||||
text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
|
||||
text += getDiagnostics(params.FilePath, e.lspClients)
|
||||
response.Content = text
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (e *editTool) createNewFile(ctx context.Context, filePath, content string) (ToolResponse, error) {
|
||||
fileInfo, err := os.Stat(filePath)
|
||||
if err == nil {
|
||||
if fileInfo.IsDir() {
|
||||
return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
|
||||
}
|
||||
return NewTextErrorResponse(fmt.Sprintf("file already exists: %s", filePath)), nil
|
||||
} else if !os.IsNotExist(err) {
|
||||
return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
|
||||
}
|
||||
|
||||
dir := filepath.Dir(filePath)
|
||||
if err = os.MkdirAll(dir, 0o755); err != nil {
|
||||
return ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
|
||||
}
|
||||
|
||||
sessionID, messageID := GetContextValues(ctx)
|
||||
if sessionID == "" || messageID == "" {
|
||||
return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
|
||||
}
|
||||
|
||||
diff, additions, removals := diff.GenerateDiff(
|
||||
"",
|
||||
content,
|
||||
filePath,
|
||||
)
|
||||
rootDir := config.WorkingDirectory()
|
||||
permissionPath := filepath.Dir(filePath)
|
||||
if strings.HasPrefix(filePath, rootDir) {
|
||||
permissionPath = rootDir
|
||||
}
|
||||
p := e.permissions.Request(
|
||||
ctx,
|
||||
permission.CreatePermissionRequest{
|
||||
SessionID: sessionID,
|
||||
Path: permissionPath,
|
||||
ToolName: EditToolName,
|
||||
Action: "write",
|
||||
Description: fmt.Sprintf("Create file %s", filePath),
|
||||
Params: EditPermissionsParams{
|
||||
FilePath: filePath,
|
||||
Diff: diff,
|
||||
},
|
||||
},
|
||||
)
|
||||
if !p {
|
||||
return ToolResponse{}, permission.ErrorPermissionDenied
|
||||
}
|
||||
|
||||
err = os.WriteFile(filePath, []byte(content), 0o644)
|
||||
if err != nil {
|
||||
return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
|
||||
}
|
||||
|
||||
// File can't be in the history so we create a new file history
|
||||
_, err = e.history.Create(ctx, sessionID, filePath, "")
|
||||
if err != nil {
|
||||
// Log error but don't fail the operation
|
||||
return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
|
||||
}
|
||||
|
||||
// Add the new content to the file history
|
||||
_, err = e.history.CreateVersion(ctx, sessionID, filePath, content)
|
||||
if err != nil {
|
||||
// Log error but don't fail the operation
|
||||
slog.Debug("Error creating file history version", "error", err)
|
||||
}
|
||||
|
||||
recordFileWrite(filePath)
|
||||
recordFileRead(filePath)
|
||||
|
||||
return WithResponseMetadata(
|
||||
NewTextResponse("File created: "+filePath),
|
||||
EditResponseMetadata{
|
||||
Diff: diff,
|
||||
Additions: additions,
|
||||
Removals: removals,
|
||||
},
|
||||
), nil
|
||||
}
|
||||
|
||||
func (e *editTool) deleteContent(ctx context.Context, filePath, oldString string) (ToolResponse, error) {
|
||||
fileInfo, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
|
||||
}
|
||||
return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
|
||||
}
|
||||
|
||||
if fileInfo.IsDir() {
|
||||
return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
|
||||
}
|
||||
|
||||
if getLastReadTime(filePath).IsZero() {
|
||||
return NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
|
||||
}
|
||||
|
||||
modTime := fileInfo.ModTime()
|
||||
lastRead := getLastReadTime(filePath)
|
||||
if modTime.After(lastRead) {
|
||||
return NewTextErrorResponse(
|
||||
fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
|
||||
filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
|
||||
)), nil
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
|
||||
}
|
||||
|
||||
oldContent := string(content)
|
||||
|
||||
index := strings.Index(oldContent, oldString)
|
||||
if index == -1 {
|
||||
return NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
|
||||
}
|
||||
|
||||
lastIndex := strings.LastIndex(oldContent, oldString)
|
||||
if index != lastIndex {
|
||||
return NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match"), nil
|
||||
}
|
||||
|
||||
newContent := oldContent[:index] + oldContent[index+len(oldString):]
|
||||
|
||||
sessionID, messageID := GetContextValues(ctx)
|
||||
|
||||
if sessionID == "" || messageID == "" {
|
||||
return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
|
||||
}
|
||||
|
||||
diff, additions, removals := diff.GenerateDiff(
|
||||
oldContent,
|
||||
newContent,
|
||||
filePath,
|
||||
)
|
||||
|
||||
rootDir := config.WorkingDirectory()
|
||||
permissionPath := filepath.Dir(filePath)
|
||||
if strings.HasPrefix(filePath, rootDir) {
|
||||
permissionPath = rootDir
|
||||
}
|
||||
p := e.permissions.Request(
|
||||
ctx,
|
||||
permission.CreatePermissionRequest{
|
||||
SessionID: sessionID,
|
||||
Path: permissionPath,
|
||||
ToolName: EditToolName,
|
||||
Action: "write",
|
||||
Description: fmt.Sprintf("Delete content from file %s", filePath),
|
||||
Params: EditPermissionsParams{
|
||||
FilePath: filePath,
|
||||
Diff: diff,
|
||||
},
|
||||
},
|
||||
)
|
||||
if !p {
|
||||
return ToolResponse{}, permission.ErrorPermissionDenied
|
||||
}
|
||||
|
||||
err = os.WriteFile(filePath, []byte(newContent), 0o644)
|
||||
if err != nil {
|
||||
return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
|
||||
}
|
||||
|
||||
// Check if file exists in history
|
||||
file, err := e.history.GetLatestByPathAndSession(ctx, filePath, sessionID)
|
||||
if err != nil {
|
||||
_, err = e.history.Create(ctx, sessionID, filePath, oldContent)
|
||||
if err != nil {
|
||||
// Log error but don't fail the operation
|
||||
return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
|
||||
}
|
||||
}
|
||||
if file.Content != oldContent {
|
||||
// User Manually changed the content store an intermediate version
|
||||
_, err = e.history.CreateVersion(ctx, sessionID, filePath, oldContent)
|
||||
if err != nil {
|
||||
slog.Debug("Error creating file history version", "error", err)
|
||||
}
|
||||
}
|
||||
// Store the new version
|
||||
_, err = e.history.CreateVersion(ctx, sessionID, filePath, "")
|
||||
if err != nil {
|
||||
slog.Debug("Error creating file history version", "error", err)
|
||||
}
|
||||
|
||||
recordFileWrite(filePath)
|
||||
recordFileRead(filePath)
|
||||
|
||||
return WithResponseMetadata(
|
||||
NewTextResponse("Content deleted from file: "+filePath),
|
||||
EditResponseMetadata{
|
||||
Diff: diff,
|
||||
Additions: additions,
|
||||
Removals: removals,
|
||||
},
|
||||
), nil
|
||||
}
|
||||
|
||||
func (e *editTool) replaceContent(ctx context.Context, filePath, oldString, newString string) (ToolResponse, error) {
|
||||
fileInfo, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
|
||||
}
|
||||
return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
|
||||
}
|
||||
|
||||
if fileInfo.IsDir() {
|
||||
return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
|
||||
}
|
||||
|
||||
if getLastReadTime(filePath).IsZero() {
|
||||
return NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
|
||||
}
|
||||
|
||||
modTime := fileInfo.ModTime()
|
||||
lastRead := getLastReadTime(filePath)
|
||||
if modTime.After(lastRead) {
|
||||
return NewTextErrorResponse(
|
||||
fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
|
||||
filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
|
||||
)), nil
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
|
||||
}
|
||||
|
||||
oldContent := string(content)
|
||||
|
||||
index := strings.Index(oldContent, oldString)
|
||||
if index == -1 {
|
||||
return NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
|
||||
}
|
||||
|
||||
lastIndex := strings.LastIndex(oldContent, oldString)
|
||||
if index != lastIndex {
|
||||
return NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match"), nil
|
||||
}
|
||||
|
||||
newContent := oldContent[:index] + newString + oldContent[index+len(oldString):]
|
||||
|
||||
if oldContent == newContent {
|
||||
return NewTextErrorResponse("new content is the same as old content. No changes made."), nil
|
||||
}
|
||||
sessionID, messageID := GetContextValues(ctx)
|
||||
|
||||
if sessionID == "" || messageID == "" {
|
||||
return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
|
||||
}
|
||||
diff, additions, removals := diff.GenerateDiff(
|
||||
oldContent,
|
||||
newContent,
|
||||
filePath,
|
||||
)
|
||||
rootDir := config.WorkingDirectory()
|
||||
permissionPath := filepath.Dir(filePath)
|
||||
if strings.HasPrefix(filePath, rootDir) {
|
||||
permissionPath = rootDir
|
||||
}
|
||||
p := e.permissions.Request(
|
||||
ctx,
|
||||
permission.CreatePermissionRequest{
|
||||
SessionID: sessionID,
|
||||
Path: permissionPath,
|
||||
ToolName: EditToolName,
|
||||
Action: "write",
|
||||
Description: fmt.Sprintf("Replace content in file %s", filePath),
|
||||
Params: EditPermissionsParams{
|
||||
FilePath: filePath,
|
||||
Diff: diff,
|
||||
},
|
||||
},
|
||||
)
|
||||
if !p {
|
||||
return ToolResponse{}, permission.ErrorPermissionDenied
|
||||
}
|
||||
|
||||
err = os.WriteFile(filePath, []byte(newContent), 0o644)
|
||||
if err != nil {
|
||||
return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
|
||||
}
|
||||
|
||||
// Check if file exists in history
|
||||
file, err := e.history.GetLatestByPathAndSession(ctx, filePath, sessionID)
|
||||
if err != nil {
|
||||
_, err = e.history.Create(ctx, sessionID, filePath, oldContent)
|
||||
if err != nil {
|
||||
// Log error but don't fail the operation
|
||||
return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
|
||||
}
|
||||
}
|
||||
if file.Content != oldContent {
|
||||
// User Manually changed the content store an intermediate version
|
||||
_, err = e.history.CreateVersion(ctx, sessionID, filePath, oldContent)
|
||||
if err != nil {
|
||||
slog.Debug("Error creating file history version", "error", err)
|
||||
}
|
||||
}
|
||||
// Store the new version
|
||||
_, err = e.history.CreateVersion(ctx, sessionID, filePath, newContent)
|
||||
if err != nil {
|
||||
slog.Debug("Error creating file history version", "error", err)
|
||||
}
|
||||
|
||||
recordFileWrite(filePath)
|
||||
recordFileRead(filePath)
|
||||
|
||||
return WithResponseMetadata(
|
||||
NewTextResponse("Content replaced in file: "+filePath),
|
||||
EditResponseMetadata{
|
||||
Diff: diff,
|
||||
Additions: additions,
|
||||
Removals: removals,
|
||||
}), nil
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
md "github.com/JohannesKaufmann/html-to-markdown"
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/sst/opencode/internal/config"
|
||||
"github.com/sst/opencode/internal/permission"
|
||||
)
|
||||
|
||||
type FetchParams struct {
|
||||
URL string `json:"url"`
|
||||
Format string `json:"format"`
|
||||
Timeout int `json:"timeout,omitempty"`
|
||||
}
|
||||
|
||||
type FetchPermissionsParams struct {
|
||||
URL string `json:"url"`
|
||||
Format string `json:"format"`
|
||||
Timeout int `json:"timeout,omitempty"`
|
||||
}
|
||||
|
||||
type fetchTool struct {
|
||||
client *http.Client
|
||||
permissions permission.Service
|
||||
}
|
||||
|
||||
const (
|
||||
FetchToolName = "fetch"
|
||||
fetchToolDescription = `Fetches content from a URL and returns it in the specified format.
|
||||
|
||||
WHEN TO USE THIS TOOL:
|
||||
- Use when you need to download content from a URL
|
||||
- Helpful for retrieving documentation, API responses, or web content
|
||||
- Useful for getting external information to assist with tasks
|
||||
|
||||
HOW TO USE:
|
||||
- Provide the URL to fetch content from
|
||||
- Specify the desired output format (text, markdown, or html)
|
||||
- Optionally set a timeout for the request
|
||||
|
||||
FEATURES:
|
||||
- Supports three output formats: text, markdown, and html
|
||||
- Automatically handles HTTP redirects
|
||||
- Sets reasonable timeouts to prevent hanging
|
||||
- Validates input parameters before making requests
|
||||
|
||||
LIMITATIONS:
|
||||
- Maximum response size is 5MB
|
||||
- Only supports HTTP and HTTPS protocols
|
||||
- Cannot handle authentication or cookies
|
||||
- Some websites may block automated requests
|
||||
|
||||
TIPS:
|
||||
- Use text format for plain text content or simple API responses
|
||||
- Use markdown format for content that should be rendered with formatting
|
||||
- Use html format when you need the raw HTML structure
|
||||
- Set appropriate timeouts for potentially slow websites`
|
||||
)
|
||||
|
||||
func NewFetchTool(permissions permission.Service) BaseTool {
|
||||
return &fetchTool{
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
permissions: permissions,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *fetchTool) Info() ToolInfo {
|
||||
return ToolInfo{
|
||||
Name: FetchToolName,
|
||||
Description: fetchToolDescription,
|
||||
Parameters: map[string]any{
|
||||
"url": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The URL to fetch content from",
|
||||
},
|
||||
"format": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The format to return the content in (text, markdown, or html)",
|
||||
"enum": []string{"text", "markdown", "html"},
|
||||
},
|
||||
"timeout": map[string]any{
|
||||
"type": "number",
|
||||
"description": "Optional timeout in seconds (max 120)",
|
||||
},
|
||||
},
|
||||
Required: []string{"url", "format"},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *fetchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
|
||||
var params FetchParams
|
||||
if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil {
|
||||
return NewTextErrorResponse("Failed to parse fetch parameters: " + err.Error()), nil
|
||||
}
|
||||
|
||||
if params.URL == "" {
|
||||
return NewTextErrorResponse("URL parameter is required"), nil
|
||||
}
|
||||
|
||||
format := strings.ToLower(params.Format)
|
||||
if format != "text" && format != "markdown" && format != "html" {
|
||||
return NewTextErrorResponse("Format must be one of: text, markdown, html"), nil
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(params.URL, "http://") && !strings.HasPrefix(params.URL, "https://") {
|
||||
return NewTextErrorResponse("URL must start with http:// or https://"), nil
|
||||
}
|
||||
|
||||
sessionID, messageID := GetContextValues(ctx)
|
||||
if sessionID == "" || messageID == "" {
|
||||
return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a new file")
|
||||
}
|
||||
|
||||
p := t.permissions.Request(
|
||||
ctx,
|
||||
permission.CreatePermissionRequest{
|
||||
SessionID: sessionID,
|
||||
Path: config.WorkingDirectory(),
|
||||
ToolName: FetchToolName,
|
||||
Action: "fetch",
|
||||
Description: fmt.Sprintf("Fetch content from URL: %s", params.URL),
|
||||
Params: FetchPermissionsParams(params),
|
||||
},
|
||||
)
|
||||
|
||||
if !p {
|
||||
return ToolResponse{}, permission.ErrorPermissionDenied
|
||||
}
|
||||
|
||||
client := t.client
|
||||
if params.Timeout > 0 {
|
||||
maxTimeout := 120 // 2 minutes
|
||||
if params.Timeout > maxTimeout {
|
||||
params.Timeout = maxTimeout
|
||||
}
|
||||
client = &http.Client{
|
||||
Timeout: time.Duration(params.Timeout) * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", params.URL, nil)
|
||||
if err != nil {
|
||||
return ToolResponse{}, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "opencode/1.0")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return ToolResponse{}, fmt.Errorf("failed to fetch URL: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return NewTextErrorResponse(fmt.Sprintf("Request failed with status code: %d", resp.StatusCode)), nil
|
||||
}
|
||||
|
||||
maxSize := int64(5 * 1024 * 1024) // 5MB
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxSize))
|
||||
if err != nil {
|
||||
return NewTextErrorResponse("Failed to read response body: " + err.Error()), nil
|
||||
}
|
||||
|
||||
content := string(body)
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
|
||||
switch format {
|
||||
case "text":
|
||||
if strings.Contains(contentType, "text/html") {
|
||||
text, err := extractTextFromHTML(content)
|
||||
if err != nil {
|
||||
return NewTextErrorResponse("Failed to extract text from HTML: " + err.Error()), nil
|
||||
}
|
||||
return NewTextResponse(text), nil
|
||||
}
|
||||
return NewTextResponse(content), nil
|
||||
|
||||
case "markdown":
|
||||
if strings.Contains(contentType, "text/html") {
|
||||
markdown, err := convertHTMLToMarkdown(content)
|
||||
if err != nil {
|
||||
return NewTextErrorResponse("Failed to convert HTML to Markdown: " + err.Error()), nil
|
||||
}
|
||||
return NewTextResponse(markdown), nil
|
||||
}
|
||||
|
||||
return NewTextResponse("```\n" + content + "\n```"), nil
|
||||
|
||||
case "html":
|
||||
return NewTextResponse(content), nil
|
||||
|
||||
default:
|
||||
return NewTextResponse(content), nil
|
||||
}
|
||||
}
|
||||
|
||||
func extractTextFromHTML(html string) (string, error) {
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(html))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
text := doc.Text()
|
||||
text = strings.Join(strings.Fields(text), " ")
|
||||
|
||||
return text, nil
|
||||
}
|
||||
|
||||
func convertHTMLToMarkdown(html string) (string, error) {
|
||||
converter := md.NewConverter("", true, nil)
|
||||
|
||||
markdown, err := converter.ConvertString(html)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return markdown, nil
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// File record to track when files were read/written
|
||||
type fileRecord struct {
|
||||
path string
|
||||
readTime time.Time
|
||||
writeTime time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
fileRecords = make(map[string]fileRecord)
|
||||
fileRecordMutex sync.RWMutex
|
||||
)
|
||||
|
||||
func recordFileRead(path string) {
|
||||
fileRecordMutex.Lock()
|
||||
defer fileRecordMutex.Unlock()
|
||||
|
||||
record, exists := fileRecords[path]
|
||||
if !exists {
|
||||
record = fileRecord{path: path}
|
||||
}
|
||||
record.readTime = time.Now()
|
||||
fileRecords[path] = record
|
||||
}
|
||||
|
||||
func getLastReadTime(path string) time.Time {
|
||||
fileRecordMutex.RLock()
|
||||
defer fileRecordMutex.RUnlock()
|
||||
|
||||
record, exists := fileRecords[path]
|
||||
if !exists {
|
||||
return time.Time{}
|
||||
}
|
||||
return record.readTime
|
||||
}
|
||||
|
||||
func recordFileWrite(path string) {
|
||||
fileRecordMutex.Lock()
|
||||
defer fileRecordMutex.Unlock()
|
||||
|
||||
record, exists := fileRecords[path]
|
||||
if !exists {
|
||||
record = fileRecord{path: path}
|
||||
}
|
||||
record.writeTime = time.Now()
|
||||
fileRecords[path] = record
|
||||
}
|
||||
@@ -1,298 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/bmatcuk/doublestar/v4"
|
||||
"github.com/sst/opencode/internal/config"
|
||||
)
|
||||
|
||||
const (
|
||||
GlobToolName = "glob"
|
||||
globDescription = `Fast file pattern matching tool that finds files by name and pattern, returning matching paths sorted by modification time (newest first).
|
||||
|
||||
WHEN TO USE THIS TOOL:
|
||||
- Use when you need to find files by name patterns or extensions
|
||||
- Great for finding specific file types across a directory structure
|
||||
- Useful for discovering files that match certain naming conventions
|
||||
|
||||
HOW TO USE:
|
||||
- Provide a glob pattern to match against file paths
|
||||
- Optionally specify a starting directory (defaults to current working directory)
|
||||
- Results are sorted with most recently modified files first
|
||||
|
||||
GLOB PATTERN SYNTAX:
|
||||
- '*' matches any sequence of non-separator characters
|
||||
- '**' matches any sequence of characters, including separators
|
||||
- '?' matches any single non-separator character
|
||||
- '[...]' matches any character in the brackets
|
||||
- '[!...]' matches any character not in the brackets
|
||||
|
||||
COMMON PATTERN EXAMPLES:
|
||||
- '*.js' - Find all JavaScript files in the current directory
|
||||
- '**/*.js' - Find all JavaScript files in any subdirectory
|
||||
- 'src/**/*.{ts,tsx}' - Find all TypeScript files in the src directory
|
||||
- '*.{html,css,js}' - Find all HTML, CSS, and JS files
|
||||
|
||||
LIMITATIONS:
|
||||
- Results are limited to 100 files (newest first)
|
||||
- Does not search file contents (use Grep tool for that)
|
||||
- Hidden files (starting with '.') are skipped
|
||||
|
||||
TIPS:
|
||||
- For the most useful results, combine with the Grep tool: first find files with Glob, then search their contents with Grep
|
||||
- When doing iterative exploration that may require multiple rounds of searching, consider using the Agent tool instead
|
||||
- Always check if results are truncated and refine your search pattern if needed`
|
||||
)
|
||||
|
||||
type fileInfo struct {
|
||||
path string
|
||||
modTime time.Time
|
||||
}
|
||||
|
||||
type GlobParams struct {
|
||||
Pattern string `json:"pattern"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type GlobResponseMetadata struct {
|
||||
NumberOfFiles int `json:"number_of_files"`
|
||||
Truncated bool `json:"truncated"`
|
||||
}
|
||||
|
||||
type globTool struct{}
|
||||
|
||||
func NewGlobTool() BaseTool {
|
||||
return &globTool{}
|
||||
}
|
||||
|
||||
func (g *globTool) Info() ToolInfo {
|
||||
return ToolInfo{
|
||||
Name: GlobToolName,
|
||||
Description: globDescription,
|
||||
Parameters: map[string]any{
|
||||
"pattern": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The glob pattern to match files against",
|
||||
},
|
||||
"path": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The directory to search in. Defaults to the current working directory.",
|
||||
},
|
||||
},
|
||||
Required: []string{"pattern"},
|
||||
}
|
||||
}
|
||||
|
||||
func (g *globTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
|
||||
var params GlobParams
|
||||
if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil {
|
||||
return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
|
||||
}
|
||||
|
||||
if params.Pattern == "" {
|
||||
return NewTextErrorResponse("pattern is required"), nil
|
||||
}
|
||||
|
||||
searchPath := params.Path
|
||||
if searchPath == "" {
|
||||
searchPath = config.WorkingDirectory()
|
||||
}
|
||||
|
||||
files, truncated, err := globFiles(params.Pattern, searchPath, 100)
|
||||
if err != nil {
|
||||
return ToolResponse{}, fmt.Errorf("error finding files: %w", err)
|
||||
}
|
||||
|
||||
var output string
|
||||
if len(files) == 0 {
|
||||
output = "No files found"
|
||||
} else {
|
||||
output = strings.Join(files, "\n")
|
||||
if truncated {
|
||||
output += "\n\n(Results are truncated. Consider using a more specific path or pattern.)"
|
||||
}
|
||||
}
|
||||
|
||||
return WithResponseMetadata(
|
||||
NewTextResponse(output),
|
||||
GlobResponseMetadata{
|
||||
NumberOfFiles: len(files),
|
||||
Truncated: truncated,
|
||||
},
|
||||
), nil
|
||||
}
|
||||
|
||||
func globFiles(pattern, searchPath string, limit int) ([]string, bool, error) {
|
||||
matches, err := globWithRipgrep(pattern, searchPath, limit)
|
||||
if err == nil {
|
||||
return matches, len(matches) >= limit, nil
|
||||
}
|
||||
|
||||
return globWithDoublestar(pattern, searchPath, limit)
|
||||
}
|
||||
|
||||
func globWithRipgrep(
|
||||
pattern, searchRoot string,
|
||||
limit int,
|
||||
) ([]string, error) {
|
||||
if searchRoot == "" {
|
||||
searchRoot = "."
|
||||
}
|
||||
|
||||
rgBin, err := exec.LookPath("rg")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ripgrep not found in $PATH: %w", err)
|
||||
}
|
||||
|
||||
if !filepath.IsAbs(pattern) && !strings.HasPrefix(pattern, "/") {
|
||||
pattern = "/" + pattern
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"--files",
|
||||
"--null",
|
||||
"--glob", pattern,
|
||||
"-L",
|
||||
}
|
||||
|
||||
cmd := exec.Command(rgBin, args...)
|
||||
cmd.Dir = searchRoot
|
||||
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("ripgrep: %w\n%s", err, out)
|
||||
}
|
||||
|
||||
var matches []string
|
||||
for _, p := range bytes.Split(out, []byte{0}) {
|
||||
if len(p) == 0 {
|
||||
continue
|
||||
}
|
||||
abs := filepath.Join(searchRoot, string(p))
|
||||
if skipHidden(abs) {
|
||||
continue
|
||||
}
|
||||
matches = append(matches, abs)
|
||||
}
|
||||
|
||||
sort.SliceStable(matches, func(i, j int) bool {
|
||||
return len(matches[i]) < len(matches[j])
|
||||
})
|
||||
|
||||
if len(matches) > limit {
|
||||
matches = matches[:limit]
|
||||
}
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
func globWithDoublestar(pattern, searchPath string, limit int) ([]string, bool, error) {
|
||||
fsys := os.DirFS(searchPath)
|
||||
|
||||
relPattern := strings.TrimPrefix(pattern, "/")
|
||||
|
||||
var matches []fileInfo
|
||||
|
||||
err := doublestar.GlobWalk(fsys, relPattern, func(path string, d fs.DirEntry) error {
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if skipHidden(path) {
|
||||
return nil
|
||||
}
|
||||
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return nil // Skip files we can't access
|
||||
}
|
||||
|
||||
absPath := path // Restore absolute path
|
||||
if !strings.HasPrefix(absPath, searchPath) {
|
||||
absPath = filepath.Join(searchPath, absPath)
|
||||
}
|
||||
|
||||
matches = append(matches, fileInfo{
|
||||
path: absPath,
|
||||
modTime: info.ModTime(),
|
||||
})
|
||||
|
||||
if len(matches) >= limit*2 { // Collect more than needed for sorting
|
||||
return fs.SkipAll
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("glob walk error: %w", err)
|
||||
}
|
||||
|
||||
sort.Slice(matches, func(i, j int) bool {
|
||||
return matches[i].modTime.After(matches[j].modTime)
|
||||
})
|
||||
|
||||
truncated := len(matches) > limit
|
||||
if truncated {
|
||||
matches = matches[:limit]
|
||||
}
|
||||
|
||||
results := make([]string, len(matches))
|
||||
for i, m := range matches {
|
||||
results[i] = m.path
|
||||
}
|
||||
|
||||
return results, truncated, nil
|
||||
}
|
||||
|
||||
func skipHidden(path string) bool {
|
||||
// Check for hidden files (starting with a dot)
|
||||
base := filepath.Base(path)
|
||||
if base != "." && strings.HasPrefix(base, ".") {
|
||||
return true
|
||||
}
|
||||
|
||||
// List of commonly ignored directories in development projects
|
||||
commonIgnoredDirs := map[string]bool{
|
||||
"node_modules": true,
|
||||
"vendor": true,
|
||||
"dist": true,
|
||||
"build": true,
|
||||
"target": true,
|
||||
".git": true,
|
||||
".idea": true,
|
||||
".vscode": true,
|
||||
"__pycache__": true,
|
||||
"bin": true,
|
||||
"obj": true,
|
||||
"out": true,
|
||||
"coverage": true,
|
||||
"tmp": true,
|
||||
"temp": true,
|
||||
"logs": true,
|
||||
"generated": true,
|
||||
"bower_components": true,
|
||||
"jspm_packages": true,
|
||||
}
|
||||
|
||||
// Check if any path component is in our ignore list
|
||||
parts := strings.SplitSeq(path, string(os.PathSeparator))
|
||||
for part := range parts {
|
||||
if commonIgnoredDirs[part] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -1,358 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sst/opencode/internal/config"
|
||||
)
|
||||
|
||||
type GrepParams struct {
|
||||
Pattern string `json:"pattern"`
|
||||
Path string `json:"path"`
|
||||
Include string `json:"include"`
|
||||
LiteralText bool `json:"literal_text"`
|
||||
}
|
||||
|
||||
type grepMatch struct {
|
||||
path string
|
||||
modTime time.Time
|
||||
lineNum int
|
||||
lineText string
|
||||
}
|
||||
|
||||
type GrepResponseMetadata struct {
|
||||
NumberOfMatches int `json:"number_of_matches"`
|
||||
Truncated bool `json:"truncated"`
|
||||
}
|
||||
|
||||
type grepTool struct{}
|
||||
|
||||
const (
|
||||
GrepToolName = "grep"
|
||||
grepDescription = `Fast content search tool that finds files containing specific text or patterns, returning matching file paths sorted by modification time (newest first).
|
||||
|
||||
WHEN TO USE THIS TOOL:
|
||||
- Use when you need to find files containing specific text or patterns
|
||||
- Great for searching code bases for function names, variable declarations, or error messages
|
||||
- Useful for finding all files that use a particular API or pattern
|
||||
|
||||
HOW TO USE:
|
||||
- Provide a regex pattern to search for within file contents
|
||||
- Set literal_text=true if you want to search for the exact text with special characters (recommended for non-regex users)
|
||||
- Optionally specify a starting directory (defaults to current working directory)
|
||||
- Optionally provide an include pattern to filter which files to search
|
||||
- Results are sorted with most recently modified files first
|
||||
|
||||
REGEX PATTERN SYNTAX (when literal_text=false):
|
||||
- Supports standard regular expression syntax
|
||||
- 'function' searches for the literal text "function"
|
||||
- 'log\..*Error' finds text starting with "log." and ending with "Error"
|
||||
- 'import\s+.*\s+from' finds import statements in JavaScript/TypeScript
|
||||
|
||||
COMMON INCLUDE PATTERN EXAMPLES:
|
||||
- '*.js' - Only search JavaScript files
|
||||
- '*.{ts,tsx}' - Only search TypeScript files
|
||||
- '*.go' - Only search Go files
|
||||
|
||||
LIMITATIONS:
|
||||
- Results are limited to 100 files (newest first)
|
||||
- Performance depends on the number of files being searched
|
||||
- Very large binary files may be skipped
|
||||
- Hidden files (starting with '.') are skipped
|
||||
|
||||
TIPS:
|
||||
- For faster, more targeted searches, first use Glob to find relevant files, then use Grep
|
||||
- When doing iterative exploration that may require multiple rounds of searching, consider using the Agent tool instead
|
||||
- Always check if results are truncated and refine your search pattern if needed
|
||||
- Use literal_text=true when searching for exact text containing special characters like dots, parentheses, etc.`
|
||||
)
|
||||
|
||||
func NewGrepTool() BaseTool {
|
||||
return &grepTool{}
|
||||
}
|
||||
|
||||
func (g *grepTool) Info() ToolInfo {
|
||||
return ToolInfo{
|
||||
Name: GrepToolName,
|
||||
Description: grepDescription,
|
||||
Parameters: map[string]any{
|
||||
"pattern": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The regex pattern to search for in file contents",
|
||||
},
|
||||
"path": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The directory to search in. Defaults to the current working directory.",
|
||||
},
|
||||
"include": map[string]any{
|
||||
"type": "string",
|
||||
"description": "File pattern to include in the search (e.g. \"*.js\", \"*.{ts,tsx}\")",
|
||||
},
|
||||
"literal_text": map[string]any{
|
||||
"type": "boolean",
|
||||
"description": "If true, the pattern will be treated as literal text with special regex characters escaped. Default is false.",
|
||||
},
|
||||
},
|
||||
Required: []string{"pattern"},
|
||||
}
|
||||
}
|
||||
|
||||
// escapeRegexPattern escapes special regex characters so they're treated as literal characters
|
||||
func escapeRegexPattern(pattern string) string {
|
||||
specialChars := []string{"\\", ".", "+", "*", "?", "(", ")", "[", "]", "{", "}", "^", "$", "|"}
|
||||
escaped := pattern
|
||||
|
||||
for _, char := range specialChars {
|
||||
escaped = strings.ReplaceAll(escaped, char, "\\"+char)
|
||||
}
|
||||
|
||||
return escaped
|
||||
}
|
||||
|
||||
func (g *grepTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
|
||||
var params GrepParams
|
||||
if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil {
|
||||
return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
|
||||
}
|
||||
|
||||
if params.Pattern == "" {
|
||||
return NewTextErrorResponse("pattern is required"), nil
|
||||
}
|
||||
|
||||
// If literal_text is true, escape the pattern
|
||||
searchPattern := params.Pattern
|
||||
if params.LiteralText {
|
||||
searchPattern = escapeRegexPattern(params.Pattern)
|
||||
}
|
||||
|
||||
searchPath := params.Path
|
||||
if searchPath == "" {
|
||||
searchPath = config.WorkingDirectory()
|
||||
}
|
||||
|
||||
matches, truncated, err := searchFiles(searchPattern, searchPath, params.Include, 100)
|
||||
if err != nil {
|
||||
return ToolResponse{}, fmt.Errorf("error searching files: %w", err)
|
||||
}
|
||||
|
||||
var output string
|
||||
if len(matches) == 0 {
|
||||
output = "No files found"
|
||||
} else {
|
||||
output = fmt.Sprintf("Found %d matches\n", len(matches))
|
||||
|
||||
currentFile := ""
|
||||
for _, match := range matches {
|
||||
if currentFile != match.path {
|
||||
if currentFile != "" {
|
||||
output += "\n"
|
||||
}
|
||||
currentFile = match.path
|
||||
output += fmt.Sprintf("%s:\n", match.path)
|
||||
}
|
||||
if match.lineNum > 0 {
|
||||
output += fmt.Sprintf(" Line %d: %s\n", match.lineNum, match.lineText)
|
||||
} else {
|
||||
output += fmt.Sprintf(" %s\n", match.path)
|
||||
}
|
||||
}
|
||||
|
||||
if truncated {
|
||||
output += "\n(Results are truncated. Consider using a more specific path or pattern.)"
|
||||
}
|
||||
}
|
||||
|
||||
return WithResponseMetadata(
|
||||
NewTextResponse(output),
|
||||
GrepResponseMetadata{
|
||||
NumberOfMatches: len(matches),
|
||||
Truncated: truncated,
|
||||
},
|
||||
), nil
|
||||
}
|
||||
|
||||
func searchFiles(pattern, rootPath, include string, limit int) ([]grepMatch, bool, error) {
|
||||
matches, err := searchWithRipgrep(pattern, rootPath, include)
|
||||
if err != nil {
|
||||
matches, err = searchFilesWithRegex(pattern, rootPath, include)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(matches, func(i, j int) bool {
|
||||
return matches[i].modTime.After(matches[j].modTime)
|
||||
})
|
||||
|
||||
truncated := len(matches) > limit
|
||||
if truncated {
|
||||
matches = matches[:limit]
|
||||
}
|
||||
|
||||
return matches, truncated, nil
|
||||
}
|
||||
|
||||
func searchWithRipgrep(pattern, path, include string) ([]grepMatch, error) {
|
||||
_, err := exec.LookPath("rg")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ripgrep not found: %w", err)
|
||||
}
|
||||
|
||||
// Use -n to show line numbers and include the matched line
|
||||
args := []string{"-n", pattern}
|
||||
if include != "" {
|
||||
args = append(args, "--glob", include)
|
||||
}
|
||||
args = append(args, path)
|
||||
|
||||
cmd := exec.Command("rg", args...)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
|
||||
return []grepMatch{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
matches := make([]grepMatch, 0, len(lines))
|
||||
|
||||
for _, line := range lines {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse ripgrep output format: file:line:content
|
||||
parts := strings.SplitN(line, ":", 3)
|
||||
if len(parts) < 3 {
|
||||
continue
|
||||
}
|
||||
|
||||
filePath := parts[0]
|
||||
lineNum, err := strconv.Atoi(parts[1])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
lineText := parts[2]
|
||||
|
||||
fileInfo, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
continue // Skip files we can't access
|
||||
}
|
||||
|
||||
matches = append(matches, grepMatch{
|
||||
path: filePath,
|
||||
modTime: fileInfo.ModTime(),
|
||||
lineNum: lineNum,
|
||||
lineText: lineText,
|
||||
})
|
||||
}
|
||||
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error) {
|
||||
matches := []grepMatch{}
|
||||
|
||||
regex, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid regex pattern: %w", err)
|
||||
}
|
||||
|
||||
var includePattern *regexp.Regexp
|
||||
if include != "" {
|
||||
regexPattern := globToRegex(include)
|
||||
includePattern, err = regexp.Compile(regexPattern)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid include pattern: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
err = filepath.Walk(rootPath, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil // Skip errors
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
return nil // Skip directories
|
||||
}
|
||||
|
||||
if skipHidden(path) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if includePattern != nil && !includePattern.MatchString(path) {
|
||||
return nil
|
||||
}
|
||||
|
||||
match, lineNum, lineText, err := fileContainsPattern(path, regex)
|
||||
if err != nil {
|
||||
return nil // Skip files we can't read
|
||||
}
|
||||
|
||||
if match {
|
||||
matches = append(matches, grepMatch{
|
||||
path: path,
|
||||
modTime: info.ModTime(),
|
||||
lineNum: lineNum,
|
||||
lineText: lineText,
|
||||
})
|
||||
|
||||
if len(matches) >= 200 {
|
||||
return filepath.SkipAll
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, string, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return false, 0, "", err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
lineNum := 0
|
||||
for scanner.Scan() {
|
||||
lineNum++
|
||||
line := scanner.Text()
|
||||
if pattern.MatchString(line) {
|
||||
return true, lineNum, line, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, 0, "", scanner.Err()
|
||||
}
|
||||
|
||||
func globToRegex(glob string) string {
|
||||
regexPattern := strings.ReplaceAll(glob, ".", "\\.")
|
||||
regexPattern = strings.ReplaceAll(regexPattern, "*", ".*")
|
||||
regexPattern = strings.ReplaceAll(regexPattern, "?", ".")
|
||||
|
||||
re := regexp.MustCompile(`\{([^}]+)\}`)
|
||||
regexPattern = re.ReplaceAllStringFunc(regexPattern, func(match string) string {
|
||||
inner := match[1 : len(match)-1]
|
||||
return "(" + strings.ReplaceAll(inner, ",", "|") + ")"
|
||||
})
|
||||
|
||||
return regexPattern
|
||||
}
|
||||
@@ -1,316 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/sst/opencode/internal/config"
|
||||
)
|
||||
|
||||
type LSParams struct {
|
||||
Path string `json:"path"`
|
||||
Ignore []string `json:"ignore"`
|
||||
}
|
||||
|
||||
type TreeNode struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Type string `json:"type"` // "file" or "directory"
|
||||
Children []*TreeNode `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
type LSResponseMetadata struct {
|
||||
NumberOfFiles int `json:"number_of_files"`
|
||||
Truncated bool `json:"truncated"`
|
||||
}
|
||||
|
||||
type lsTool struct{}
|
||||
|
||||
const (
|
||||
LSToolName = "ls"
|
||||
MaxLSFiles = 1000
|
||||
lsDescription = `Directory listing tool that shows files and subdirectories in a tree structure, helping you explore and understand the project organization.
|
||||
|
||||
WHEN TO USE THIS TOOL:
|
||||
- Use when you need to explore the structure of a directory
|
||||
- Helpful for understanding the organization of a project
|
||||
- Good first step when getting familiar with a new codebase
|
||||
|
||||
HOW TO USE:
|
||||
- Provide a path to list (defaults to current working directory)
|
||||
- Optionally specify glob patterns to ignore
|
||||
- Results are displayed in a tree structure
|
||||
|
||||
FEATURES:
|
||||
- Displays a hierarchical view of files and directories
|
||||
- Automatically skips hidden files/directories (starting with '.')
|
||||
- Skips common system directories like __pycache__
|
||||
- Can filter out files matching specific patterns
|
||||
|
||||
LIMITATIONS:
|
||||
- Results are limited to 1000 files
|
||||
- Very large directories will be truncated
|
||||
- Does not show file sizes or permissions
|
||||
- Cannot recursively list all directories in a large project
|
||||
|
||||
TIPS:
|
||||
- Use Glob tool for finding files by name patterns instead of browsing
|
||||
- Use Grep tool for searching file contents
|
||||
- Combine with other tools for more effective exploration`
|
||||
)
|
||||
|
||||
func NewLsTool() BaseTool {
|
||||
return &lsTool{}
|
||||
}
|
||||
|
||||
func (l *lsTool) Info() ToolInfo {
|
||||
return ToolInfo{
|
||||
Name: LSToolName,
|
||||
Description: lsDescription,
|
||||
Parameters: map[string]any{
|
||||
"path": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The path to the directory to list (defaults to current working directory)",
|
||||
},
|
||||
"ignore": map[string]any{
|
||||
"type": "array",
|
||||
"description": "List of glob patterns to ignore",
|
||||
"items": map[string]any{
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{"path"},
|
||||
}
|
||||
}
|
||||
|
||||
func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
|
||||
var params LSParams
|
||||
if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil {
|
||||
return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
|
||||
}
|
||||
|
||||
searchPath := params.Path
|
||||
if searchPath == "" {
|
||||
searchPath = config.WorkingDirectory()
|
||||
}
|
||||
|
||||
if !filepath.IsAbs(searchPath) {
|
||||
searchPath = filepath.Join(config.WorkingDirectory(), searchPath)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(searchPath); os.IsNotExist(err) {
|
||||
return NewTextErrorResponse(fmt.Sprintf("path does not exist: %s", searchPath)), nil
|
||||
}
|
||||
|
||||
files, truncated, err := listDirectory(searchPath, params.Ignore, MaxLSFiles)
|
||||
if err != nil {
|
||||
return ToolResponse{}, fmt.Errorf("error listing directory: %w", err)
|
||||
}
|
||||
|
||||
tree := createFileTree(files)
|
||||
output := printTree(tree, searchPath)
|
||||
|
||||
if truncated {
|
||||
output = fmt.Sprintf("There are more than %d files in the directory. Use a more specific path or use the Glob tool to find specific files. The first %d files and directories are included below:\n\n%s", MaxLSFiles, MaxLSFiles, output)
|
||||
}
|
||||
|
||||
return WithResponseMetadata(
|
||||
NewTextResponse(output),
|
||||
LSResponseMetadata{
|
||||
NumberOfFiles: len(files),
|
||||
Truncated: truncated,
|
||||
},
|
||||
), nil
|
||||
}
|
||||
|
||||
func listDirectory(initialPath string, ignorePatterns []string, limit int) ([]string, bool, error) {
|
||||
var results []string
|
||||
truncated := false
|
||||
|
||||
err := filepath.Walk(initialPath, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil // Skip files we don't have permission to access
|
||||
}
|
||||
|
||||
if shouldSkip(path, ignorePatterns) {
|
||||
if info.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if path != initialPath {
|
||||
if info.IsDir() {
|
||||
path = path + string(filepath.Separator)
|
||||
}
|
||||
results = append(results, path)
|
||||
}
|
||||
|
||||
if len(results) >= limit {
|
||||
truncated = true
|
||||
return filepath.SkipAll
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, truncated, err
|
||||
}
|
||||
|
||||
return results, truncated, nil
|
||||
}
|
||||
|
||||
func shouldSkip(path string, ignorePatterns []string) bool {
|
||||
base := filepath.Base(path)
|
||||
|
||||
if base != "." && strings.HasPrefix(base, ".") {
|
||||
return true
|
||||
}
|
||||
|
||||
commonIgnored := []string{
|
||||
"__pycache__",
|
||||
"node_modules",
|
||||
"dist",
|
||||
"build",
|
||||
"target",
|
||||
"vendor",
|
||||
"bin",
|
||||
"obj",
|
||||
".git",
|
||||
".idea",
|
||||
".vscode",
|
||||
".DS_Store",
|
||||
"*.pyc",
|
||||
"*.pyo",
|
||||
"*.pyd",
|
||||
"*.so",
|
||||
"*.dll",
|
||||
"*.exe",
|
||||
}
|
||||
|
||||
if strings.Contains(path, filepath.Join("__pycache__", "")) {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, ignored := range commonIgnored {
|
||||
if strings.HasSuffix(ignored, "/") {
|
||||
if strings.Contains(path, filepath.Join(ignored[:len(ignored)-1], "")) {
|
||||
return true
|
||||
}
|
||||
} else if strings.HasPrefix(ignored, "*.") {
|
||||
if strings.HasSuffix(base, ignored[1:]) {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
if base == ignored {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, pattern := range ignorePatterns {
|
||||
matched, err := filepath.Match(pattern, base)
|
||||
if err == nil && matched {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func createFileTree(sortedPaths []string) []*TreeNode {
|
||||
root := []*TreeNode{}
|
||||
pathMap := make(map[string]*TreeNode)
|
||||
|
||||
for _, path := range sortedPaths {
|
||||
parts := strings.Split(path, string(filepath.Separator))
|
||||
currentPath := ""
|
||||
var parentPath string
|
||||
|
||||
var cleanParts []string
|
||||
for _, part := range parts {
|
||||
if part != "" {
|
||||
cleanParts = append(cleanParts, part)
|
||||
}
|
||||
}
|
||||
parts = cleanParts
|
||||
|
||||
if len(parts) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
for i, part := range parts {
|
||||
if currentPath == "" {
|
||||
currentPath = part
|
||||
} else {
|
||||
currentPath = filepath.Join(currentPath, part)
|
||||
}
|
||||
|
||||
if _, exists := pathMap[currentPath]; exists {
|
||||
parentPath = currentPath
|
||||
continue
|
||||
}
|
||||
|
||||
isLastPart := i == len(parts)-1
|
||||
isDir := !isLastPart || strings.HasSuffix(path, string(filepath.Separator))
|
||||
nodeType := "file"
|
||||
if isDir {
|
||||
nodeType = "directory"
|
||||
}
|
||||
newNode := &TreeNode{
|
||||
Name: part,
|
||||
Path: currentPath,
|
||||
Type: nodeType,
|
||||
Children: []*TreeNode{},
|
||||
}
|
||||
|
||||
pathMap[currentPath] = newNode
|
||||
|
||||
if i > 0 && parentPath != "" {
|
||||
if parent, ok := pathMap[parentPath]; ok {
|
||||
parent.Children = append(parent.Children, newNode)
|
||||
}
|
||||
} else {
|
||||
root = append(root, newNode)
|
||||
}
|
||||
|
||||
parentPath = currentPath
|
||||
}
|
||||
}
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
func printTree(tree []*TreeNode, rootPath string) string {
|
||||
var result strings.Builder
|
||||
|
||||
result.WriteString(fmt.Sprintf("- %s%s\n", rootPath, string(filepath.Separator)))
|
||||
|
||||
for _, node := range tree {
|
||||
printNode(&result, node, 1)
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
func printNode(builder *strings.Builder, node *TreeNode, level int) {
|
||||
indent := strings.Repeat(" ", level)
|
||||
|
||||
nodeName := node.Name
|
||||
if node.Type == "directory" {
|
||||
nodeName += string(filepath.Separator)
|
||||
}
|
||||
|
||||
fmt.Fprintf(builder, "%s- %s\n", indent, nodeName)
|
||||
|
||||
if node.Type == "directory" && len(node.Children) > 0 {
|
||||
for _, child := range node.Children {
|
||||
printNode(builder, child, level+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,457 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLsTool_Info(t *testing.T) {
|
||||
tool := NewLsTool()
|
||||
info := tool.Info()
|
||||
|
||||
assert.Equal(t, LSToolName, info.Name)
|
||||
assert.NotEmpty(t, info.Description)
|
||||
assert.Contains(t, info.Parameters, "path")
|
||||
assert.Contains(t, info.Parameters, "ignore")
|
||||
assert.Contains(t, info.Required, "path")
|
||||
}
|
||||
|
||||
func TestLsTool_Run(t *testing.T) {
|
||||
// Create a temporary directory for testing
|
||||
tempDir, err := os.MkdirTemp("", "ls_tool_test")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Create a test directory structure
|
||||
testDirs := []string{
|
||||
"dir1",
|
||||
"dir2",
|
||||
"dir2/subdir1",
|
||||
"dir2/subdir2",
|
||||
"dir3",
|
||||
"dir3/.hidden_dir",
|
||||
"__pycache__",
|
||||
}
|
||||
|
||||
testFiles := []string{
|
||||
"file1.txt",
|
||||
"file2.txt",
|
||||
"dir1/file3.txt",
|
||||
"dir2/file4.txt",
|
||||
"dir2/subdir1/file5.txt",
|
||||
"dir2/subdir2/file6.txt",
|
||||
"dir3/file7.txt",
|
||||
"dir3/.hidden_file.txt",
|
||||
"__pycache__/cache.pyc",
|
||||
".hidden_root_file.txt",
|
||||
}
|
||||
|
||||
// Create directories
|
||||
for _, dir := range testDirs {
|
||||
dirPath := filepath.Join(tempDir, dir)
|
||||
err := os.MkdirAll(dirPath, 0755)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Create files
|
||||
for _, file := range testFiles {
|
||||
filePath := filepath.Join(tempDir, file)
|
||||
err := os.WriteFile(filePath, []byte("test content"), 0644)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
t.Run("lists directory successfully", func(t *testing.T) {
|
||||
tool := NewLsTool()
|
||||
params := LSParams{
|
||||
Path: tempDir,
|
||||
}
|
||||
|
||||
paramsJSON, err := json.Marshal(params)
|
||||
require.NoError(t, err)
|
||||
|
||||
call := ToolCall{
|
||||
Name: LSToolName,
|
||||
Input: string(paramsJSON),
|
||||
}
|
||||
|
||||
response, err := tool.Run(context.Background(), call)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check that visible directories and files are included
|
||||
assert.Contains(t, response.Content, "dir1")
|
||||
assert.Contains(t, response.Content, "dir2")
|
||||
assert.Contains(t, response.Content, "dir3")
|
||||
assert.Contains(t, response.Content, "file1.txt")
|
||||
assert.Contains(t, response.Content, "file2.txt")
|
||||
|
||||
// Check that hidden files and directories are not included
|
||||
assert.NotContains(t, response.Content, ".hidden_dir")
|
||||
assert.NotContains(t, response.Content, ".hidden_file.txt")
|
||||
assert.NotContains(t, response.Content, ".hidden_root_file.txt")
|
||||
|
||||
// Check that __pycache__ is not included
|
||||
assert.NotContains(t, response.Content, "__pycache__")
|
||||
})
|
||||
|
||||
t.Run("handles non-existent path", func(t *testing.T) {
|
||||
tool := NewLsTool()
|
||||
params := LSParams{
|
||||
Path: filepath.Join(tempDir, "non_existent_dir"),
|
||||
}
|
||||
|
||||
paramsJSON, err := json.Marshal(params)
|
||||
require.NoError(t, err)
|
||||
|
||||
call := ToolCall{
|
||||
Name: LSToolName,
|
||||
Input: string(paramsJSON),
|
||||
}
|
||||
|
||||
response, err := tool.Run(context.Background(), call)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, response.Content, "path does not exist")
|
||||
})
|
||||
|
||||
t.Run("handles empty path parameter", func(t *testing.T) {
|
||||
// For this test, we need to mock the config.WorkingDirectory function
|
||||
// Since we can't easily do that, we'll just check that the response doesn't contain an error message
|
||||
|
||||
tool := NewLsTool()
|
||||
params := LSParams{
|
||||
Path: "",
|
||||
}
|
||||
|
||||
paramsJSON, err := json.Marshal(params)
|
||||
require.NoError(t, err)
|
||||
|
||||
call := ToolCall{
|
||||
Name: LSToolName,
|
||||
Input: string(paramsJSON),
|
||||
}
|
||||
|
||||
response, err := tool.Run(context.Background(), call)
|
||||
require.NoError(t, err)
|
||||
|
||||
// The response should either contain a valid directory listing or an error
|
||||
// We'll just check that it's not empty
|
||||
assert.NotEmpty(t, response.Content)
|
||||
})
|
||||
|
||||
t.Run("handles invalid parameters", func(t *testing.T) {
|
||||
tool := NewLsTool()
|
||||
call := ToolCall{
|
||||
Name: LSToolName,
|
||||
Input: "invalid json",
|
||||
}
|
||||
|
||||
response, err := tool.Run(context.Background(), call)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, response.Content, "error parsing parameters")
|
||||
})
|
||||
|
||||
t.Run("respects ignore patterns", func(t *testing.T) {
|
||||
tool := NewLsTool()
|
||||
params := LSParams{
|
||||
Path: tempDir,
|
||||
Ignore: []string{"file1.txt", "dir1"},
|
||||
}
|
||||
|
||||
paramsJSON, err := json.Marshal(params)
|
||||
require.NoError(t, err)
|
||||
|
||||
call := ToolCall{
|
||||
Name: LSToolName,
|
||||
Input: string(paramsJSON),
|
||||
}
|
||||
|
||||
response, err := tool.Run(context.Background(), call)
|
||||
require.NoError(t, err)
|
||||
|
||||
// The output format is a tree, so we need to check for specific patterns
|
||||
// Check that file1.txt is not directly mentioned
|
||||
assert.NotContains(t, response.Content, "- file1.txt")
|
||||
|
||||
// Check that dir1/ is not directly mentioned
|
||||
assert.NotContains(t, response.Content, "- dir1/")
|
||||
})
|
||||
|
||||
t.Run("handles relative path", func(t *testing.T) {
|
||||
// Save original working directory
|
||||
origWd, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
os.Chdir(origWd)
|
||||
}()
|
||||
|
||||
// Change to a directory above the temp directory
|
||||
parentDir := filepath.Dir(tempDir)
|
||||
err = os.Chdir(parentDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
tool := NewLsTool()
|
||||
params := LSParams{
|
||||
Path: filepath.Base(tempDir),
|
||||
}
|
||||
|
||||
paramsJSON, err := json.Marshal(params)
|
||||
require.NoError(t, err)
|
||||
|
||||
call := ToolCall{
|
||||
Name: LSToolName,
|
||||
Input: string(paramsJSON),
|
||||
}
|
||||
|
||||
response, err := tool.Run(context.Background(), call)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should list the temp directory contents
|
||||
assert.Contains(t, response.Content, "dir1")
|
||||
assert.Contains(t, response.Content, "file1.txt")
|
||||
})
|
||||
}
|
||||
|
||||
func TestShouldSkip(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
path string
|
||||
ignorePatterns []string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "hidden file",
|
||||
path: "/path/to/.hidden_file",
|
||||
ignorePatterns: []string{},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "hidden directory",
|
||||
path: "/path/to/.hidden_dir",
|
||||
ignorePatterns: []string{},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "pycache directory",
|
||||
path: "/path/to/__pycache__/file.pyc",
|
||||
ignorePatterns: []string{},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "node_modules directory",
|
||||
path: "/path/to/node_modules/package",
|
||||
ignorePatterns: []string{},
|
||||
expected: false, // The shouldSkip function doesn't directly check for node_modules in the path
|
||||
},
|
||||
{
|
||||
name: "normal file",
|
||||
path: "/path/to/normal_file.txt",
|
||||
ignorePatterns: []string{},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "normal directory",
|
||||
path: "/path/to/normal_dir",
|
||||
ignorePatterns: []string{},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "ignored by pattern",
|
||||
path: "/path/to/ignore_me.txt",
|
||||
ignorePatterns: []string{"ignore_*.txt"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "not ignored by pattern",
|
||||
path: "/path/to/keep_me.txt",
|
||||
ignorePatterns: []string{"ignore_*.txt"},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := shouldSkip(tc.path, tc.ignorePatterns)
|
||||
assert.Equal(t, tc.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFileTree(t *testing.T) {
|
||||
paths := []string{
|
||||
"/path/to/file1.txt",
|
||||
"/path/to/dir1/file2.txt",
|
||||
"/path/to/dir1/subdir/file3.txt",
|
||||
"/path/to/dir2/file4.txt",
|
||||
}
|
||||
|
||||
tree := createFileTree(paths)
|
||||
|
||||
// Check the structure of the tree
|
||||
assert.Len(t, tree, 1) // Should have one root node
|
||||
|
||||
// Check the root node
|
||||
rootNode := tree[0]
|
||||
assert.Equal(t, "path", rootNode.Name)
|
||||
assert.Equal(t, "directory", rootNode.Type)
|
||||
assert.Len(t, rootNode.Children, 1)
|
||||
|
||||
// Check the "to" node
|
||||
toNode := rootNode.Children[0]
|
||||
assert.Equal(t, "to", toNode.Name)
|
||||
assert.Equal(t, "directory", toNode.Type)
|
||||
assert.Len(t, toNode.Children, 3) // file1.txt, dir1, dir2
|
||||
|
||||
// Find the dir1 node
|
||||
var dir1Node *TreeNode
|
||||
for _, child := range toNode.Children {
|
||||
if child.Name == "dir1" {
|
||||
dir1Node = child
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
require.NotNil(t, dir1Node)
|
||||
assert.Equal(t, "directory", dir1Node.Type)
|
||||
assert.Len(t, dir1Node.Children, 2) // file2.txt and subdir
|
||||
}
|
||||
|
||||
func TestPrintTree(t *testing.T) {
|
||||
// Create a simple tree
|
||||
tree := []*TreeNode{
|
||||
{
|
||||
Name: "dir1",
|
||||
Path: "dir1",
|
||||
Type: "directory",
|
||||
Children: []*TreeNode{
|
||||
{
|
||||
Name: "file1.txt",
|
||||
Path: "dir1/file1.txt",
|
||||
Type: "file",
|
||||
},
|
||||
{
|
||||
Name: "subdir",
|
||||
Path: "dir1/subdir",
|
||||
Type: "directory",
|
||||
Children: []*TreeNode{
|
||||
{
|
||||
Name: "file2.txt",
|
||||
Path: "dir1/subdir/file2.txt",
|
||||
Type: "file",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "file3.txt",
|
||||
Path: "file3.txt",
|
||||
Type: "file",
|
||||
},
|
||||
}
|
||||
|
||||
result := printTree(tree, "/root")
|
||||
|
||||
// Check the output format
|
||||
assert.Contains(t, result, "- /root/")
|
||||
assert.Contains(t, result, " - dir1/")
|
||||
assert.Contains(t, result, " - file1.txt")
|
||||
assert.Contains(t, result, " - subdir/")
|
||||
assert.Contains(t, result, " - file2.txt")
|
||||
assert.Contains(t, result, " - file3.txt")
|
||||
}
|
||||
|
||||
func TestListDirectory(t *testing.T) {
|
||||
// Create a temporary directory for testing
|
||||
tempDir, err := os.MkdirTemp("", "list_directory_test")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Create a test directory structure
|
||||
testDirs := []string{
|
||||
"dir1",
|
||||
"dir1/subdir1",
|
||||
".hidden_dir",
|
||||
}
|
||||
|
||||
testFiles := []string{
|
||||
"file1.txt",
|
||||
"file2.txt",
|
||||
"dir1/file3.txt",
|
||||
"dir1/subdir1/file4.txt",
|
||||
".hidden_file.txt",
|
||||
}
|
||||
|
||||
// Create directories
|
||||
for _, dir := range testDirs {
|
||||
dirPath := filepath.Join(tempDir, dir)
|
||||
err := os.MkdirAll(dirPath, 0755)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Create files
|
||||
for _, file := range testFiles {
|
||||
filePath := filepath.Join(tempDir, file)
|
||||
err := os.WriteFile(filePath, []byte("test content"), 0644)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
t.Run("lists files with no limit", func(t *testing.T) {
|
||||
files, truncated, err := listDirectory(tempDir, []string{}, 1000)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, truncated)
|
||||
|
||||
// Check that visible files and directories are included
|
||||
containsPath := func(paths []string, target string) bool {
|
||||
targetPath := filepath.Join(tempDir, target)
|
||||
for _, path := range paths {
|
||||
if strings.HasPrefix(path, targetPath) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
assert.True(t, containsPath(files, "dir1"))
|
||||
assert.True(t, containsPath(files, "file1.txt"))
|
||||
assert.True(t, containsPath(files, "file2.txt"))
|
||||
assert.True(t, containsPath(files, "dir1/file3.txt"))
|
||||
|
||||
// Check that hidden files and directories are not included
|
||||
assert.False(t, containsPath(files, ".hidden_dir"))
|
||||
assert.False(t, containsPath(files, ".hidden_file.txt"))
|
||||
})
|
||||
|
||||
t.Run("respects limit and returns truncated flag", func(t *testing.T) {
|
||||
files, truncated, err := listDirectory(tempDir, []string{}, 2)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, truncated)
|
||||
assert.Len(t, files, 2)
|
||||
})
|
||||
|
||||
t.Run("respects ignore patterns", func(t *testing.T) {
|
||||
files, truncated, err := listDirectory(tempDir, []string{"*.txt"}, 1000)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, truncated)
|
||||
|
||||
// Check that no .txt files are included
|
||||
for _, file := range files {
|
||||
assert.False(t, strings.HasSuffix(file, ".txt"), "Found .txt file: %s", file)
|
||||
}
|
||||
|
||||
// But directories should still be included
|
||||
containsDir := false
|
||||
for _, file := range files {
|
||||
if strings.Contains(file, "dir1") {
|
||||
containsDir = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, containsDir)
|
||||
})
|
||||
}
|
||||
@@ -1,350 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/sst/opencode/internal/lsp"
|
||||
"github.com/sst/opencode/internal/lsp/protocol"
|
||||
"github.com/sst/opencode/internal/lsp/util"
|
||||
)
|
||||
|
||||
type CodeActionParams struct {
|
||||
FilePath string `json:"file_path"`
|
||||
Line int `json:"line"`
|
||||
Column int `json:"column"`
|
||||
EndLine int `json:"end_line,omitempty"`
|
||||
EndColumn int `json:"end_column,omitempty"`
|
||||
ActionID int `json:"action_id,omitempty"`
|
||||
LspName string `json:"lsp_name,omitempty"`
|
||||
}
|
||||
|
||||
type codeActionTool struct {
|
||||
lspClients map[string]*lsp.Client
|
||||
}
|
||||
|
||||
const (
|
||||
CodeActionToolName = "codeAction"
|
||||
codeActionDescription = `Get available code actions at a specific position or range in a file.
|
||||
WHEN TO USE THIS TOOL:
|
||||
- Use when you need to find available fixes or refactorings for code issues
|
||||
- Helpful for resolving errors, warnings, or improving code quality
|
||||
- Great for discovering automated code transformations
|
||||
|
||||
HOW TO USE:
|
||||
- Provide the path to the file containing the code
|
||||
- Specify the line number (1-based) where the action should be applied
|
||||
- Specify the column number (1-based) where the action should be applied
|
||||
- Optionally specify end_line and end_column to define a range
|
||||
- Results show available code actions with their titles and kinds
|
||||
|
||||
TO EXECUTE A CODE ACTION:
|
||||
- After getting the list of available actions, call the tool again with the same parameters
|
||||
- Add action_id parameter with the number of the action you want to execute (e.g., 1 for the first action)
|
||||
- Add lsp_name parameter with the name of the LSP server that provided the action
|
||||
|
||||
FEATURES:
|
||||
- Finds quick fixes for errors and warnings
|
||||
- Discovers available refactorings
|
||||
- Shows code organization actions
|
||||
- Returns detailed information about each action
|
||||
- Can execute selected code actions
|
||||
|
||||
LIMITATIONS:
|
||||
- Requires a functioning LSP server for the file type
|
||||
- May not work for all code issues depending on LSP capabilities
|
||||
- Results depend on the accuracy of the LSP server
|
||||
|
||||
TIPS:
|
||||
- Use in conjunction with Diagnostics tool to find issues that can be fixed
|
||||
- First call without action_id to see available actions, then call again with action_id to execute
|
||||
`
|
||||
)
|
||||
|
||||
func NewCodeActionTool(lspClients map[string]*lsp.Client) BaseTool {
|
||||
return &codeActionTool{
|
||||
lspClients,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *codeActionTool) Info() ToolInfo {
|
||||
return ToolInfo{
|
||||
Name: CodeActionToolName,
|
||||
Description: codeActionDescription,
|
||||
Parameters: map[string]any{
|
||||
"file_path": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The path to the file containing the code",
|
||||
},
|
||||
"line": map[string]any{
|
||||
"type": "integer",
|
||||
"description": "The line number (1-based) where the action should be applied",
|
||||
},
|
||||
"column": map[string]any{
|
||||
"type": "integer",
|
||||
"description": "The column number (1-based) where the action should be applied",
|
||||
},
|
||||
"end_line": map[string]any{
|
||||
"type": "integer",
|
||||
"description": "The ending line number (1-based) for a range (optional)",
|
||||
},
|
||||
"end_column": map[string]any{
|
||||
"type": "integer",
|
||||
"description": "The ending column number (1-based) for a range (optional)",
|
||||
},
|
||||
"action_id": map[string]any{
|
||||
"type": "integer",
|
||||
"description": "The ID of the code action to execute (optional)",
|
||||
},
|
||||
"lsp_name": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The name of the LSP server that provided the action (optional)",
|
||||
},
|
||||
},
|
||||
Required: []string{"file_path", "line", "column"},
|
||||
}
|
||||
}
|
||||
|
||||
func (b *codeActionTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
|
||||
var params CodeActionParams
|
||||
if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil {
|
||||
return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
|
||||
}
|
||||
|
||||
lsps := b.lspClients
|
||||
|
||||
if len(lsps) == 0 {
|
||||
return NewTextResponse("\nLSP clients are still initializing. Code actions will be available once they're ready.\n"), nil
|
||||
}
|
||||
|
||||
// Ensure file is open in LSP
|
||||
notifyLspOpenFile(ctx, params.FilePath, lsps)
|
||||
|
||||
// Convert 1-based line/column to 0-based for LSP protocol
|
||||
line := max(0, params.Line-1)
|
||||
column := max(0, params.Column-1)
|
||||
|
||||
// Handle optional end line/column
|
||||
endLine := line
|
||||
endColumn := column
|
||||
if params.EndLine > 0 {
|
||||
endLine = max(0, params.EndLine-1)
|
||||
}
|
||||
if params.EndColumn > 0 {
|
||||
endColumn = max(0, params.EndColumn-1)
|
||||
}
|
||||
|
||||
// Check if we're executing a specific action
|
||||
if params.ActionID > 0 && params.LspName != "" {
|
||||
return executeCodeAction(ctx, params.FilePath, line, column, endLine, endColumn, params.ActionID, params.LspName, lsps)
|
||||
}
|
||||
|
||||
// Otherwise, just list available actions
|
||||
output := getCodeActions(ctx, params.FilePath, line, column, endLine, endColumn, lsps)
|
||||
return NewTextResponse(output), nil
|
||||
}
|
||||
|
||||
func getCodeActions(ctx context.Context, filePath string, line, column, endLine, endColumn int, lsps map[string]*lsp.Client) string {
|
||||
var results []string
|
||||
|
||||
for lspName, client := range lsps {
|
||||
// Create code action params
|
||||
uri := fmt.Sprintf("file://%s", filePath)
|
||||
codeActionParams := protocol.CodeActionParams{
|
||||
TextDocument: protocol.TextDocumentIdentifier{
|
||||
URI: protocol.DocumentUri(uri),
|
||||
},
|
||||
Range: protocol.Range{
|
||||
Start: protocol.Position{
|
||||
Line: uint32(line),
|
||||
Character: uint32(column),
|
||||
},
|
||||
End: protocol.Position{
|
||||
Line: uint32(endLine),
|
||||
Character: uint32(endColumn),
|
||||
},
|
||||
},
|
||||
Context: protocol.CodeActionContext{
|
||||
// Request all kinds of code actions
|
||||
Only: []protocol.CodeActionKind{
|
||||
protocol.QuickFix,
|
||||
protocol.Refactor,
|
||||
protocol.RefactorExtract,
|
||||
protocol.RefactorInline,
|
||||
protocol.RefactorRewrite,
|
||||
protocol.Source,
|
||||
protocol.SourceOrganizeImports,
|
||||
protocol.SourceFixAll,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Get code actions
|
||||
codeActions, err := client.CodeAction(ctx, codeActionParams)
|
||||
if err != nil {
|
||||
results = append(results, fmt.Sprintf("Error from %s: %s", lspName, err))
|
||||
continue
|
||||
}
|
||||
|
||||
if len(codeActions) == 0 {
|
||||
results = append(results, fmt.Sprintf("No code actions found by %s", lspName))
|
||||
continue
|
||||
}
|
||||
|
||||
// Format the code actions
|
||||
results = append(results, fmt.Sprintf("Code actions found by %s:", lspName))
|
||||
for i, action := range codeActions {
|
||||
actionInfo := formatCodeAction(action, i+1)
|
||||
results = append(results, actionInfo)
|
||||
}
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
return "No code actions found at the specified position."
|
||||
}
|
||||
|
||||
return strings.Join(results, "\n")
|
||||
}
|
||||
|
||||
func formatCodeAction(action protocol.Or_Result_textDocument_codeAction_Item0_Elem, index int) string {
|
||||
switch v := action.Value.(type) {
|
||||
case protocol.CodeAction:
|
||||
kind := "Unknown"
|
||||
if v.Kind != "" {
|
||||
kind = string(v.Kind)
|
||||
}
|
||||
|
||||
var details []string
|
||||
|
||||
// Add edit information if available
|
||||
if v.Edit != nil {
|
||||
numChanges := 0
|
||||
if v.Edit.Changes != nil {
|
||||
numChanges = len(v.Edit.Changes)
|
||||
}
|
||||
if v.Edit.DocumentChanges != nil {
|
||||
numChanges = len(v.Edit.DocumentChanges)
|
||||
}
|
||||
details = append(details, fmt.Sprintf("Edits: %d changes", numChanges))
|
||||
}
|
||||
|
||||
// Add command information if available
|
||||
if v.Command != nil {
|
||||
details = append(details, fmt.Sprintf("Command: %s", v.Command.Title))
|
||||
}
|
||||
|
||||
// Add diagnostics information if available
|
||||
if v.Diagnostics != nil && len(v.Diagnostics) > 0 {
|
||||
details = append(details, fmt.Sprintf("Fixes: %d diagnostics", len(v.Diagnostics)))
|
||||
}
|
||||
|
||||
detailsStr := ""
|
||||
if len(details) > 0 {
|
||||
detailsStr = " (" + strings.Join(details, ", ") + ")"
|
||||
}
|
||||
|
||||
return fmt.Sprintf(" %d. %s [%s]%s", index, v.Title, kind, detailsStr)
|
||||
|
||||
case protocol.Command:
|
||||
return fmt.Sprintf(" %d. %s [Command]", index, v.Title)
|
||||
}
|
||||
|
||||
return fmt.Sprintf(" %d. Unknown code action type", index)
|
||||
}
|
||||
|
||||
func executeCodeAction(ctx context.Context, filePath string, line, column, endLine, endColumn, actionID int, lspName string, lsps map[string]*lsp.Client) (ToolResponse, error) {
|
||||
client, ok := lsps[lspName]
|
||||
if !ok {
|
||||
return NewTextErrorResponse(fmt.Sprintf("LSP server '%s' not found", lspName)), nil
|
||||
}
|
||||
|
||||
// Create code action params
|
||||
uri := fmt.Sprintf("file://%s", filePath)
|
||||
codeActionParams := protocol.CodeActionParams{
|
||||
TextDocument: protocol.TextDocumentIdentifier{
|
||||
URI: protocol.DocumentUri(uri),
|
||||
},
|
||||
Range: protocol.Range{
|
||||
Start: protocol.Position{
|
||||
Line: uint32(line),
|
||||
Character: uint32(column),
|
||||
},
|
||||
End: protocol.Position{
|
||||
Line: uint32(endLine),
|
||||
Character: uint32(endColumn),
|
||||
},
|
||||
},
|
||||
Context: protocol.CodeActionContext{
|
||||
// Request all kinds of code actions
|
||||
Only: []protocol.CodeActionKind{
|
||||
protocol.QuickFix,
|
||||
protocol.Refactor,
|
||||
protocol.RefactorExtract,
|
||||
protocol.RefactorInline,
|
||||
protocol.RefactorRewrite,
|
||||
protocol.Source,
|
||||
protocol.SourceOrganizeImports,
|
||||
protocol.SourceFixAll,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Get code actions
|
||||
codeActions, err := client.CodeAction(ctx, codeActionParams)
|
||||
if err != nil {
|
||||
return NewTextErrorResponse(fmt.Sprintf("Error getting code actions: %s", err)), nil
|
||||
}
|
||||
|
||||
if len(codeActions) == 0 {
|
||||
return NewTextErrorResponse("No code actions found"), nil
|
||||
}
|
||||
|
||||
// Check if the requested action ID is valid
|
||||
if actionID < 1 || actionID > len(codeActions) {
|
||||
return NewTextErrorResponse(fmt.Sprintf("Invalid action ID: %d. Available actions: 1-%d", actionID, len(codeActions))), nil
|
||||
}
|
||||
|
||||
// Get the selected action (adjust for 0-based index)
|
||||
selectedAction := codeActions[actionID-1]
|
||||
|
||||
// Execute the action based on its type
|
||||
switch v := selectedAction.Value.(type) {
|
||||
case protocol.CodeAction:
|
||||
// Apply workspace edit if available
|
||||
if v.Edit != nil {
|
||||
err := util.ApplyWorkspaceEdit(*v.Edit)
|
||||
if err != nil {
|
||||
return NewTextErrorResponse(fmt.Sprintf("Error applying edit: %s", err)), nil
|
||||
}
|
||||
}
|
||||
|
||||
// Execute command if available
|
||||
if v.Command != nil {
|
||||
_, err := client.ExecuteCommand(ctx, protocol.ExecuteCommandParams{
|
||||
Command: v.Command.Command,
|
||||
Arguments: v.Command.Arguments,
|
||||
})
|
||||
if err != nil {
|
||||
return NewTextErrorResponse(fmt.Sprintf("Error executing command: %s", err)), nil
|
||||
}
|
||||
}
|
||||
|
||||
return NewTextResponse(fmt.Sprintf("Successfully executed code action: %s", v.Title)), nil
|
||||
|
||||
case protocol.Command:
|
||||
// Execute the command
|
||||
_, err := client.ExecuteCommand(ctx, protocol.ExecuteCommandParams{
|
||||
Command: v.Command,
|
||||
Arguments: v.Arguments,
|
||||
})
|
||||
if err != nil {
|
||||
return NewTextErrorResponse(fmt.Sprintf("Error executing command: %s", err)), nil
|
||||
}
|
||||
|
||||
return NewTextResponse(fmt.Sprintf("Successfully executed command: %s", v.Title)), nil
|
||||
}
|
||||
|
||||
return NewTextErrorResponse("Unknown code action type"), nil
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/sst/opencode/internal/lsp"
|
||||
"github.com/sst/opencode/internal/lsp/protocol"
|
||||
)
|
||||
|
||||
type DefinitionParams struct {
|
||||
FilePath string `json:"file_path"`
|
||||
Line int `json:"line"`
|
||||
Column int `json:"column"`
|
||||
}
|
||||
|
||||
type definitionTool struct {
|
||||
lspClients map[string]*lsp.Client
|
||||
}
|
||||
|
||||
const (
|
||||
DefinitionToolName = "definition"
|
||||
definitionDescription = `Find the definition of a symbol at a specific position in a file.
|
||||
WHEN TO USE THIS TOOL:
|
||||
- Use when you need to find where a symbol is defined
|
||||
- Helpful for understanding code structure and relationships
|
||||
- Great for navigating between implementation and interface
|
||||
|
||||
HOW TO USE:
|
||||
- Provide the path to the file containing the symbol
|
||||
- Specify the line number (1-based) where the symbol appears
|
||||
- Specify the column number (1-based) where the symbol appears
|
||||
- Results show the location of the symbol's definition
|
||||
|
||||
FEATURES:
|
||||
- Finds definitions across files in the project
|
||||
- Works with variables, functions, classes, interfaces, etc.
|
||||
- Returns file path, line, and column of the definition
|
||||
|
||||
LIMITATIONS:
|
||||
- Requires a functioning LSP server for the file type
|
||||
- May not work for all symbols depending on LSP capabilities
|
||||
- Results depend on the accuracy of the LSP server
|
||||
|
||||
TIPS:
|
||||
- Use in conjunction with References tool to understand usage
|
||||
- Combine with View tool to examine the definition
|
||||
`
|
||||
)
|
||||
|
||||
func NewDefinitionTool(lspClients map[string]*lsp.Client) BaseTool {
|
||||
return &definitionTool{
|
||||
lspClients,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *definitionTool) Info() ToolInfo {
|
||||
return ToolInfo{
|
||||
Name: DefinitionToolName,
|
||||
Description: definitionDescription,
|
||||
Parameters: map[string]any{
|
||||
"file_path": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The path to the file containing the symbol",
|
||||
},
|
||||
"line": map[string]any{
|
||||
"type": "integer",
|
||||
"description": "The line number (1-based) where the symbol appears",
|
||||
},
|
||||
"column": map[string]any{
|
||||
"type": "integer",
|
||||
"description": "The column number (1-based) where the symbol appears",
|
||||
},
|
||||
},
|
||||
Required: []string{"file_path", "line", "column"},
|
||||
}
|
||||
}
|
||||
|
||||
func (b *definitionTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
|
||||
var params DefinitionParams
|
||||
if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil {
|
||||
return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
|
||||
}
|
||||
|
||||
lsps := b.lspClients
|
||||
|
||||
if len(lsps) == 0 {
|
||||
return NewTextResponse("\nLSP clients are still initializing. Definition lookup will be available once they're ready.\n"), nil
|
||||
}
|
||||
|
||||
// Ensure file is open in LSP
|
||||
notifyLspOpenFile(ctx, params.FilePath, lsps)
|
||||
|
||||
// Convert 1-based line/column to 0-based for LSP protocol
|
||||
line := max(0, params.Line-1)
|
||||
column := max(0, params.Column-1)
|
||||
|
||||
output := getDefinition(ctx, params.FilePath, line, column, lsps)
|
||||
|
||||
return NewTextResponse(output), nil
|
||||
}
|
||||
|
||||
func getDefinition(ctx context.Context, filePath string, line, column int, lsps map[string]*lsp.Client) string {
|
||||
var results []string
|
||||
|
||||
slog.Debug(fmt.Sprintf("Looking for definition in %s at line %d, column %d", filePath, line+1, column+1))
|
||||
slog.Debug(fmt.Sprintf("Available LSP clients: %v", getClientNames(lsps)))
|
||||
|
||||
for lspName, client := range lsps {
|
||||
slog.Debug(fmt.Sprintf("Trying LSP client: %s", lspName))
|
||||
// Create definition params
|
||||
uri := fmt.Sprintf("file://%s", filePath)
|
||||
definitionParams := protocol.DefinitionParams{
|
||||
TextDocumentPositionParams: protocol.TextDocumentPositionParams{
|
||||
TextDocument: protocol.TextDocumentIdentifier{
|
||||
URI: protocol.DocumentUri(uri),
|
||||
},
|
||||
Position: protocol.Position{
|
||||
Line: uint32(line),
|
||||
Character: uint32(column),
|
||||
},
|
||||
},
|
||||
}
|
||||
slog.Debug(fmt.Sprintf("Sending definition request with params: %+v", definitionParams))
|
||||
|
||||
// Get definition
|
||||
definition, err := client.Definition(ctx, definitionParams)
|
||||
if err != nil {
|
||||
slog.Debug(fmt.Sprintf("Error from %s: %s", lspName, err))
|
||||
results = append(results, fmt.Sprintf("Error from %s: %s", lspName, err))
|
||||
continue
|
||||
}
|
||||
slog.Debug(fmt.Sprintf("Got definition result type: %T", definition.Value))
|
||||
|
||||
// Process the definition result
|
||||
locations := processDefinitionResult(definition)
|
||||
slog.Debug(fmt.Sprintf("Processed locations count: %d", len(locations)))
|
||||
if len(locations) == 0 {
|
||||
results = append(results, fmt.Sprintf("No definition found by %s", lspName))
|
||||
continue
|
||||
}
|
||||
|
||||
// Format the locations
|
||||
for _, loc := range locations {
|
||||
path := strings.TrimPrefix(string(loc.URI), "file://")
|
||||
// Convert 0-based line/column to 1-based for display
|
||||
defLine := loc.Range.Start.Line + 1
|
||||
defColumn := loc.Range.Start.Character + 1
|
||||
slog.Debug(fmt.Sprintf("Found definition at %s:%d:%d", path, defLine, defColumn))
|
||||
results = append(results, fmt.Sprintf("Definition found by %s: %s:%d:%d", lspName, path, defLine, defColumn))
|
||||
}
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
return "No definition found for the symbol at the specified position."
|
||||
}
|
||||
|
||||
return strings.Join(results, "\n")
|
||||
}
|
||||
|
||||
func processDefinitionResult(result protocol.Or_Result_textDocument_definition) []protocol.Location {
|
||||
var locations []protocol.Location
|
||||
|
||||
switch v := result.Value.(type) {
|
||||
case protocol.Location:
|
||||
locations = append(locations, v)
|
||||
case []protocol.Location:
|
||||
locations = append(locations, v...)
|
||||
case []protocol.DefinitionLink:
|
||||
for _, link := range v {
|
||||
locations = append(locations, protocol.Location{
|
||||
URI: link.TargetURI,
|
||||
Range: link.TargetRange,
|
||||
})
|
||||
}
|
||||
case protocol.Or_Definition:
|
||||
switch d := v.Value.(type) {
|
||||
case protocol.Location:
|
||||
locations = append(locations, d)
|
||||
case []protocol.Location:
|
||||
locations = append(locations, d...)
|
||||
}
|
||||
}
|
||||
|
||||
return locations
|
||||
}
|
||||
|
||||
// Helper function to get LSP client names for debugging
|
||||
func getClientNames(lsps map[string]*lsp.Client) []string {
|
||||
names := make([]string, 0, len(lsps))
|
||||
for name := range lsps {
|
||||
names = append(names, name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
@@ -1,296 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sst/opencode/internal/lsp"
|
||||
"github.com/sst/opencode/internal/lsp/protocol"
|
||||
)
|
||||
|
||||
type DiagnosticsParams struct {
|
||||
FilePath string `json:"file_path"`
|
||||
}
|
||||
type diagnosticsTool struct {
|
||||
lspClients map[string]*lsp.Client
|
||||
}
|
||||
|
||||
const (
|
||||
DiagnosticsToolName = "diagnostics"
|
||||
diagnosticsDescription = `Get diagnostics for a file and/or project.
|
||||
WHEN TO USE THIS TOOL:
|
||||
- Use when you need to check for errors or warnings in your code
|
||||
- Helpful for debugging and ensuring code quality
|
||||
- Good for getting a quick overview of issues in a file or project
|
||||
HOW TO USE:
|
||||
- Provide a path to a file to get diagnostics for that file
|
||||
- Leave the path empty to get diagnostics for the entire project
|
||||
- Results are displayed in a structured format with severity levels
|
||||
FEATURES:
|
||||
- Displays errors, warnings, and hints
|
||||
- Groups diagnostics by severity
|
||||
- Provides detailed information about each diagnostic
|
||||
LIMITATIONS:
|
||||
- Results are limited to the diagnostics provided by the LSP clients
|
||||
- May not cover all possible issues in the code
|
||||
- Does not provide suggestions for fixing issues
|
||||
TIPS:
|
||||
- Use in conjunction with other tools for a comprehensive code review
|
||||
- Combine with the LSP client for real-time diagnostics
|
||||
`
|
||||
)
|
||||
|
||||
func NewDiagnosticsTool(lspClients map[string]*lsp.Client) BaseTool {
|
||||
return &diagnosticsTool{
|
||||
lspClients,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *diagnosticsTool) Info() ToolInfo {
|
||||
return ToolInfo{
|
||||
Name: DiagnosticsToolName,
|
||||
Description: diagnosticsDescription,
|
||||
Parameters: map[string]any{
|
||||
"file_path": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The path to the file to get diagnostics for (leave w empty for project diagnostics)",
|
||||
},
|
||||
},
|
||||
Required: []string{},
|
||||
}
|
||||
}
|
||||
|
||||
func (b *diagnosticsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
|
||||
var params DiagnosticsParams
|
||||
if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil {
|
||||
return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
|
||||
}
|
||||
|
||||
lsps := b.lspClients
|
||||
|
||||
if len(lsps) == 0 {
|
||||
// Return a more helpful message when LSP clients aren't ready yet
|
||||
return NewTextResponse("\n<diagnostic_summary>\nLSP clients are still initializing. Diagnostics will be available once they're ready.\n</diagnostic_summary>\n"), nil
|
||||
}
|
||||
|
||||
if params.FilePath != "" {
|
||||
notifyLspOpenFile(ctx, params.FilePath, lsps)
|
||||
waitForLspDiagnostics(ctx, params.FilePath, lsps)
|
||||
}
|
||||
|
||||
output := getDiagnostics(params.FilePath, lsps)
|
||||
|
||||
return NewTextResponse(output), nil
|
||||
}
|
||||
|
||||
func notifyLspOpenFile(ctx context.Context, filePath string, lsps map[string]*lsp.Client) {
|
||||
for _, client := range lsps {
|
||||
err := client.OpenFile(ctx, filePath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func waitForLspDiagnostics(ctx context.Context, filePath string, lsps map[string]*lsp.Client) {
|
||||
if len(lsps) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
diagChan := make(chan struct{}, 1)
|
||||
|
||||
for _, client := range lsps {
|
||||
originalDiags := make(map[protocol.DocumentUri][]protocol.Diagnostic)
|
||||
maps.Copy(originalDiags, client.GetDiagnostics())
|
||||
|
||||
handler := func(params json.RawMessage) {
|
||||
lsp.HandleDiagnostics(client, params)
|
||||
var diagParams protocol.PublishDiagnosticsParams
|
||||
if err := json.Unmarshal(params, &diagParams); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if diagParams.URI.Path() == filePath || hasDiagnosticsChanged(client.GetDiagnostics(), originalDiags) {
|
||||
select {
|
||||
case diagChan <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client.RegisterNotificationHandler("textDocument/publishDiagnostics", handler)
|
||||
|
||||
if client.IsFileOpen(filePath) {
|
||||
err := client.NotifyChange(ctx, filePath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
err := client.OpenFile(ctx, filePath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case <-diagChan:
|
||||
case <-time.After(5 * time.Second):
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
|
||||
func hasDiagnosticsChanged(current, original map[protocol.DocumentUri][]protocol.Diagnostic) bool {
|
||||
for uri, diags := range current {
|
||||
origDiags, exists := original[uri]
|
||||
if !exists || len(diags) != len(origDiags) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func getDiagnostics(filePath string, lsps map[string]*lsp.Client) string {
|
||||
fileDiagnostics := []string{}
|
||||
projectDiagnostics := []string{}
|
||||
|
||||
formatDiagnostic := func(pth string, diagnostic protocol.Diagnostic, source string) string {
|
||||
severity := "Info"
|
||||
switch diagnostic.Severity {
|
||||
case protocol.SeverityError:
|
||||
severity = "Error"
|
||||
case protocol.SeverityWarning:
|
||||
severity = "Warn"
|
||||
case protocol.SeverityHint:
|
||||
severity = "Hint"
|
||||
}
|
||||
|
||||
location := fmt.Sprintf("%s:%d:%d", pth, diagnostic.Range.Start.Line+1, diagnostic.Range.Start.Character+1)
|
||||
|
||||
sourceInfo := ""
|
||||
if diagnostic.Source != "" {
|
||||
sourceInfo = diagnostic.Source
|
||||
} else if source != "" {
|
||||
sourceInfo = source
|
||||
}
|
||||
|
||||
codeInfo := ""
|
||||
if diagnostic.Code != nil {
|
||||
codeInfo = fmt.Sprintf("[%v]", diagnostic.Code)
|
||||
}
|
||||
|
||||
tagsInfo := ""
|
||||
if len(diagnostic.Tags) > 0 {
|
||||
tags := []string{}
|
||||
for _, tag := range diagnostic.Tags {
|
||||
switch tag {
|
||||
case protocol.Unnecessary:
|
||||
tags = append(tags, "unnecessary")
|
||||
case protocol.Deprecated:
|
||||
tags = append(tags, "deprecated")
|
||||
}
|
||||
}
|
||||
if len(tags) > 0 {
|
||||
tagsInfo = fmt.Sprintf(" (%s)", strings.Join(tags, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s: %s [%s]%s%s %s",
|
||||
severity,
|
||||
location,
|
||||
sourceInfo,
|
||||
codeInfo,
|
||||
tagsInfo,
|
||||
diagnostic.Message)
|
||||
}
|
||||
|
||||
for lspName, client := range lsps {
|
||||
diagnostics := client.GetDiagnostics()
|
||||
if len(diagnostics) > 0 {
|
||||
for location, diags := range diagnostics {
|
||||
isCurrentFile := location.Path() == filePath
|
||||
|
||||
for _, diag := range diags {
|
||||
formattedDiag := formatDiagnostic(location.Path(), diag, lspName)
|
||||
|
||||
if isCurrentFile {
|
||||
fileDiagnostics = append(fileDiagnostics, formattedDiag)
|
||||
} else {
|
||||
projectDiagnostics = append(projectDiagnostics, formattedDiag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(fileDiagnostics, func(i, j int) bool {
|
||||
iIsError := strings.HasPrefix(fileDiagnostics[i], "Error")
|
||||
jIsError := strings.HasPrefix(fileDiagnostics[j], "Error")
|
||||
if iIsError != jIsError {
|
||||
return iIsError // Errors come first
|
||||
}
|
||||
return fileDiagnostics[i] < fileDiagnostics[j] // Then alphabetically
|
||||
})
|
||||
|
||||
sort.Slice(projectDiagnostics, func(i, j int) bool {
|
||||
iIsError := strings.HasPrefix(projectDiagnostics[i], "Error")
|
||||
jIsError := strings.HasPrefix(projectDiagnostics[j], "Error")
|
||||
if iIsError != jIsError {
|
||||
return iIsError
|
||||
}
|
||||
return projectDiagnostics[i] < projectDiagnostics[j]
|
||||
})
|
||||
|
||||
output := ""
|
||||
|
||||
if len(fileDiagnostics) > 0 {
|
||||
output += "\n<file_diagnostics>\n"
|
||||
if len(fileDiagnostics) > 10 {
|
||||
output += strings.Join(fileDiagnostics[:10], "\n")
|
||||
output += fmt.Sprintf("\n... and %d more diagnostics", len(fileDiagnostics)-10)
|
||||
} else {
|
||||
output += strings.Join(fileDiagnostics, "\n")
|
||||
}
|
||||
output += "\n</file_diagnostics>\n"
|
||||
}
|
||||
|
||||
if len(projectDiagnostics) > 0 {
|
||||
output += "\n<project_diagnostics>\n"
|
||||
if len(projectDiagnostics) > 10 {
|
||||
output += strings.Join(projectDiagnostics[:10], "\n")
|
||||
output += fmt.Sprintf("\n... and %d more diagnostics", len(projectDiagnostics)-10)
|
||||
} else {
|
||||
output += strings.Join(projectDiagnostics, "\n")
|
||||
}
|
||||
output += "\n</project_diagnostics>\n"
|
||||
}
|
||||
|
||||
if len(fileDiagnostics) > 0 || len(projectDiagnostics) > 0 {
|
||||
fileErrors := countSeverity(fileDiagnostics, "Error")
|
||||
fileWarnings := countSeverity(fileDiagnostics, "Warn")
|
||||
projectErrors := countSeverity(projectDiagnostics, "Error")
|
||||
projectWarnings := countSeverity(projectDiagnostics, "Warn")
|
||||
|
||||
output += "\n<diagnostic_summary>\n"
|
||||
output += fmt.Sprintf("Current file: %d errors, %d warnings\n", fileErrors, fileWarnings)
|
||||
output += fmt.Sprintf("Project: %d errors, %d warnings\n", projectErrors, projectWarnings)
|
||||
output += "</diagnostic_summary>\n"
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
func countSeverity(diagnostics []string, severity string) int {
|
||||
count := 0
|
||||
for _, diag := range diagnostics {
|
||||
if strings.HasPrefix(diag, severity) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/sst/opencode/internal/lsp"
|
||||
"github.com/sst/opencode/internal/lsp/protocol"
|
||||
)
|
||||
|
||||
type DocSymbolsParams struct {
|
||||
FilePath string `json:"file_path"`
|
||||
}
|
||||
|
||||
type docSymbolsTool struct {
|
||||
lspClients map[string]*lsp.Client
|
||||
}
|
||||
|
||||
const (
|
||||
DocSymbolsToolName = "docSymbols"
|
||||
docSymbolsDescription = `Get document symbols for a file.
|
||||
WHEN TO USE THIS TOOL:
|
||||
- Use when you need to understand the structure of a file
|
||||
- Helpful for finding classes, functions, methods, and variables in a file
|
||||
- Great for getting an overview of a file's organization
|
||||
|
||||
HOW TO USE:
|
||||
- Provide the path to the file to get symbols for
|
||||
- Results show all symbols defined in the file with their kind and location
|
||||
|
||||
FEATURES:
|
||||
- Lists all symbols in a hierarchical structure
|
||||
- Shows symbol types (function, class, variable, etc.)
|
||||
- Provides location information for each symbol
|
||||
- Organizes symbols by their scope and relationship
|
||||
|
||||
LIMITATIONS:
|
||||
- Requires a functioning LSP server for the file type
|
||||
- Results depend on the accuracy of the LSP server
|
||||
- May not work for all file types
|
||||
|
||||
TIPS:
|
||||
- Use to quickly understand the structure of a large file
|
||||
- Combine with Definition and References tools for deeper code exploration
|
||||
`
|
||||
)
|
||||
|
||||
func NewDocSymbolsTool(lspClients map[string]*lsp.Client) BaseTool {
|
||||
return &docSymbolsTool{
|
||||
lspClients,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *docSymbolsTool) Info() ToolInfo {
|
||||
return ToolInfo{
|
||||
Name: DocSymbolsToolName,
|
||||
Description: docSymbolsDescription,
|
||||
Parameters: map[string]any{
|
||||
"file_path": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The path to the file to get symbols for",
|
||||
},
|
||||
},
|
||||
Required: []string{"file_path"},
|
||||
}
|
||||
}
|
||||
|
||||
func (b *docSymbolsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
|
||||
var params DocSymbolsParams
|
||||
if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil {
|
||||
return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
|
||||
}
|
||||
|
||||
lsps := b.lspClients
|
||||
|
||||
if len(lsps) == 0 {
|
||||
return NewTextResponse("\nLSP clients are still initializing. Document symbols lookup will be available once they're ready.\n"), nil
|
||||
}
|
||||
|
||||
// Ensure file is open in LSP
|
||||
notifyLspOpenFile(ctx, params.FilePath, lsps)
|
||||
|
||||
output := getDocumentSymbols(ctx, params.FilePath, lsps)
|
||||
|
||||
return NewTextResponse(output), nil
|
||||
}
|
||||
|
||||
func getDocumentSymbols(ctx context.Context, filePath string, lsps map[string]*lsp.Client) string {
|
||||
var results []string
|
||||
|
||||
for lspName, client := range lsps {
|
||||
// Create document symbol params
|
||||
uri := fmt.Sprintf("file://%s", filePath)
|
||||
symbolParams := protocol.DocumentSymbolParams{
|
||||
TextDocument: protocol.TextDocumentIdentifier{
|
||||
URI: protocol.DocumentUri(uri),
|
||||
},
|
||||
}
|
||||
|
||||
// Get document symbols
|
||||
symbolResult, err := client.DocumentSymbol(ctx, symbolParams)
|
||||
if err != nil {
|
||||
results = append(results, fmt.Sprintf("Error from %s: %s", lspName, err))
|
||||
continue
|
||||
}
|
||||
|
||||
// Process the symbol result
|
||||
symbols := processDocumentSymbolResult(symbolResult)
|
||||
if len(symbols) == 0 {
|
||||
results = append(results, fmt.Sprintf("No symbols found by %s", lspName))
|
||||
continue
|
||||
}
|
||||
|
||||
// Format the symbols
|
||||
results = append(results, fmt.Sprintf("Symbols found by %s:", lspName))
|
||||
for _, symbol := range symbols {
|
||||
results = append(results, formatSymbol(symbol, 1))
|
||||
}
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
return "No symbols found in the specified file."
|
||||
}
|
||||
|
||||
return strings.Join(results, "\n")
|
||||
}
|
||||
|
||||
func processDocumentSymbolResult(result protocol.Or_Result_textDocument_documentSymbol) []SymbolInfo {
|
||||
var symbols []SymbolInfo
|
||||
|
||||
switch v := result.Value.(type) {
|
||||
case []protocol.SymbolInformation:
|
||||
for _, si := range v {
|
||||
symbols = append(symbols, SymbolInfo{
|
||||
Name: si.Name,
|
||||
Kind: symbolKindToString(si.Kind),
|
||||
Location: locationToString(si.Location),
|
||||
Children: nil,
|
||||
})
|
||||
}
|
||||
case []protocol.DocumentSymbol:
|
||||
for _, ds := range v {
|
||||
symbols = append(symbols, documentSymbolToSymbolInfo(ds))
|
||||
}
|
||||
}
|
||||
|
||||
return symbols
|
||||
}
|
||||
|
||||
// SymbolInfo represents a symbol in a document
|
||||
type SymbolInfo struct {
|
||||
Name string
|
||||
Kind string
|
||||
Location string
|
||||
Children []SymbolInfo
|
||||
}
|
||||
|
||||
func documentSymbolToSymbolInfo(symbol protocol.DocumentSymbol) SymbolInfo {
|
||||
info := SymbolInfo{
|
||||
Name: symbol.Name,
|
||||
Kind: symbolKindToString(symbol.Kind),
|
||||
Location: fmt.Sprintf("Line %d-%d",
|
||||
symbol.Range.Start.Line+1,
|
||||
symbol.Range.End.Line+1),
|
||||
Children: []SymbolInfo{},
|
||||
}
|
||||
|
||||
for _, child := range symbol.Children {
|
||||
info.Children = append(info.Children, documentSymbolToSymbolInfo(child))
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
func locationToString(location protocol.Location) string {
|
||||
return fmt.Sprintf("Line %d-%d",
|
||||
location.Range.Start.Line+1,
|
||||
location.Range.End.Line+1)
|
||||
}
|
||||
|
||||
func symbolKindToString(kind protocol.SymbolKind) string {
|
||||
if kindStr, ok := protocol.TableKindMap[kind]; ok {
|
||||
return kindStr
|
||||
}
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
func formatSymbol(symbol SymbolInfo, level int) string {
|
||||
indent := strings.Repeat(" ", level)
|
||||
result := fmt.Sprintf("%s- %s (%s) %s", indent, symbol.Name, symbol.Kind, symbol.Location)
|
||||
|
||||
var childResults []string
|
||||
for _, child := range symbol.Children {
|
||||
childResults = append(childResults, formatSymbol(child, level+1))
|
||||
}
|
||||
|
||||
if len(childResults) > 0 {
|
||||
return result + "\n" + strings.Join(childResults, "\n")
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/sst/opencode/internal/lsp"
|
||||
"github.com/sst/opencode/internal/lsp/protocol"
|
||||
)
|
||||
|
||||
type ReferencesParams struct {
|
||||
FilePath string `json:"file_path"`
|
||||
Line int `json:"line"`
|
||||
Column int `json:"column"`
|
||||
IncludeDeclaration bool `json:"include_declaration"`
|
||||
}
|
||||
|
||||
type referencesTool struct {
|
||||
lspClients map[string]*lsp.Client
|
||||
}
|
||||
|
||||
const (
|
||||
ReferencesToolName = "references"
|
||||
referencesDescription = `Find all references to a symbol at a specific position in a file.
|
||||
WHEN TO USE THIS TOOL:
|
||||
- Use when you need to find all places where a symbol is used
|
||||
- Helpful for understanding code usage and dependencies
|
||||
- Great for refactoring and impact analysis
|
||||
|
||||
HOW TO USE:
|
||||
- Provide the path to the file containing the symbol
|
||||
- Specify the line number (1-based) where the symbol appears
|
||||
- Specify the column number (1-based) where the symbol appears
|
||||
- Optionally set include_declaration to include the declaration in results
|
||||
- Results show all locations where the symbol is referenced
|
||||
|
||||
FEATURES:
|
||||
- Finds references across files in the project
|
||||
- Works with variables, functions, classes, interfaces, etc.
|
||||
- Returns file paths, lines, and columns of all references
|
||||
|
||||
LIMITATIONS:
|
||||
- Requires a functioning LSP server for the file type
|
||||
- May not find all references depending on LSP capabilities
|
||||
- Results depend on the accuracy of the LSP server
|
||||
|
||||
TIPS:
|
||||
- Use in conjunction with Definition tool to understand symbol origins
|
||||
- Combine with View tool to examine the references
|
||||
`
|
||||
)
|
||||
|
||||
func NewReferencesTool(lspClients map[string]*lsp.Client) BaseTool {
|
||||
return &referencesTool{
|
||||
lspClients,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *referencesTool) Info() ToolInfo {
|
||||
return ToolInfo{
|
||||
Name: ReferencesToolName,
|
||||
Description: referencesDescription,
|
||||
Parameters: map[string]any{
|
||||
"file_path": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The path to the file containing the symbol",
|
||||
},
|
||||
"line": map[string]any{
|
||||
"type": "integer",
|
||||
"description": "The line number (1-based) where the symbol appears",
|
||||
},
|
||||
"column": map[string]any{
|
||||
"type": "integer",
|
||||
"description": "The column number (1-based) where the symbol appears",
|
||||
},
|
||||
"include_declaration": map[string]any{
|
||||
"type": "boolean",
|
||||
"description": "Whether to include the declaration in the results",
|
||||
},
|
||||
},
|
||||
Required: []string{"file_path", "line", "column"},
|
||||
}
|
||||
}
|
||||
|
||||
func (b *referencesTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
|
||||
var params ReferencesParams
|
||||
if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil {
|
||||
return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
|
||||
}
|
||||
|
||||
lsps := b.lspClients
|
||||
|
||||
if len(lsps) == 0 {
|
||||
return NewTextResponse("\nLSP clients are still initializing. References lookup will be available once they're ready.\n"), nil
|
||||
}
|
||||
|
||||
// Ensure file is open in LSP
|
||||
notifyLspOpenFile(ctx, params.FilePath, lsps)
|
||||
|
||||
// Convert 1-based line/column to 0-based for LSP protocol
|
||||
line := max(0, params.Line-1)
|
||||
column := max(0, params.Column-1)
|
||||
|
||||
output := getReferences(ctx, params.FilePath, line, column, params.IncludeDeclaration, lsps)
|
||||
|
||||
return NewTextResponse(output), nil
|
||||
}
|
||||
|
||||
func getReferences(ctx context.Context, filePath string, line, column int, includeDeclaration bool, lsps map[string]*lsp.Client) string {
|
||||
var results []string
|
||||
|
||||
for lspName, client := range lsps {
|
||||
// Create references params
|
||||
uri := fmt.Sprintf("file://%s", filePath)
|
||||
referencesParams := protocol.ReferenceParams{
|
||||
TextDocumentPositionParams: protocol.TextDocumentPositionParams{
|
||||
TextDocument: protocol.TextDocumentIdentifier{
|
||||
URI: protocol.DocumentUri(uri),
|
||||
},
|
||||
Position: protocol.Position{
|
||||
Line: uint32(line),
|
||||
Character: uint32(column),
|
||||
},
|
||||
},
|
||||
Context: protocol.ReferenceContext{
|
||||
IncludeDeclaration: includeDeclaration,
|
||||
},
|
||||
}
|
||||
|
||||
// Get references
|
||||
references, err := client.References(ctx, referencesParams)
|
||||
if err != nil {
|
||||
results = append(results, fmt.Sprintf("Error from %s: %s", lspName, err))
|
||||
continue
|
||||
}
|
||||
|
||||
if len(references) == 0 {
|
||||
results = append(results, fmt.Sprintf("No references found by %s", lspName))
|
||||
continue
|
||||
}
|
||||
|
||||
// Format the locations
|
||||
results = append(results, fmt.Sprintf("References found by %s:", lspName))
|
||||
for _, loc := range references {
|
||||
path := strings.TrimPrefix(string(loc.URI), "file://")
|
||||
// Convert 0-based line/column to 1-based for display
|
||||
refLine := loc.Range.Start.Line + 1
|
||||
refColumn := loc.Range.Start.Character + 1
|
||||
results = append(results, fmt.Sprintf(" %s:%d:%d", path, refLine, refColumn))
|
||||
}
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
return "No references found for the symbol at the specified position."
|
||||
}
|
||||
|
||||
return strings.Join(results, "\n")
|
||||
}
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/sst/opencode/internal/lsp"
|
||||
"github.com/sst/opencode/internal/lsp/protocol"
|
||||
)
|
||||
|
||||
type WorkspaceSymbolsParams struct {
|
||||
Query string `json:"query"`
|
||||
}
|
||||
|
||||
type workspaceSymbolsTool struct {
|
||||
lspClients map[string]*lsp.Client
|
||||
}
|
||||
|
||||
const (
|
||||
WorkspaceSymbolsToolName = "workspaceSymbols"
|
||||
workspaceSymbolsDescription = `Find symbols across the workspace matching a query.
|
||||
WHEN TO USE THIS TOOL:
|
||||
- Use when you need to find symbols across multiple files
|
||||
- Helpful for locating classes, functions, or variables in a project
|
||||
- Great for exploring large codebases
|
||||
|
||||
HOW TO USE:
|
||||
- Provide a query string to search for symbols
|
||||
- Results show matching symbols from across the workspace
|
||||
|
||||
FEATURES:
|
||||
- Searches across all files in the workspace
|
||||
- Shows symbol types (function, class, variable, etc.)
|
||||
- Provides location information for each symbol
|
||||
- Works with partial matches and fuzzy search (depending on LSP server)
|
||||
|
||||
LIMITATIONS:
|
||||
- Requires a functioning LSP server for the file types
|
||||
- Results depend on the accuracy of the LSP server
|
||||
- Query capabilities vary by language server
|
||||
- May not work for all file types
|
||||
|
||||
TIPS:
|
||||
- Use specific queries to narrow down results
|
||||
- Combine with DocSymbols tool for detailed file exploration
|
||||
- Use with Definition tool to jump to symbol definitions
|
||||
`
|
||||
)
|
||||
|
||||
func NewWorkspaceSymbolsTool(lspClients map[string]*lsp.Client) BaseTool {
|
||||
return &workspaceSymbolsTool{
|
||||
lspClients,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *workspaceSymbolsTool) Info() ToolInfo {
|
||||
return ToolInfo{
|
||||
Name: WorkspaceSymbolsToolName,
|
||||
Description: workspaceSymbolsDescription,
|
||||
Parameters: map[string]any{
|
||||
"query": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The query string to search for symbols",
|
||||
},
|
||||
},
|
||||
Required: []string{"query"},
|
||||
}
|
||||
}
|
||||
|
||||
func (b *workspaceSymbolsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
|
||||
var params WorkspaceSymbolsParams
|
||||
if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil {
|
||||
return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
|
||||
}
|
||||
|
||||
lsps := b.lspClients
|
||||
|
||||
if len(lsps) == 0 {
|
||||
return NewTextResponse("\nLSP clients are still initializing. Workspace symbols lookup will be available once they're ready.\n"), nil
|
||||
}
|
||||
|
||||
output := getWorkspaceSymbols(ctx, params.Query, lsps)
|
||||
|
||||
return NewTextResponse(output), nil
|
||||
}
|
||||
|
||||
func getWorkspaceSymbols(ctx context.Context, query string, lsps map[string]*lsp.Client) string {
|
||||
var results []string
|
||||
|
||||
for lspName, client := range lsps {
|
||||
// Create workspace symbol params
|
||||
symbolParams := protocol.WorkspaceSymbolParams{
|
||||
Query: query,
|
||||
}
|
||||
|
||||
// Get workspace symbols
|
||||
symbolResult, err := client.Symbol(ctx, symbolParams)
|
||||
if err != nil {
|
||||
results = append(results, fmt.Sprintf("Error from %s: %s", lspName, err))
|
||||
continue
|
||||
}
|
||||
|
||||
// Process the symbol result
|
||||
symbols := processWorkspaceSymbolResult(symbolResult)
|
||||
if len(symbols) == 0 {
|
||||
results = append(results, fmt.Sprintf("No symbols found by %s for query '%s'", lspName, query))
|
||||
continue
|
||||
}
|
||||
|
||||
// Format the symbols
|
||||
results = append(results, fmt.Sprintf("Symbols found by %s for query '%s':", lspName, query))
|
||||
for _, symbol := range symbols {
|
||||
results = append(results, fmt.Sprintf(" %s (%s) - %s", symbol.Name, symbol.Kind, symbol.Location))
|
||||
}
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
return fmt.Sprintf("No symbols found matching query '%s'.", query)
|
||||
}
|
||||
|
||||
return strings.Join(results, "\n")
|
||||
}
|
||||
|
||||
func processWorkspaceSymbolResult(result protocol.Or_Result_workspace_symbol) []SymbolInfo {
|
||||
var symbols []SymbolInfo
|
||||
|
||||
switch v := result.Value.(type) {
|
||||
case []protocol.SymbolInformation:
|
||||
for _, si := range v {
|
||||
symbols = append(symbols, SymbolInfo{
|
||||
Name: si.Name,
|
||||
Kind: symbolKindToString(si.Kind),
|
||||
Location: formatWorkspaceLocation(si.Location),
|
||||
Children: nil,
|
||||
})
|
||||
}
|
||||
case []protocol.WorkspaceSymbol:
|
||||
for _, ws := range v {
|
||||
location := "Unknown location"
|
||||
if ws.Location.Value != nil {
|
||||
if loc, ok := ws.Location.Value.(protocol.Location); ok {
|
||||
location = formatWorkspaceLocation(loc)
|
||||
}
|
||||
}
|
||||
symbols = append(symbols, SymbolInfo{
|
||||
Name: ws.Name,
|
||||
Kind: symbolKindToString(ws.Kind),
|
||||
Location: location,
|
||||
Children: nil,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return symbols
|
||||
}
|
||||
|
||||
func formatWorkspaceLocation(location protocol.Location) string {
|
||||
path := strings.TrimPrefix(string(location.URI), "file://")
|
||||
return fmt.Sprintf("%s:%d:%d", path, location.Range.Start.Line+1, location.Range.Start.Character+1)
|
||||
}
|
||||
@@ -1,375 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/sst/opencode/internal/config"
|
||||
"github.com/sst/opencode/internal/diff"
|
||||
"github.com/sst/opencode/internal/history"
|
||||
"github.com/sst/opencode/internal/lsp"
|
||||
"github.com/sst/opencode/internal/permission"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
type PatchParams struct {
|
||||
PatchText string `json:"patch_text"`
|
||||
}
|
||||
|
||||
type PatchResponseMetadata struct {
|
||||
FilesChanged []string `json:"files_changed"`
|
||||
Additions int `json:"additions"`
|
||||
Removals int `json:"removals"`
|
||||
}
|
||||
|
||||
type patchTool struct {
|
||||
lspClients map[string]*lsp.Client
|
||||
permissions permission.Service
|
||||
files history.Service
|
||||
}
|
||||
|
||||
const (
|
||||
PatchToolName = "patch"
|
||||
patchDescription = `Applies a patch to multiple files in one operation. This tool is useful for making coordinated changes across multiple files.
|
||||
|
||||
The patch text must follow this format:
|
||||
*** Begin Patch
|
||||
*** Update File: /path/to/file
|
||||
@@ Context line (unique within the file)
|
||||
Line to keep
|
||||
-Line to remove
|
||||
+Line to add
|
||||
Line to keep
|
||||
*** Add File: /path/to/new/file
|
||||
+Content of the new file
|
||||
+More content
|
||||
*** Delete File: /path/to/file/to/delete
|
||||
*** End Patch
|
||||
|
||||
Before using this tool:
|
||||
1. Use the FileRead tool to understand the files' contents and context
|
||||
2. Verify all file paths are correct (use the LS tool)
|
||||
|
||||
CRITICAL REQUIREMENTS FOR USING THIS TOOL:
|
||||
|
||||
1. UNIQUENESS: Context lines MUST uniquely identify the specific sections you want to change
|
||||
2. PRECISION: All whitespace, indentation, and surrounding code must match exactly
|
||||
3. VALIDATION: Ensure edits result in idiomatic, correct code
|
||||
4. PATHS: Always use absolute file paths (starting with /)
|
||||
|
||||
The tool will apply all changes in a single atomic operation.`
|
||||
)
|
||||
|
||||
func NewPatchTool(lspClients map[string]*lsp.Client, permissions permission.Service, files history.Service) BaseTool {
|
||||
return &patchTool{
|
||||
lspClients: lspClients,
|
||||
permissions: permissions,
|
||||
files: files,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *patchTool) Info() ToolInfo {
|
||||
return ToolInfo{
|
||||
Name: PatchToolName,
|
||||
Description: patchDescription,
|
||||
Parameters: map[string]any{
|
||||
"patch_text": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The full patch text that describes all changes to be made",
|
||||
},
|
||||
},
|
||||
Required: []string{"patch_text"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *patchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
|
||||
var params PatchParams
|
||||
if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil {
|
||||
return NewTextErrorResponse("invalid parameters"), nil
|
||||
}
|
||||
|
||||
if params.PatchText == "" {
|
||||
return NewTextErrorResponse("patch_text is required"), nil
|
||||
}
|
||||
|
||||
// Identify all files needed for the patch and verify they've been read
|
||||
filesToRead := diff.IdentifyFilesNeeded(params.PatchText)
|
||||
for _, filePath := range filesToRead {
|
||||
absPath := filePath
|
||||
if !filepath.IsAbs(absPath) {
|
||||
wd := config.WorkingDirectory()
|
||||
absPath = filepath.Join(wd, absPath)
|
||||
}
|
||||
|
||||
if getLastReadTime(absPath).IsZero() {
|
||||
return NewTextErrorResponse(fmt.Sprintf("you must read the file %s before patching it. Use the FileRead tool first", filePath)), nil
|
||||
}
|
||||
|
||||
fileInfo, err := os.Stat(absPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return NewTextErrorResponse(fmt.Sprintf("file not found: %s", absPath)), nil
|
||||
}
|
||||
return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
|
||||
}
|
||||
|
||||
if fileInfo.IsDir() {
|
||||
return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", absPath)), nil
|
||||
}
|
||||
|
||||
modTime := fileInfo.ModTime()
|
||||
lastRead := getLastReadTime(absPath)
|
||||
if modTime.After(lastRead) {
|
||||
return NewTextErrorResponse(
|
||||
fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
|
||||
absPath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
|
||||
)), nil
|
||||
}
|
||||
}
|
||||
|
||||
// Check for new files to ensure they don't already exist
|
||||
filesToAdd := diff.IdentifyFilesAdded(params.PatchText)
|
||||
for _, filePath := range filesToAdd {
|
||||
absPath := filePath
|
||||
if !filepath.IsAbs(absPath) {
|
||||
wd := config.WorkingDirectory()
|
||||
absPath = filepath.Join(wd, absPath)
|
||||
}
|
||||
|
||||
_, err := os.Stat(absPath)
|
||||
if err == nil {
|
||||
return NewTextErrorResponse(fmt.Sprintf("file already exists and cannot be added: %s", absPath)), nil
|
||||
} else if !os.IsNotExist(err) {
|
||||
return ToolResponse{}, fmt.Errorf("failed to check file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Load all required files
|
||||
currentFiles := make(map[string]string)
|
||||
for _, filePath := range filesToRead {
|
||||
absPath := filePath
|
||||
if !filepath.IsAbs(absPath) {
|
||||
wd := config.WorkingDirectory()
|
||||
absPath = filepath.Join(wd, absPath)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
return ToolResponse{}, fmt.Errorf("failed to read file %s: %w", absPath, err)
|
||||
}
|
||||
currentFiles[filePath] = string(content)
|
||||
}
|
||||
|
||||
// Process the patch
|
||||
patch, fuzz, err := diff.TextToPatch(params.PatchText, currentFiles)
|
||||
if err != nil {
|
||||
return NewTextErrorResponse(fmt.Sprintf("failed to parse patch: %s", err)), nil
|
||||
}
|
||||
|
||||
if fuzz > 3 {
|
||||
return NewTextErrorResponse(fmt.Sprintf("patch contains fuzzy matches (fuzz level: %d). Please make your context lines more precise", fuzz)), nil
|
||||
}
|
||||
|
||||
// Convert patch to commit
|
||||
commit, err := diff.PatchToCommit(patch, currentFiles)
|
||||
if err != nil {
|
||||
return NewTextErrorResponse(fmt.Sprintf("failed to create commit from patch: %s", err)), nil
|
||||
}
|
||||
|
||||
// Get session ID and message ID
|
||||
sessionID, messageID := GetContextValues(ctx)
|
||||
if sessionID == "" || messageID == "" {
|
||||
return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a patch")
|
||||
}
|
||||
|
||||
// Request permission for all changes
|
||||
for path, change := range commit.Changes {
|
||||
switch change.Type {
|
||||
case diff.ActionAdd:
|
||||
dir := filepath.Dir(path)
|
||||
patchDiff, _, _ := diff.GenerateDiff("", *change.NewContent, path)
|
||||
p := p.permissions.Request(
|
||||
ctx,
|
||||
permission.CreatePermissionRequest{
|
||||
SessionID: sessionID,
|
||||
Path: dir,
|
||||
ToolName: PatchToolName,
|
||||
Action: "create",
|
||||
Description: fmt.Sprintf("Create file %s", path),
|
||||
Params: EditPermissionsParams{
|
||||
FilePath: path,
|
||||
Diff: patchDiff,
|
||||
},
|
||||
},
|
||||
)
|
||||
if !p {
|
||||
return ToolResponse{}, permission.ErrorPermissionDenied
|
||||
}
|
||||
case diff.ActionUpdate:
|
||||
currentContent := ""
|
||||
if change.OldContent != nil {
|
||||
currentContent = *change.OldContent
|
||||
}
|
||||
newContent := ""
|
||||
if change.NewContent != nil {
|
||||
newContent = *change.NewContent
|
||||
}
|
||||
patchDiff, _, _ := diff.GenerateDiff(currentContent, newContent, path)
|
||||
dir := filepath.Dir(path)
|
||||
p := p.permissions.Request(
|
||||
ctx,
|
||||
permission.CreatePermissionRequest{
|
||||
SessionID: sessionID,
|
||||
Path: dir,
|
||||
ToolName: PatchToolName,
|
||||
Action: "update",
|
||||
Description: fmt.Sprintf("Update file %s", path),
|
||||
Params: EditPermissionsParams{
|
||||
FilePath: path,
|
||||
Diff: patchDiff,
|
||||
},
|
||||
},
|
||||
)
|
||||
if !p {
|
||||
return ToolResponse{}, permission.ErrorPermissionDenied
|
||||
}
|
||||
case diff.ActionDelete:
|
||||
dir := filepath.Dir(path)
|
||||
patchDiff, _, _ := diff.GenerateDiff(*change.OldContent, "", path)
|
||||
p := p.permissions.Request(
|
||||
ctx,
|
||||
permission.CreatePermissionRequest{
|
||||
SessionID: sessionID,
|
||||
Path: dir,
|
||||
ToolName: PatchToolName,
|
||||
Action: "delete",
|
||||
Description: fmt.Sprintf("Delete file %s", path),
|
||||
Params: EditPermissionsParams{
|
||||
FilePath: path,
|
||||
Diff: patchDiff,
|
||||
},
|
||||
},
|
||||
)
|
||||
if !p {
|
||||
return ToolResponse{}, permission.ErrorPermissionDenied
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply the changes to the filesystem
|
||||
err = diff.ApplyCommit(commit, func(path string, content string) error {
|
||||
absPath := path
|
||||
if !filepath.IsAbs(absPath) {
|
||||
wd := config.WorkingDirectory()
|
||||
absPath = filepath.Join(wd, absPath)
|
||||
}
|
||||
|
||||
// Create parent directories if needed
|
||||
dir := filepath.Dir(absPath)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create parent directories for %s: %w", absPath, err)
|
||||
}
|
||||
|
||||
return os.WriteFile(absPath, []byte(content), 0o644)
|
||||
}, func(path string) error {
|
||||
absPath := path
|
||||
if !filepath.IsAbs(absPath) {
|
||||
wd := config.WorkingDirectory()
|
||||
absPath = filepath.Join(wd, absPath)
|
||||
}
|
||||
return os.Remove(absPath)
|
||||
})
|
||||
if err != nil {
|
||||
return NewTextErrorResponse(fmt.Sprintf("failed to apply patch: %s", err)), nil
|
||||
}
|
||||
|
||||
// Update file history for all modified files
|
||||
changedFiles := []string{}
|
||||
totalAdditions := 0
|
||||
totalRemovals := 0
|
||||
|
||||
for path, change := range commit.Changes {
|
||||
absPath := path
|
||||
if !filepath.IsAbs(absPath) {
|
||||
wd := config.WorkingDirectory()
|
||||
absPath = filepath.Join(wd, absPath)
|
||||
}
|
||||
changedFiles = append(changedFiles, absPath)
|
||||
|
||||
oldContent := ""
|
||||
if change.OldContent != nil {
|
||||
oldContent = *change.OldContent
|
||||
}
|
||||
|
||||
newContent := ""
|
||||
if change.NewContent != nil {
|
||||
newContent = *change.NewContent
|
||||
}
|
||||
|
||||
// Calculate diff statistics
|
||||
_, additions, removals := diff.GenerateDiff(oldContent, newContent, path)
|
||||
totalAdditions += additions
|
||||
totalRemovals += removals
|
||||
|
||||
// Update history
|
||||
file, err := p.files.GetLatestByPathAndSession(ctx, absPath, sessionID)
|
||||
if err != nil && change.Type != diff.ActionAdd {
|
||||
// If not adding a file, create history entry for existing file
|
||||
_, err = p.files.Create(ctx, sessionID, absPath, oldContent)
|
||||
if err != nil {
|
||||
slog.Debug("Error creating file history", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err == nil && change.Type != diff.ActionAdd && file.Content != oldContent {
|
||||
// User manually changed content, store intermediate version
|
||||
_, err = p.files.CreateVersion(ctx, sessionID, absPath, oldContent)
|
||||
if err != nil {
|
||||
slog.Debug("Error creating file history version", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Store new version
|
||||
if change.Type == diff.ActionDelete {
|
||||
_, err = p.files.CreateVersion(ctx, sessionID, absPath, "")
|
||||
} else {
|
||||
_, err = p.files.CreateVersion(ctx, sessionID, absPath, newContent)
|
||||
}
|
||||
if err != nil {
|
||||
slog.Debug("Error creating file history version", "error", err)
|
||||
}
|
||||
|
||||
// Record file operations
|
||||
recordFileWrite(absPath)
|
||||
recordFileRead(absPath)
|
||||
}
|
||||
|
||||
// Run LSP diagnostics on all changed files
|
||||
for _, filePath := range changedFiles {
|
||||
waitForLspDiagnostics(ctx, filePath, p.lspClients)
|
||||
}
|
||||
|
||||
result := fmt.Sprintf("Patch applied successfully. %d files changed, %d additions, %d removals",
|
||||
len(changedFiles), totalAdditions, totalRemovals)
|
||||
|
||||
diagnosticsText := ""
|
||||
for _, filePath := range changedFiles {
|
||||
diagnosticsText += getDiagnostics(filePath, p.lspClients)
|
||||
}
|
||||
|
||||
if diagnosticsText != "" {
|
||||
result += "\n\nDiagnostics:\n" + diagnosticsText
|
||||
}
|
||||
|
||||
return WithResponseMetadata(
|
||||
NewTextResponse(result),
|
||||
PatchResponseMetadata{
|
||||
FilesChanged: changedFiles,
|
||||
Additions: totalAdditions,
|
||||
Removals: totalRemovals,
|
||||
}), nil
|
||||
}
|
||||
@@ -1,308 +0,0 @@
|
||||
package shell
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/sst/opencode/internal/status"
|
||||
)
|
||||
|
||||
type PersistentShell struct {
|
||||
cmd *exec.Cmd
|
||||
stdin *os.File
|
||||
isAlive bool
|
||||
cwd string
|
||||
mu sync.Mutex
|
||||
commandQueue chan *commandExecution
|
||||
}
|
||||
|
||||
type commandExecution struct {
|
||||
command string
|
||||
timeout time.Duration
|
||||
resultChan chan commandResult
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
type commandResult struct {
|
||||
stdout string
|
||||
stderr string
|
||||
exitCode int
|
||||
interrupted bool
|
||||
err error
|
||||
}
|
||||
|
||||
var (
|
||||
shellInstance *PersistentShell
|
||||
shellInstanceOnce sync.Once
|
||||
)
|
||||
|
||||
func GetPersistentShell(workingDir string) *PersistentShell {
|
||||
shellInstanceOnce.Do(func() {
|
||||
shellInstance = newPersistentShell(workingDir)
|
||||
})
|
||||
|
||||
if shellInstance == nil {
|
||||
shellInstance = newPersistentShell(workingDir)
|
||||
} else if !shellInstance.isAlive {
|
||||
shellInstance = newPersistentShell(shellInstance.cwd)
|
||||
}
|
||||
|
||||
return shellInstance
|
||||
}
|
||||
|
||||
func newPersistentShell(cwd string) *PersistentShell {
|
||||
shellPath := os.Getenv("SHELL")
|
||||
if shellPath == "" {
|
||||
shellPath = "/bin/bash"
|
||||
}
|
||||
|
||||
cmd := exec.Command(shellPath, "-l")
|
||||
cmd.Dir = cwd
|
||||
|
||||
stdinPipe, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd.Env = append(os.Environ(), "GIT_EDITOR=true")
|
||||
|
||||
err = cmd.Start()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
shell := &PersistentShell{
|
||||
cmd: cmd,
|
||||
stdin: stdinPipe.(*os.File),
|
||||
isAlive: true,
|
||||
cwd: cwd,
|
||||
commandQueue: make(chan *commandExecution, 10),
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
fmt.Fprintf(os.Stderr, "Panic in shell command processor: %v\n", r)
|
||||
shell.isAlive = false
|
||||
close(shell.commandQueue)
|
||||
}
|
||||
}()
|
||||
shell.processCommands()
|
||||
}()
|
||||
|
||||
go func() {
|
||||
err := cmd.Wait()
|
||||
if err != nil {
|
||||
status.Error(fmt.Sprintf("Shell process exited with error: %v", err))
|
||||
}
|
||||
shell.isAlive = false
|
||||
close(shell.commandQueue)
|
||||
}()
|
||||
|
||||
return shell
|
||||
}
|
||||
|
||||
func (s *PersistentShell) processCommands() {
|
||||
for cmd := range s.commandQueue {
|
||||
result := s.execCommand(cmd.command, cmd.timeout, cmd.ctx)
|
||||
cmd.resultChan <- result
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PersistentShell) execCommand(command string, timeout time.Duration, ctx context.Context) commandResult {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if !s.isAlive {
|
||||
return commandResult{
|
||||
stderr: "Shell is not alive",
|
||||
exitCode: 1,
|
||||
err: errors.New("shell is not alive"),
|
||||
}
|
||||
}
|
||||
|
||||
tempDir := os.TempDir()
|
||||
stdoutFile := filepath.Join(tempDir, fmt.Sprintf("opencode-stdout-%d", time.Now().UnixNano()))
|
||||
stderrFile := filepath.Join(tempDir, fmt.Sprintf("opencode-stderr-%d", time.Now().UnixNano()))
|
||||
statusFile := filepath.Join(tempDir, fmt.Sprintf("opencode-status-%d", time.Now().UnixNano()))
|
||||
cwdFile := filepath.Join(tempDir, fmt.Sprintf("opencode-cwd-%d", time.Now().UnixNano()))
|
||||
|
||||
defer func() {
|
||||
os.Remove(stdoutFile)
|
||||
os.Remove(stderrFile)
|
||||
os.Remove(statusFile)
|
||||
os.Remove(cwdFile)
|
||||
}()
|
||||
|
||||
fullCommand := fmt.Sprintf(`
|
||||
eval %s < /dev/null > %s 2> %s
|
||||
EXEC_EXIT_CODE=$?
|
||||
pwd > %s
|
||||
echo $EXEC_EXIT_CODE > %s
|
||||
`,
|
||||
shellQuote(command),
|
||||
shellQuote(stdoutFile),
|
||||
shellQuote(stderrFile),
|
||||
shellQuote(cwdFile),
|
||||
shellQuote(statusFile),
|
||||
)
|
||||
|
||||
_, err := s.stdin.Write([]byte(fullCommand + "\n"))
|
||||
if err != nil {
|
||||
return commandResult{
|
||||
stderr: fmt.Sprintf("Failed to write command to shell: %v", err),
|
||||
exitCode: 1,
|
||||
err: err,
|
||||
}
|
||||
}
|
||||
|
||||
interrupted := false
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
done := make(chan bool)
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
s.killChildren()
|
||||
interrupted = true
|
||||
done <- true
|
||||
return
|
||||
|
||||
case <-time.After(10 * time.Millisecond):
|
||||
if fileExists(statusFile) && fileSize(statusFile) > 0 {
|
||||
done <- true
|
||||
return
|
||||
}
|
||||
|
||||
if timeout > 0 {
|
||||
elapsed := time.Since(startTime)
|
||||
if elapsed > timeout {
|
||||
s.killChildren()
|
||||
interrupted = true
|
||||
done <- true
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
<-done
|
||||
|
||||
stdout := readFileOrEmpty(stdoutFile)
|
||||
stderr := readFileOrEmpty(stderrFile)
|
||||
exitCodeStr := readFileOrEmpty(statusFile)
|
||||
newCwd := readFileOrEmpty(cwdFile)
|
||||
|
||||
exitCode := 0
|
||||
if exitCodeStr != "" {
|
||||
fmt.Sscanf(exitCodeStr, "%d", &exitCode)
|
||||
} else if interrupted {
|
||||
exitCode = 143
|
||||
stderr += "\nCommand execution timed out or was interrupted"
|
||||
}
|
||||
|
||||
if newCwd != "" {
|
||||
s.cwd = strings.TrimSpace(newCwd)
|
||||
}
|
||||
|
||||
return commandResult{
|
||||
stdout: stdout,
|
||||
stderr: stderr,
|
||||
exitCode: exitCode,
|
||||
interrupted: interrupted,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PersistentShell) killChildren() {
|
||||
if s.cmd == nil || s.cmd.Process == nil {
|
||||
return
|
||||
}
|
||||
|
||||
pgrepCmd := exec.Command("pgrep", "-P", fmt.Sprintf("%d", s.cmd.Process.Pid))
|
||||
output, err := pgrepCmd.Output()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for pidStr := range strings.SplitSeq(string(output), "\n") {
|
||||
if pidStr = strings.TrimSpace(pidStr); pidStr != "" {
|
||||
var pid int
|
||||
fmt.Sscanf(pidStr, "%d", &pid)
|
||||
if pid > 0 {
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err == nil {
|
||||
proc.Signal(syscall.SIGTERM)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PersistentShell) Exec(ctx context.Context, command string, timeoutMs int) (string, string, int, bool, error) {
|
||||
if !s.isAlive {
|
||||
return "", "Shell is not alive", 1, false, errors.New("shell is not alive")
|
||||
}
|
||||
|
||||
timeout := time.Duration(timeoutMs) * time.Millisecond
|
||||
|
||||
resultChan := make(chan commandResult)
|
||||
s.commandQueue <- &commandExecution{
|
||||
command: command,
|
||||
timeout: timeout,
|
||||
resultChan: resultChan,
|
||||
ctx: ctx,
|
||||
}
|
||||
|
||||
result := <-resultChan
|
||||
return result.stdout, result.stderr, result.exitCode, result.interrupted, result.err
|
||||
}
|
||||
|
||||
func (s *PersistentShell) Close() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if !s.isAlive {
|
||||
return
|
||||
}
|
||||
|
||||
s.stdin.Write([]byte("exit\n"))
|
||||
|
||||
s.cmd.Process.Kill()
|
||||
s.isAlive = false
|
||||
}
|
||||
|
||||
func shellQuote(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
|
||||
}
|
||||
|
||||
func readFileOrEmpty(path string) string {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(content)
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func fileSize(path string) int64 {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return info.Size()
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
type ToolInfo struct {
|
||||
Name string
|
||||
Description string
|
||||
Parameters map[string]any
|
||||
Required []string
|
||||
}
|
||||
|
||||
type toolResponseType string
|
||||
|
||||
type (
|
||||
sessionIDContextKey string
|
||||
messageIDContextKey string
|
||||
)
|
||||
|
||||
const (
|
||||
ToolResponseTypeText toolResponseType = "text"
|
||||
ToolResponseTypeImage toolResponseType = "image"
|
||||
|
||||
SessionIDContextKey sessionIDContextKey = "session_id"
|
||||
MessageIDContextKey messageIDContextKey = "message_id"
|
||||
)
|
||||
|
||||
type ToolResponse struct {
|
||||
Type toolResponseType `json:"type"`
|
||||
Content string `json:"content"`
|
||||
Metadata string `json:"metadata,omitempty"`
|
||||
IsError bool `json:"is_error"`
|
||||
}
|
||||
|
||||
func NewTextResponse(content string) ToolResponse {
|
||||
return ToolResponse{
|
||||
Type: ToolResponseTypeText,
|
||||
Content: content,
|
||||
}
|
||||
}
|
||||
|
||||
func WithResponseMetadata(response ToolResponse, metadata any) ToolResponse {
|
||||
if metadata != nil {
|
||||
metadataBytes, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
return response
|
||||
}
|
||||
response.Metadata = string(metadataBytes)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
func NewTextErrorResponse(content string) ToolResponse {
|
||||
return ToolResponse{
|
||||
Type: ToolResponseTypeText,
|
||||
Content: content,
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
|
||||
type ToolCall struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Input string `json:"input"`
|
||||
}
|
||||
|
||||
type BaseTool interface {
|
||||
Info() ToolInfo
|
||||
Run(ctx context.Context, params ToolCall) (ToolResponse, error)
|
||||
}
|
||||
|
||||
func GetContextValues(ctx context.Context) (string, string) {
|
||||
sessionID := ctx.Value(SessionIDContextKey)
|
||||
messageID := ctx.Value(MessageIDContextKey)
|
||||
if sessionID == nil {
|
||||
return "", ""
|
||||
}
|
||||
if messageID == nil {
|
||||
return sessionID.(string), ""
|
||||
}
|
||||
return sessionID.(string), messageID.(string)
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/sst/opencode/internal/config"
|
||||
"github.com/sst/opencode/internal/lsp"
|
||||
)
|
||||
|
||||
type ViewParams struct {
|
||||
FilePath string `json:"file_path"`
|
||||
Offset int `json:"offset"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
type viewTool struct {
|
||||
lspClients map[string]*lsp.Client
|
||||
}
|
||||
|
||||
type ViewResponseMetadata struct {
|
||||
FilePath string `json:"file_path"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
const (
|
||||
ViewToolName = "view"
|
||||
MaxReadSize = 250 * 1024
|
||||
DefaultReadLimit = 2000
|
||||
MaxLineLength = 2000
|
||||
viewDescription = `File viewing tool that reads and displays the contents of files with line numbers, allowing you to examine code, logs, or text data.
|
||||
|
||||
WHEN TO USE THIS TOOL:
|
||||
- Use when you need to read the contents of a specific file
|
||||
- Helpful for examining source code, configuration files, or log files
|
||||
- Perfect for looking at text-based file formats
|
||||
|
||||
HOW TO USE:
|
||||
- Provide the path to the file you want to view
|
||||
- Optionally specify an offset to start reading from a specific line
|
||||
- Optionally specify a limit to control how many lines are read
|
||||
|
||||
FEATURES:
|
||||
- Displays file contents with line numbers for easy reference
|
||||
- Can read from any position in a file using the offset parameter
|
||||
- Handles large files by limiting the number of lines read
|
||||
- Automatically truncates very long lines for better display
|
||||
- Suggests similar file names when the requested file isn't found
|
||||
|
||||
LIMITATIONS:
|
||||
- Maximum file size is 250KB
|
||||
- Default reading limit is 2000 lines
|
||||
- Lines longer than 2000 characters are truncated
|
||||
- Cannot display binary files or images
|
||||
- Images can be identified but not displayed
|
||||
|
||||
TIPS:
|
||||
- Use with Glob tool to first find files you want to view
|
||||
- For code exploration, first use Grep to find relevant files, then View to examine them
|
||||
- When viewing large files, use the offset parameter to read specific sections`
|
||||
)
|
||||
|
||||
func NewViewTool(lspClients map[string]*lsp.Client) BaseTool {
|
||||
return &viewTool{
|
||||
lspClients,
|
||||
}
|
||||
}
|
||||
|
||||
func (v *viewTool) Info() ToolInfo {
|
||||
return ToolInfo{
|
||||
Name: ViewToolName,
|
||||
Description: viewDescription,
|
||||
Parameters: map[string]any{
|
||||
"file_path": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The path to the file to read",
|
||||
},
|
||||
"offset": map[string]any{
|
||||
"type": "integer",
|
||||
"description": "The line number to start reading from (0-based)",
|
||||
},
|
||||
"limit": map[string]any{
|
||||
"type": "integer",
|
||||
"description": "The number of lines to read (defaults to 2000)",
|
||||
},
|
||||
},
|
||||
Required: []string{"file_path"},
|
||||
}
|
||||
}
|
||||
|
||||
// Run implements Tool.
|
||||
func (v *viewTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
|
||||
var params ViewParams
|
||||
if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil {
|
||||
return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
|
||||
}
|
||||
|
||||
if params.FilePath == "" {
|
||||
return NewTextErrorResponse("file_path is required"), nil
|
||||
}
|
||||
|
||||
// Handle relative paths
|
||||
filePath := params.FilePath
|
||||
if !filepath.IsAbs(filePath) {
|
||||
filePath = filepath.Join(config.WorkingDirectory(), filePath)
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
fileInfo, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// Try to offer suggestions for similarly named files
|
||||
dir := filepath.Dir(filePath)
|
||||
base := filepath.Base(filePath)
|
||||
|
||||
dirEntries, dirErr := os.ReadDir(dir)
|
||||
if dirErr == nil {
|
||||
var suggestions []string
|
||||
for _, entry := range dirEntries {
|
||||
if strings.Contains(strings.ToLower(entry.Name()), strings.ToLower(base)) ||
|
||||
strings.Contains(strings.ToLower(base), strings.ToLower(entry.Name())) {
|
||||
suggestions = append(suggestions, filepath.Join(dir, entry.Name()))
|
||||
if len(suggestions) >= 3 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(suggestions) > 0 {
|
||||
return NewTextErrorResponse(fmt.Sprintf("File not found: %s\n\nDid you mean one of these?\n%s",
|
||||
filePath, strings.Join(suggestions, "\n"))), nil
|
||||
}
|
||||
}
|
||||
|
||||
return NewTextErrorResponse(fmt.Sprintf("File not found: %s", filePath)), nil
|
||||
}
|
||||
return ToolResponse{}, fmt.Errorf("error accessing file: %w", err)
|
||||
}
|
||||
|
||||
// Check if it's a directory
|
||||
if fileInfo.IsDir() {
|
||||
return NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
|
||||
}
|
||||
|
||||
// Check file size
|
||||
if fileInfo.Size() > MaxReadSize {
|
||||
return NewTextErrorResponse(fmt.Sprintf("File is too large (%d bytes). Maximum size is %d bytes",
|
||||
fileInfo.Size(), MaxReadSize)), nil
|
||||
}
|
||||
|
||||
// Set default limit if not provided
|
||||
if params.Limit <= 0 {
|
||||
params.Limit = DefaultReadLimit
|
||||
}
|
||||
|
||||
// Check if it's an image file
|
||||
isImage, imageType := isImageFile(filePath)
|
||||
// TODO: handle images
|
||||
if isImage {
|
||||
return NewTextErrorResponse(fmt.Sprintf("This is an image file of type: %s\nUse a different tool to process images", imageType)), nil
|
||||
}
|
||||
|
||||
// Read the file content
|
||||
content, lineCount, err := readTextFile(filePath, params.Offset, params.Limit)
|
||||
if err != nil {
|
||||
return ToolResponse{}, fmt.Errorf("error reading file: %w", err)
|
||||
}
|
||||
|
||||
notifyLspOpenFile(ctx, filePath, v.lspClients)
|
||||
output := "<file>\n"
|
||||
// Format the output with line numbers
|
||||
output += addLineNumbers(content, params.Offset+1)
|
||||
|
||||
// Add a note if the content was truncated
|
||||
if lineCount > params.Offset+len(strings.Split(content, "\n")) {
|
||||
output += fmt.Sprintf("\n\n(File has more lines. Use 'offset' parameter to read beyond line %d)",
|
||||
params.Offset+len(strings.Split(content, "\n")))
|
||||
}
|
||||
output += "\n</file>\n"
|
||||
output += getDiagnostics(filePath, v.lspClients)
|
||||
recordFileRead(filePath)
|
||||
return WithResponseMetadata(
|
||||
NewTextResponse(output),
|
||||
ViewResponseMetadata{
|
||||
FilePath: filePath,
|
||||
Content: content,
|
||||
},
|
||||
), nil
|
||||
}
|
||||
|
||||
func addLineNumbers(content string, startLine int) string {
|
||||
if content == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
lines := strings.Split(content, "\n")
|
||||
|
||||
var result []string
|
||||
for i, line := range lines {
|
||||
line = strings.TrimSuffix(line, "\r")
|
||||
|
||||
lineNum := i + startLine
|
||||
numStr := fmt.Sprintf("%d", lineNum)
|
||||
|
||||
if len(numStr) >= 6 {
|
||||
result = append(result, fmt.Sprintf("%s|%s", numStr, line))
|
||||
} else {
|
||||
paddedNum := fmt.Sprintf("%6s", numStr)
|
||||
result = append(result, fmt.Sprintf("%s|%s", paddedNum, line))
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(result, "\n")
|
||||
}
|
||||
|
||||
func readTextFile(filePath string, offset, limit int) (string, int, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
lineCount := 0
|
||||
|
||||
scanner := NewLineScanner(file)
|
||||
if offset > 0 {
|
||||
for lineCount < offset && scanner.Scan() {
|
||||
lineCount++
|
||||
}
|
||||
if err = scanner.Err(); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
}
|
||||
|
||||
if offset == 0 {
|
||||
_, err = file.Seek(0, io.SeekStart)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
}
|
||||
|
||||
var lines []string
|
||||
lineCount = offset
|
||||
|
||||
for scanner.Scan() && len(lines) < limit {
|
||||
lineCount++
|
||||
lineText := scanner.Text()
|
||||
if len(lineText) > MaxLineLength {
|
||||
lineText = lineText[:MaxLineLength] + "..."
|
||||
}
|
||||
lines = append(lines, lineText)
|
||||
}
|
||||
|
||||
// Continue scanning to get total line count
|
||||
for scanner.Scan() {
|
||||
lineCount++
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n"), lineCount, nil
|
||||
}
|
||||
|
||||
func isImageFile(filePath string) (bool, string) {
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
switch ext {
|
||||
case ".jpg", ".jpeg":
|
||||
return true, "JPEG"
|
||||
case ".png":
|
||||
return true, "PNG"
|
||||
case ".gif":
|
||||
return true, "GIF"
|
||||
case ".bmp":
|
||||
return true, "BMP"
|
||||
case ".svg":
|
||||
return true, "SVG"
|
||||
case ".webp":
|
||||
return true, "WebP"
|
||||
default:
|
||||
return false, ""
|
||||
}
|
||||
}
|
||||
|
||||
type LineScanner struct {
|
||||
scanner *bufio.Scanner
|
||||
}
|
||||
|
||||
func NewLineScanner(r io.Reader) *LineScanner {
|
||||
return &LineScanner{
|
||||
scanner: bufio.NewScanner(r),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *LineScanner) Scan() bool {
|
||||
return s.scanner.Scan()
|
||||
}
|
||||
|
||||
func (s *LineScanner) Text() string {
|
||||
return s.scanner.Text()
|
||||
}
|
||||
|
||||
func (s *LineScanner) Err() error {
|
||||
return s.scanner.Err()
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sst/opencode/internal/config"
|
||||
"github.com/sst/opencode/internal/diff"
|
||||
"github.com/sst/opencode/internal/history"
|
||||
"github.com/sst/opencode/internal/lsp"
|
||||
"github.com/sst/opencode/internal/permission"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
type WriteParams struct {
|
||||
FilePath string `json:"file_path"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type WritePermissionsParams struct {
|
||||
FilePath string `json:"file_path"`
|
||||
Diff string `json:"diff"`
|
||||
}
|
||||
|
||||
type writeTool struct {
|
||||
lspClients map[string]*lsp.Client
|
||||
permissions permission.Service
|
||||
files history.Service
|
||||
}
|
||||
|
||||
type WriteResponseMetadata struct {
|
||||
Diff string `json:"diff"`
|
||||
Additions int `json:"additions"`
|
||||
Removals int `json:"removals"`
|
||||
}
|
||||
|
||||
const (
|
||||
WriteToolName = "write"
|
||||
writeDescription = `File writing tool that creates or updates files in the filesystem, allowing you to save or modify text content.
|
||||
|
||||
WHEN TO USE THIS TOOL:
|
||||
- Use when you need to create a new file
|
||||
- Helpful for updating existing files with modified content
|
||||
- Perfect for saving generated code, configurations, or text data
|
||||
|
||||
HOW TO USE:
|
||||
- Provide the path to the file you want to write
|
||||
- Include the content to be written to the file
|
||||
- The tool will create any necessary parent directories
|
||||
|
||||
FEATURES:
|
||||
- Can create new files or overwrite existing ones
|
||||
- Creates parent directories automatically if they don't exist
|
||||
- Checks if the file has been modified since last read for safety
|
||||
- Avoids unnecessary writes when content hasn't changed
|
||||
|
||||
LIMITATIONS:
|
||||
- You should read a file before writing to it to avoid conflicts
|
||||
- Cannot append to files (rewrites the entire file)
|
||||
|
||||
|
||||
TIPS:
|
||||
- Use the View tool first to examine existing files before modifying them
|
||||
- Use the LS tool to verify the correct location when creating new files
|
||||
- Combine with Glob and Grep tools to find and modify multiple files
|
||||
- Always include descriptive comments when making changes to existing code`
|
||||
)
|
||||
|
||||
func NewWriteTool(lspClients map[string]*lsp.Client, permissions permission.Service, files history.Service) BaseTool {
|
||||
return &writeTool{
|
||||
lspClients: lspClients,
|
||||
permissions: permissions,
|
||||
files: files,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *writeTool) Info() ToolInfo {
|
||||
return ToolInfo{
|
||||
Name: WriteToolName,
|
||||
Description: writeDescription,
|
||||
Parameters: map[string]any{
|
||||
"file_path": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The path to the file to write",
|
||||
},
|
||||
"content": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The content to write to the file",
|
||||
},
|
||||
},
|
||||
Required: []string{"file_path", "content"},
|
||||
}
|
||||
}
|
||||
|
||||
func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
|
||||
var params WriteParams
|
||||
if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil {
|
||||
return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
|
||||
}
|
||||
|
||||
if params.FilePath == "" {
|
||||
return NewTextErrorResponse("file_path is required"), nil
|
||||
}
|
||||
|
||||
if params.Content == "" {
|
||||
return NewTextErrorResponse("content is required"), nil
|
||||
}
|
||||
|
||||
filePath := params.FilePath
|
||||
if !filepath.IsAbs(filePath) {
|
||||
filePath = filepath.Join(config.WorkingDirectory(), filePath)
|
||||
}
|
||||
|
||||
fileInfo, err := os.Stat(filePath)
|
||||
if err == nil {
|
||||
if fileInfo.IsDir() {
|
||||
return NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
|
||||
}
|
||||
|
||||
modTime := fileInfo.ModTime()
|
||||
lastRead := getLastReadTime(filePath)
|
||||
if modTime.After(lastRead) {
|
||||
return NewTextErrorResponse(fmt.Sprintf("File %s has been modified since it was last read.\nLast modification: %s\nLast read: %s\n\nPlease read the file again before modifying it.",
|
||||
filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))), nil
|
||||
}
|
||||
|
||||
oldContent, readErr := os.ReadFile(filePath)
|
||||
if readErr == nil && string(oldContent) == params.Content {
|
||||
return NewTextErrorResponse(fmt.Sprintf("File %s already contains the exact content. No changes made.", filePath)), nil
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return ToolResponse{}, fmt.Errorf("error checking file: %w", err)
|
||||
}
|
||||
|
||||
dir := filepath.Dir(filePath)
|
||||
if err = os.MkdirAll(dir, 0o755); err != nil {
|
||||
return ToolResponse{}, fmt.Errorf("error creating directory: %w", err)
|
||||
}
|
||||
|
||||
oldContent := ""
|
||||
if fileInfo != nil && !fileInfo.IsDir() {
|
||||
oldBytes, readErr := os.ReadFile(filePath)
|
||||
if readErr == nil {
|
||||
oldContent = string(oldBytes)
|
||||
}
|
||||
}
|
||||
|
||||
sessionID, messageID := GetContextValues(ctx)
|
||||
if sessionID == "" || messageID == "" {
|
||||
return ToolResponse{}, fmt.Errorf("session_id and message_id are required")
|
||||
}
|
||||
|
||||
diff, additions, removals := diff.GenerateDiff(
|
||||
oldContent,
|
||||
params.Content,
|
||||
filePath,
|
||||
)
|
||||
|
||||
rootDir := config.WorkingDirectory()
|
||||
permissionPath := filepath.Dir(filePath)
|
||||
if strings.HasPrefix(filePath, rootDir) {
|
||||
permissionPath = rootDir
|
||||
}
|
||||
p := w.permissions.Request(
|
||||
ctx,
|
||||
permission.CreatePermissionRequest{
|
||||
SessionID: sessionID,
|
||||
Path: permissionPath,
|
||||
ToolName: WriteToolName,
|
||||
Action: "write",
|
||||
Description: fmt.Sprintf("Create file %s", filePath),
|
||||
Params: WritePermissionsParams{
|
||||
FilePath: filePath,
|
||||
Diff: diff,
|
||||
},
|
||||
},
|
||||
)
|
||||
if !p {
|
||||
return ToolResponse{}, permission.ErrorPermissionDenied
|
||||
}
|
||||
|
||||
err = os.WriteFile(filePath, []byte(params.Content), 0o644)
|
||||
if err != nil {
|
||||
return ToolResponse{}, fmt.Errorf("error writing file: %w", err)
|
||||
}
|
||||
|
||||
// Check if file exists in history
|
||||
file, err := w.files.GetLatestByPathAndSession(ctx, filePath, sessionID)
|
||||
if err != nil {
|
||||
_, err = w.files.Create(ctx, sessionID, filePath, oldContent)
|
||||
if err != nil {
|
||||
// Log error but don't fail the operation
|
||||
return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
|
||||
}
|
||||
}
|
||||
if file.Content != oldContent {
|
||||
// User Manually changed the content store an intermediate version
|
||||
_, err = w.files.CreateVersion(ctx, sessionID, filePath, oldContent)
|
||||
if err != nil {
|
||||
slog.Debug("Error creating file history version", "error", err)
|
||||
}
|
||||
}
|
||||
// Store the new version
|
||||
_, err = w.files.CreateVersion(ctx, sessionID, filePath, params.Content)
|
||||
if err != nil {
|
||||
slog.Debug("Error creating file history version", "error", err)
|
||||
}
|
||||
|
||||
recordFileWrite(filePath)
|
||||
recordFileRead(filePath)
|
||||
waitForLspDiagnostics(ctx, filePath, w.lspClients)
|
||||
|
||||
result := fmt.Sprintf("File successfully written: %s", filePath)
|
||||
result = fmt.Sprintf("<result>\n%s\n</result>", result)
|
||||
result += getDiagnostics(filePath, w.lspClients)
|
||||
return WithResponseMetadata(NewTextResponse(result),
|
||||
WriteResponseMetadata{
|
||||
Diff: diff,
|
||||
Additions: additions,
|
||||
Removals: removals,
|
||||
},
|
||||
), nil
|
||||
}
|
||||
@@ -1,292 +0,0 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-logfmt/logfmt"
|
||||
"github.com/google/uuid"
|
||||
"github.com/sst/opencode/internal/db"
|
||||
"github.com/sst/opencode/internal/pubsub"
|
||||
)
|
||||
|
||||
type Log struct {
|
||||
ID string
|
||||
SessionID string
|
||||
Timestamp time.Time
|
||||
Level string
|
||||
Message string
|
||||
Attributes map[string]string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
const (
|
||||
EventLogCreated pubsub.EventType = "log_created"
|
||||
)
|
||||
|
||||
type Service interface {
|
||||
pubsub.Subscriber[Log]
|
||||
|
||||
Create(ctx context.Context, timestamp time.Time, level, message string, attributes map[string]string, sessionID string) error
|
||||
ListBySession(ctx context.Context, sessionID string) ([]Log, error)
|
||||
ListAll(ctx context.Context, limit int) ([]Log, error)
|
||||
}
|
||||
|
||||
type service struct {
|
||||
db *db.Queries
|
||||
broker *pubsub.Broker[Log]
|
||||
}
|
||||
|
||||
var globalLoggingService *service
|
||||
|
||||
func InitService(dbConn *sql.DB) error {
|
||||
if globalLoggingService != nil {
|
||||
return fmt.Errorf("logging service already initialized")
|
||||
}
|
||||
queries := db.New(dbConn)
|
||||
broker := pubsub.NewBroker[Log]()
|
||||
|
||||
globalLoggingService = &service{
|
||||
db: queries,
|
||||
broker: broker,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetService() Service {
|
||||
if globalLoggingService == nil {
|
||||
panic("logging service not initialized. Call logging.InitService() first.")
|
||||
}
|
||||
return globalLoggingService
|
||||
}
|
||||
|
||||
func (s *service) Create(ctx context.Context, timestamp time.Time, level, message string, attributes map[string]string, sessionID string) error {
|
||||
if level == "" {
|
||||
level = "info"
|
||||
}
|
||||
|
||||
var attributesJSON sql.NullString
|
||||
if len(attributes) > 0 {
|
||||
attributesBytes, err := json.Marshal(attributes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal log attributes: %w", err)
|
||||
}
|
||||
attributesJSON = sql.NullString{String: string(attributesBytes), Valid: true}
|
||||
}
|
||||
|
||||
dbLog, err := s.db.CreateLog(ctx, db.CreateLogParams{
|
||||
ID: uuid.New().String(),
|
||||
SessionID: sql.NullString{String: sessionID, Valid: sessionID != ""},
|
||||
Timestamp: timestamp.UTC().Format(time.RFC3339Nano),
|
||||
Level: level,
|
||||
Message: message,
|
||||
Attributes: attributesJSON,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("db.CreateLog: %w", err)
|
||||
}
|
||||
|
||||
log := s.fromDBItem(dbLog)
|
||||
s.broker.Publish(EventLogCreated, log)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *service) ListBySession(ctx context.Context, sessionID string) ([]Log, error) {
|
||||
dbLogs, err := s.db.ListLogsBySession(ctx, sql.NullString{String: sessionID, Valid: true})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db.ListLogsBySession: %w", err)
|
||||
}
|
||||
|
||||
logs := make([]Log, len(dbLogs))
|
||||
for i, dbSess := range dbLogs {
|
||||
logs[i] = s.fromDBItem(dbSess)
|
||||
}
|
||||
return logs, nil
|
||||
}
|
||||
|
||||
func (s *service) ListAll(ctx context.Context, limit int) ([]Log, error) {
|
||||
dbLogs, err := s.db.ListAllLogs(ctx, int64(limit))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db.ListAllLogs: %w", err)
|
||||
}
|
||||
logs := make([]Log, len(dbLogs))
|
||||
for i, dbSess := range dbLogs {
|
||||
logs[i] = s.fromDBItem(dbSess)
|
||||
}
|
||||
return logs, nil
|
||||
}
|
||||
|
||||
func (s *service) Subscribe(ctx context.Context) <-chan pubsub.Event[Log] {
|
||||
return s.broker.Subscribe(ctx)
|
||||
}
|
||||
|
||||
func (s *service) fromDBItem(item db.Log) Log {
|
||||
log := Log{
|
||||
ID: item.ID,
|
||||
SessionID: item.SessionID.String,
|
||||
Level: item.Level,
|
||||
Message: item.Message,
|
||||
}
|
||||
|
||||
// Parse timestamp from ISO string
|
||||
timestamp, err := time.Parse(time.RFC3339Nano, item.Timestamp)
|
||||
if err == nil {
|
||||
log.Timestamp = timestamp
|
||||
} else {
|
||||
log.Timestamp = time.Now() // Fallback
|
||||
}
|
||||
|
||||
// Parse created_at from ISO string
|
||||
createdAt, err := time.Parse(time.RFC3339Nano, item.CreatedAt)
|
||||
if err == nil {
|
||||
log.CreatedAt = createdAt
|
||||
} else {
|
||||
log.CreatedAt = time.Now() // Fallback
|
||||
}
|
||||
|
||||
if item.Attributes.Valid && item.Attributes.String != "" {
|
||||
if err := json.Unmarshal([]byte(item.Attributes.String), &log.Attributes); err != nil {
|
||||
slog.Error("Failed to unmarshal log attributes", "log_id", item.ID, "error", err)
|
||||
log.Attributes = make(map[string]string)
|
||||
}
|
||||
} else {
|
||||
log.Attributes = make(map[string]string)
|
||||
}
|
||||
|
||||
return log
|
||||
}
|
||||
|
||||
func Create(ctx context.Context, timestamp time.Time, level, message string, attributes map[string]string, sessionID string) error {
|
||||
return GetService().Create(ctx, timestamp, level, message, attributes, sessionID)
|
||||
}
|
||||
|
||||
func ListBySession(ctx context.Context, sessionID string) ([]Log, error) {
|
||||
return GetService().ListBySession(ctx, sessionID)
|
||||
}
|
||||
|
||||
func ListAll(ctx context.Context, limit int) ([]Log, error) {
|
||||
return GetService().ListAll(ctx, limit)
|
||||
}
|
||||
|
||||
func Subscribe(ctx context.Context) <-chan pubsub.Event[Log] {
|
||||
return GetService().Subscribe(ctx)
|
||||
}
|
||||
|
||||
type slogWriter struct{}
|
||||
|
||||
func (sw *slogWriter) Write(p []byte) (n int, err error) {
|
||||
// Example: time=2024-05-09T12:34:56.789-05:00 level=INFO msg="User request" session=xyz foo=bar
|
||||
d := logfmt.NewDecoder(bytes.NewReader(p))
|
||||
for d.ScanRecord() {
|
||||
var timestamp time.Time
|
||||
var level string
|
||||
var message string
|
||||
var sessionID string
|
||||
var attributes map[string]string
|
||||
|
||||
attributes = make(map[string]string)
|
||||
hasTimestamp := false
|
||||
|
||||
for d.ScanKeyval() {
|
||||
key := string(d.Key())
|
||||
value := string(d.Value())
|
||||
|
||||
switch key {
|
||||
case "time":
|
||||
parsedTime, timeErr := time.Parse(time.RFC3339Nano, value)
|
||||
if timeErr != nil {
|
||||
parsedTime, timeErr = time.Parse(time.RFC3339, value)
|
||||
if timeErr != nil {
|
||||
slog.Error("Failed to parse time in slog writer", "value", value, "error", timeErr)
|
||||
timestamp = time.Now().UTC()
|
||||
hasTimestamp = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
timestamp = parsedTime
|
||||
hasTimestamp = true
|
||||
case "level":
|
||||
level = strings.ToLower(value)
|
||||
case "msg", "message":
|
||||
message = value
|
||||
case "session_id":
|
||||
sessionID = value
|
||||
default:
|
||||
attributes[key] = value
|
||||
}
|
||||
}
|
||||
if d.Err() != nil {
|
||||
return len(p), fmt.Errorf("logfmt.ScanRecord: %w", d.Err())
|
||||
}
|
||||
|
||||
if !hasTimestamp {
|
||||
timestamp = time.Now()
|
||||
}
|
||||
|
||||
// Create log entry via the service (non-blocking or handle error appropriately)
|
||||
// Using context.Background() as this is a low-level logging write.
|
||||
go func(timestamp time.Time, level, message string, attributes map[string]string, sessionID string) { // Run in a goroutine to avoid blocking slog
|
||||
if globalLoggingService == nil {
|
||||
// If the logging service is not initialized, log the message to stderr
|
||||
// fmt.Fprintf(os.Stderr, "ERROR [logging.slogWriter]: logging service not initialized\n")
|
||||
return
|
||||
}
|
||||
if err := Create(context.Background(), timestamp, level, message, attributes, sessionID); err != nil {
|
||||
// Log internal error using a more primitive logger to avoid loops
|
||||
fmt.Fprintf(os.Stderr, "ERROR [logging.slogWriter]: failed to persist log: %v\n", err)
|
||||
}
|
||||
}(timestamp, level, message, attributes, sessionID)
|
||||
}
|
||||
if d.Err() != nil {
|
||||
return len(p), fmt.Errorf("logfmt.ScanRecord final: %w", d.Err())
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func NewSlogWriter() io.Writer {
|
||||
return &slogWriter{}
|
||||
}
|
||||
|
||||
// RecoverPanic is a common function to handle panics gracefully.
|
||||
// It logs the error, creates a panic log file with stack trace,
|
||||
// and executes an optional cleanup function.
|
||||
func RecoverPanic(name string, cleanup func()) {
|
||||
if r := recover(); r != nil {
|
||||
errorMsg := fmt.Sprintf("Panic in %s: %v", name, r)
|
||||
// Use slog directly here, as our service might be the one panicking.
|
||||
slog.Error(errorMsg)
|
||||
// status.Error(errorMsg)
|
||||
|
||||
timestamp := time.Now().Format("20060102-150405")
|
||||
filename := fmt.Sprintf("opencode-panic-%s-%s.log", name, timestamp)
|
||||
|
||||
file, err := os.Create(filename)
|
||||
if err != nil {
|
||||
errMsg := fmt.Sprintf("Failed to create panic log file '%s': %v", filename, err)
|
||||
slog.Error(errMsg)
|
||||
// status.Error(errMsg)
|
||||
} else {
|
||||
defer file.Close()
|
||||
fmt.Fprintf(file, "Panic in %s: %v\n\n", name, r)
|
||||
fmt.Fprintf(file, "Time: %s\n\n", time.Now().Format(time.RFC3339))
|
||||
fmt.Fprintf(file, "Stack Trace:\n%s\n", string(debug.Stack())) // Capture stack trace
|
||||
infoMsg := fmt.Sprintf("Panic details written to %s", filename)
|
||||
slog.Info(infoMsg)
|
||||
// status.Info(infoMsg)
|
||||
}
|
||||
|
||||
if cleanup != nil {
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,797 +0,0 @@
|
||||
package lsp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"log/slog"
|
||||
|
||||
"github.com/sst/opencode/internal/config"
|
||||
"github.com/sst/opencode/internal/logging"
|
||||
"github.com/sst/opencode/internal/lsp/protocol"
|
||||
"github.com/sst/opencode/internal/status"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
Cmd *exec.Cmd
|
||||
stdin io.WriteCloser
|
||||
stdout *bufio.Reader
|
||||
stderr io.ReadCloser
|
||||
|
||||
// Request ID counter
|
||||
nextID atomic.Int32
|
||||
|
||||
// Response handlers
|
||||
handlers map[int32]chan *Message
|
||||
handlersMu sync.RWMutex
|
||||
|
||||
// Server request handlers
|
||||
serverRequestHandlers map[string]ServerRequestHandler
|
||||
serverHandlersMu sync.RWMutex
|
||||
|
||||
// Notification handlers
|
||||
notificationHandlers map[string]NotificationHandler
|
||||
notificationMu sync.RWMutex
|
||||
|
||||
// Diagnostic cache
|
||||
diagnostics map[protocol.DocumentUri][]protocol.Diagnostic
|
||||
diagnosticsMu sync.RWMutex
|
||||
|
||||
// Files are currently opened by the LSP
|
||||
openFiles map[string]*OpenFileInfo
|
||||
openFilesMu sync.RWMutex
|
||||
|
||||
// Server state
|
||||
serverState atomic.Value
|
||||
}
|
||||
|
||||
func NewClient(ctx context.Context, command string, args ...string) (*Client, error) {
|
||||
cmd := exec.CommandContext(ctx, command, args...)
|
||||
// Copy env
|
||||
cmd.Env = os.Environ()
|
||||
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create stdin pipe: %w", err)
|
||||
}
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create stdout pipe: %w", err)
|
||||
}
|
||||
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create stderr pipe: %w", err)
|
||||
}
|
||||
|
||||
client := &Client{
|
||||
Cmd: cmd,
|
||||
stdin: stdin,
|
||||
stdout: bufio.NewReader(stdout),
|
||||
stderr: stderr,
|
||||
handlers: make(map[int32]chan *Message),
|
||||
notificationHandlers: make(map[string]NotificationHandler),
|
||||
serverRequestHandlers: make(map[string]ServerRequestHandler),
|
||||
diagnostics: make(map[protocol.DocumentUri][]protocol.Diagnostic),
|
||||
openFiles: make(map[string]*OpenFileInfo),
|
||||
}
|
||||
|
||||
// Initialize server state
|
||||
client.serverState.Store(StateStarting)
|
||||
|
||||
// Start the LSP server process
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("failed to start LSP server: %w", err)
|
||||
}
|
||||
|
||||
// Handle stderr in a separate goroutine
|
||||
go func() {
|
||||
scanner := bufio.NewScanner(stderr)
|
||||
for scanner.Scan() {
|
||||
slog.Info("LSP Server", "message", scanner.Text())
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
slog.Error("Error reading LSP stderr", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Start message handling loop
|
||||
go func() {
|
||||
defer logging.RecoverPanic("LSP-message-handler", func() {
|
||||
status.Error("LSP message handler crashed, LSP functionality may be impaired")
|
||||
})
|
||||
client.handleMessages()
|
||||
}()
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (c *Client) RegisterNotificationHandler(method string, handler NotificationHandler) {
|
||||
c.notificationMu.Lock()
|
||||
defer c.notificationMu.Unlock()
|
||||
c.notificationHandlers[method] = handler
|
||||
}
|
||||
|
||||
func (c *Client) RegisterServerRequestHandler(method string, handler ServerRequestHandler) {
|
||||
c.serverHandlersMu.Lock()
|
||||
defer c.serverHandlersMu.Unlock()
|
||||
c.serverRequestHandlers[method] = handler
|
||||
}
|
||||
|
||||
func (c *Client) InitializeLSPClient(ctx context.Context, workspaceDir string) (*protocol.InitializeResult, error) {
|
||||
initParams := &protocol.InitializeParams{
|
||||
WorkspaceFoldersInitializeParams: protocol.WorkspaceFoldersInitializeParams{
|
||||
WorkspaceFolders: []protocol.WorkspaceFolder{
|
||||
{
|
||||
URI: protocol.URI("file://" + workspaceDir),
|
||||
Name: workspaceDir,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
XInitializeParams: protocol.XInitializeParams{
|
||||
ProcessID: int32(os.Getpid()),
|
||||
ClientInfo: &protocol.ClientInfo{
|
||||
Name: "mcp-language-server",
|
||||
Version: "0.1.0",
|
||||
},
|
||||
RootPath: workspaceDir,
|
||||
RootURI: protocol.DocumentUri("file://" + workspaceDir),
|
||||
Capabilities: protocol.ClientCapabilities{
|
||||
Workspace: protocol.WorkspaceClientCapabilities{
|
||||
Configuration: true,
|
||||
DidChangeConfiguration: protocol.DidChangeConfigurationClientCapabilities{
|
||||
DynamicRegistration: true,
|
||||
},
|
||||
DidChangeWatchedFiles: protocol.DidChangeWatchedFilesClientCapabilities{
|
||||
DynamicRegistration: true,
|
||||
RelativePatternSupport: true,
|
||||
},
|
||||
},
|
||||
TextDocument: protocol.TextDocumentClientCapabilities{
|
||||
Synchronization: &protocol.TextDocumentSyncClientCapabilities{
|
||||
DynamicRegistration: true,
|
||||
DidSave: true,
|
||||
},
|
||||
Completion: protocol.CompletionClientCapabilities{
|
||||
CompletionItem: protocol.ClientCompletionItemOptions{},
|
||||
},
|
||||
CodeLens: &protocol.CodeLensClientCapabilities{
|
||||
DynamicRegistration: true,
|
||||
},
|
||||
DocumentSymbol: protocol.DocumentSymbolClientCapabilities{},
|
||||
CodeAction: protocol.CodeActionClientCapabilities{
|
||||
CodeActionLiteralSupport: protocol.ClientCodeActionLiteralOptions{
|
||||
CodeActionKind: protocol.ClientCodeActionKindOptions{
|
||||
ValueSet: []protocol.CodeActionKind{},
|
||||
},
|
||||
},
|
||||
},
|
||||
PublishDiagnostics: protocol.PublishDiagnosticsClientCapabilities{
|
||||
VersionSupport: true,
|
||||
},
|
||||
SemanticTokens: protocol.SemanticTokensClientCapabilities{
|
||||
Requests: protocol.ClientSemanticTokensRequestOptions{
|
||||
Range: &protocol.Or_ClientSemanticTokensRequestOptions_range{},
|
||||
Full: &protocol.Or_ClientSemanticTokensRequestOptions_full{},
|
||||
},
|
||||
TokenTypes: []string{},
|
||||
TokenModifiers: []string{},
|
||||
Formats: []protocol.TokenFormat{},
|
||||
},
|
||||
},
|
||||
Window: protocol.WindowClientCapabilities{},
|
||||
},
|
||||
InitializationOptions: map[string]any{
|
||||
"codelenses": map[string]bool{
|
||||
"generate": true,
|
||||
"regenerate_cgo": true,
|
||||
"test": true,
|
||||
"tidy": true,
|
||||
"upgrade_dependency": true,
|
||||
"vendor": true,
|
||||
"vulncheck": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var result protocol.InitializeResult
|
||||
if err := c.Call(ctx, "initialize", initParams, &result); err != nil {
|
||||
return nil, fmt.Errorf("initialize failed: %w", err)
|
||||
}
|
||||
|
||||
if err := c.Notify(ctx, "initialized", struct{}{}); err != nil {
|
||||
return nil, fmt.Errorf("initialized notification failed: %w", err)
|
||||
}
|
||||
|
||||
// Register handlers
|
||||
c.RegisterServerRequestHandler("workspace/applyEdit", HandleApplyEdit)
|
||||
c.RegisterServerRequestHandler("workspace/configuration", HandleWorkspaceConfiguration)
|
||||
c.RegisterServerRequestHandler("client/registerCapability", HandleRegisterCapability)
|
||||
c.RegisterNotificationHandler("window/showMessage", HandleServerMessage)
|
||||
c.RegisterNotificationHandler("textDocument/publishDiagnostics",
|
||||
func(params json.RawMessage) { HandleDiagnostics(c, params) })
|
||||
|
||||
// Notify the LSP server
|
||||
err := c.Initialized(ctx, protocol.InitializedParams{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("initialization failed: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (c *Client) Close() error {
|
||||
// Try to close all open files first
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Attempt to close files but continue shutdown regardless
|
||||
c.CloseAllFiles(ctx)
|
||||
|
||||
// Close stdin to signal the server
|
||||
if err := c.stdin.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close stdin: %w", err)
|
||||
}
|
||||
|
||||
// Use a channel to handle the Wait with timeout
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- c.Cmd.Wait()
|
||||
}()
|
||||
|
||||
// Wait for process to exit with timeout
|
||||
select {
|
||||
case err := <-done:
|
||||
return err
|
||||
case <-time.After(2 * time.Second):
|
||||
// If we timeout, try to kill the process
|
||||
if err := c.Cmd.Process.Kill(); err != nil {
|
||||
return fmt.Errorf("failed to kill process: %w", err)
|
||||
}
|
||||
return fmt.Errorf("process killed after timeout")
|
||||
}
|
||||
}
|
||||
|
||||
type ServerState int
|
||||
|
||||
const (
|
||||
StateStarting ServerState = iota
|
||||
StateReady
|
||||
StateError
|
||||
)
|
||||
|
||||
// GetServerState returns the current state of the LSP server
|
||||
func (c *Client) GetServerState() ServerState {
|
||||
if val := c.serverState.Load(); val != nil {
|
||||
return val.(ServerState)
|
||||
}
|
||||
return StateStarting
|
||||
}
|
||||
|
||||
// SetServerState sets the current state of the LSP server
|
||||
func (c *Client) SetServerState(state ServerState) {
|
||||
c.serverState.Store(state)
|
||||
}
|
||||
|
||||
// WaitForServerReady waits for the server to be ready by polling the server
|
||||
// with a simple request until it responds successfully or times out
|
||||
func (c *Client) WaitForServerReady(ctx context.Context) error {
|
||||
cnf := config.Get()
|
||||
|
||||
// Set initial state
|
||||
c.SetServerState(StateStarting)
|
||||
|
||||
// Create a context with timeout
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Try to ping the server with a simple request
|
||||
ticker := time.NewTicker(500 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
if cnf.DebugLSP {
|
||||
slog.Debug("Waiting for LSP server to be ready...")
|
||||
}
|
||||
|
||||
// Determine server type for specialized initialization
|
||||
serverType := c.detectServerType()
|
||||
|
||||
// For TypeScript-like servers, we need to open some key files first
|
||||
if serverType == ServerTypeTypeScript {
|
||||
if cnf.DebugLSP {
|
||||
slog.Debug("TypeScript-like server detected, opening key configuration files")
|
||||
}
|
||||
c.openKeyConfigFiles(ctx)
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
c.SetServerState(StateError)
|
||||
return fmt.Errorf("timeout waiting for LSP server to be ready")
|
||||
case <-ticker.C:
|
||||
// Try a ping method appropriate for this server type
|
||||
err := c.pingServerByType(ctx, serverType)
|
||||
if err == nil {
|
||||
// Server responded successfully
|
||||
c.SetServerState(StateReady)
|
||||
if cnf.DebugLSP {
|
||||
slog.Debug("LSP server is ready")
|
||||
}
|
||||
return nil
|
||||
} else {
|
||||
slog.Debug("LSP server not ready yet", "error", err, "serverType", serverType)
|
||||
}
|
||||
|
||||
if cnf.DebugLSP {
|
||||
slog.Debug("LSP server not ready yet", "error", err, "serverType", serverType)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ServerType represents the type of LSP server
|
||||
type ServerType int
|
||||
|
||||
const (
|
||||
ServerTypeUnknown ServerType = iota
|
||||
ServerTypeGo
|
||||
ServerTypeTypeScript
|
||||
ServerTypeRust
|
||||
ServerTypePython
|
||||
ServerTypeGeneric
|
||||
)
|
||||
|
||||
// detectServerType tries to determine what type of LSP server we're dealing with
|
||||
func (c *Client) detectServerType() ServerType {
|
||||
if c.Cmd == nil {
|
||||
return ServerTypeUnknown
|
||||
}
|
||||
|
||||
cmdPath := strings.ToLower(c.Cmd.Path)
|
||||
|
||||
switch {
|
||||
case strings.Contains(cmdPath, "gopls"):
|
||||
return ServerTypeGo
|
||||
case strings.Contains(cmdPath, "typescript") || strings.Contains(cmdPath, "vtsls") || strings.Contains(cmdPath, "tsserver"):
|
||||
return ServerTypeTypeScript
|
||||
case strings.Contains(cmdPath, "rust-analyzer"):
|
||||
return ServerTypeRust
|
||||
case strings.Contains(cmdPath, "pyright") || strings.Contains(cmdPath, "pylsp") || strings.Contains(cmdPath, "python"):
|
||||
return ServerTypePython
|
||||
default:
|
||||
return ServerTypeGeneric
|
||||
}
|
||||
}
|
||||
|
||||
// openKeyConfigFiles opens important configuration files that help initialize the server
|
||||
func (c *Client) openKeyConfigFiles(ctx context.Context) {
|
||||
workDir := config.WorkingDirectory()
|
||||
serverType := c.detectServerType()
|
||||
|
||||
var filesToOpen []string
|
||||
|
||||
switch serverType {
|
||||
case ServerTypeTypeScript:
|
||||
// TypeScript servers need these config files to properly initialize
|
||||
filesToOpen = []string{
|
||||
filepath.Join(workDir, "tsconfig.json"),
|
||||
filepath.Join(workDir, "package.json"),
|
||||
filepath.Join(workDir, "jsconfig.json"),
|
||||
}
|
||||
|
||||
// Also find and open a few TypeScript files to help the server initialize
|
||||
c.openTypeScriptFiles(ctx, workDir)
|
||||
case ServerTypeGo:
|
||||
filesToOpen = []string{
|
||||
filepath.Join(workDir, "go.mod"),
|
||||
filepath.Join(workDir, "go.sum"),
|
||||
}
|
||||
case ServerTypeRust:
|
||||
filesToOpen = []string{
|
||||
filepath.Join(workDir, "Cargo.toml"),
|
||||
filepath.Join(workDir, "Cargo.lock"),
|
||||
}
|
||||
}
|
||||
|
||||
// Try to open each file, ignoring errors if they don't exist
|
||||
for _, file := range filesToOpen {
|
||||
if _, err := os.Stat(file); err == nil {
|
||||
// File exists, try to open it
|
||||
if err := c.OpenFile(ctx, file); err != nil {
|
||||
slog.Debug("Failed to open key config file", "file", file, "error", err)
|
||||
} else {
|
||||
slog.Debug("Opened key config file for initialization", "file", file)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pingServerByType sends a ping request appropriate for the server type
|
||||
func (c *Client) pingServerByType(ctx context.Context, serverType ServerType) error {
|
||||
switch serverType {
|
||||
case ServerTypeTypeScript:
|
||||
// For TypeScript, try a document symbol request on an open file
|
||||
return c.pingTypeScriptServer(ctx)
|
||||
case ServerTypeGo:
|
||||
// For Go, workspace/symbol works well
|
||||
return c.pingWithWorkspaceSymbol(ctx)
|
||||
case ServerTypeRust:
|
||||
// For Rust, workspace/symbol works well
|
||||
return c.pingWithWorkspaceSymbol(ctx)
|
||||
default:
|
||||
// Default ping method
|
||||
return c.pingWithWorkspaceSymbol(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// pingTypeScriptServer tries to ping a TypeScript server with appropriate methods
|
||||
func (c *Client) pingTypeScriptServer(ctx context.Context) error {
|
||||
// First try workspace/symbol which works for many servers
|
||||
if err := c.pingWithWorkspaceSymbol(ctx); err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// If that fails, try to find an open file and request document symbols
|
||||
c.openFilesMu.RLock()
|
||||
defer c.openFilesMu.RUnlock()
|
||||
|
||||
// If we have any open files, try to get document symbols for one
|
||||
for uri := range c.openFiles {
|
||||
filePath := strings.TrimPrefix(uri, "file://")
|
||||
if strings.HasSuffix(filePath, ".ts") || strings.HasSuffix(filePath, ".js") ||
|
||||
strings.HasSuffix(filePath, ".tsx") || strings.HasSuffix(filePath, ".jsx") {
|
||||
var symbols []protocol.DocumentSymbol
|
||||
err := c.Call(ctx, "textDocument/documentSymbol", protocol.DocumentSymbolParams{
|
||||
TextDocument: protocol.TextDocumentIdentifier{
|
||||
URI: protocol.DocumentUri(uri),
|
||||
},
|
||||
}, &symbols)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we have no open TypeScript files, try to find and open one
|
||||
workDir := config.WorkingDirectory()
|
||||
err := filepath.WalkDir(workDir, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Skip directories and non-TypeScript files
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
ext := filepath.Ext(path)
|
||||
if ext == ".ts" || ext == ".js" || ext == ".tsx" || ext == ".jsx" {
|
||||
// Found a TypeScript file, try to open it
|
||||
if err := c.OpenFile(ctx, path); err == nil {
|
||||
// Successfully opened, stop walking
|
||||
return filepath.SkipAll
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
slog.Debug("Error walking directory for TypeScript files", "error", err)
|
||||
}
|
||||
|
||||
// Final fallback - just try a generic capability
|
||||
return c.pingWithServerCapabilities(ctx)
|
||||
}
|
||||
|
||||
// openTypeScriptFiles finds and opens TypeScript files to help initialize the server
|
||||
func (c *Client) openTypeScriptFiles(ctx context.Context, workDir string) {
|
||||
cnf := config.Get()
|
||||
filesOpened := 0
|
||||
maxFilesToOpen := 5 // Limit to a reasonable number of files
|
||||
|
||||
// Find and open TypeScript files
|
||||
err := filepath.WalkDir(workDir, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Skip directories and non-TypeScript files
|
||||
if d.IsDir() {
|
||||
// Skip common directories to avoid wasting time
|
||||
if shouldSkipDir(path) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if we've opened enough files
|
||||
if filesOpened >= maxFilesToOpen {
|
||||
return filepath.SkipAll
|
||||
}
|
||||
|
||||
// Check file extension
|
||||
ext := filepath.Ext(path)
|
||||
if ext == ".ts" || ext == ".tsx" || ext == ".js" || ext == ".jsx" {
|
||||
// Try to open the file
|
||||
if err := c.OpenFile(ctx, path); err == nil {
|
||||
filesOpened++
|
||||
if cnf.DebugLSP {
|
||||
slog.Debug("Opened TypeScript file for initialization", "file", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil && cnf.DebugLSP {
|
||||
slog.Debug("Error walking directory for TypeScript files", "error", err)
|
||||
}
|
||||
|
||||
if cnf.DebugLSP {
|
||||
slog.Debug("Opened TypeScript files for initialization", "count", filesOpened)
|
||||
}
|
||||
}
|
||||
|
||||
// shouldSkipDir returns true if the directory should be skipped during file search
|
||||
func shouldSkipDir(path string) bool {
|
||||
dirName := filepath.Base(path)
|
||||
|
||||
// Skip hidden directories
|
||||
if strings.HasPrefix(dirName, ".") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Skip common directories that won't contain relevant source files
|
||||
skipDirs := map[string]bool{
|
||||
"node_modules": true,
|
||||
"dist": true,
|
||||
"build": true,
|
||||
"coverage": true,
|
||||
"vendor": true,
|
||||
"target": true,
|
||||
}
|
||||
|
||||
return skipDirs[dirName]
|
||||
}
|
||||
|
||||
// pingWithWorkspaceSymbol tries a workspace/symbol request
|
||||
func (c *Client) pingWithWorkspaceSymbol(ctx context.Context) error {
|
||||
var result []protocol.SymbolInformation
|
||||
return c.Call(ctx, "workspace/symbol", protocol.WorkspaceSymbolParams{
|
||||
Query: "",
|
||||
}, &result)
|
||||
}
|
||||
|
||||
// pingWithServerCapabilities tries to get server capabilities
|
||||
func (c *Client) pingWithServerCapabilities(ctx context.Context) error {
|
||||
// This is a very lightweight request that should work for most servers
|
||||
return c.Notify(ctx, "$/cancelRequest", struct{ ID int }{ID: -1})
|
||||
}
|
||||
|
||||
type OpenFileInfo struct {
|
||||
Version int32
|
||||
URI protocol.DocumentUri
|
||||
}
|
||||
|
||||
func (c *Client) OpenFile(ctx context.Context, filepath string) error {
|
||||
uri := fmt.Sprintf("file://%s", filepath)
|
||||
|
||||
c.openFilesMu.Lock()
|
||||
if _, exists := c.openFiles[uri]; exists {
|
||||
c.openFilesMu.Unlock()
|
||||
return nil // Already open
|
||||
}
|
||||
c.openFilesMu.Unlock()
|
||||
|
||||
// Skip files that do not exist or cannot be read
|
||||
content, err := os.ReadFile(filepath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading file: %w", err)
|
||||
}
|
||||
|
||||
params := protocol.DidOpenTextDocumentParams{
|
||||
TextDocument: protocol.TextDocumentItem{
|
||||
URI: protocol.DocumentUri(uri),
|
||||
LanguageID: DetectLanguageID(uri),
|
||||
Version: 1,
|
||||
Text: string(content),
|
||||
},
|
||||
}
|
||||
|
||||
if err := c.Notify(ctx, "textDocument/didOpen", params); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.openFilesMu.Lock()
|
||||
c.openFiles[uri] = &OpenFileInfo{
|
||||
Version: 1,
|
||||
URI: protocol.DocumentUri(uri),
|
||||
}
|
||||
c.openFilesMu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) NotifyChange(ctx context.Context, filepath string) error {
|
||||
uri := fmt.Sprintf("file://%s", filepath)
|
||||
|
||||
// Verify file exists before attempting to read it
|
||||
if _, err := os.Stat(filepath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// File was deleted - close it in the LSP client instead of notifying change
|
||||
return c.CloseFile(ctx, filepath)
|
||||
}
|
||||
return fmt.Errorf("error checking file: %w", err)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(filepath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading file: %w", err)
|
||||
}
|
||||
|
||||
c.openFilesMu.Lock()
|
||||
fileInfo, isOpen := c.openFiles[uri]
|
||||
if !isOpen {
|
||||
c.openFilesMu.Unlock()
|
||||
return fmt.Errorf("cannot notify change for unopened file: %s", filepath)
|
||||
}
|
||||
|
||||
// Increment version
|
||||
fileInfo.Version++
|
||||
version := fileInfo.Version
|
||||
c.openFilesMu.Unlock()
|
||||
|
||||
params := protocol.DidChangeTextDocumentParams{
|
||||
TextDocument: protocol.VersionedTextDocumentIdentifier{
|
||||
TextDocumentIdentifier: protocol.TextDocumentIdentifier{
|
||||
URI: protocol.DocumentUri(uri),
|
||||
},
|
||||
Version: version,
|
||||
},
|
||||
ContentChanges: []protocol.TextDocumentContentChangeEvent{
|
||||
{
|
||||
Value: protocol.TextDocumentContentChangeWholeDocument{
|
||||
Text: string(content),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return c.Notify(ctx, "textDocument/didChange", params)
|
||||
}
|
||||
|
||||
func (c *Client) CloseFile(ctx context.Context, filepath string) error {
|
||||
cnf := config.Get()
|
||||
uri := fmt.Sprintf("file://%s", filepath)
|
||||
|
||||
c.openFilesMu.Lock()
|
||||
if _, exists := c.openFiles[uri]; !exists {
|
||||
c.openFilesMu.Unlock()
|
||||
return nil // Already closed
|
||||
}
|
||||
c.openFilesMu.Unlock()
|
||||
|
||||
params := protocol.DidCloseTextDocumentParams{
|
||||
TextDocument: protocol.TextDocumentIdentifier{
|
||||
URI: protocol.DocumentUri(uri),
|
||||
},
|
||||
}
|
||||
|
||||
if cnf.DebugLSP {
|
||||
slog.Debug("Closing file", "file", filepath)
|
||||
}
|
||||
if err := c.Notify(ctx, "textDocument/didClose", params); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.openFilesMu.Lock()
|
||||
delete(c.openFiles, uri)
|
||||
c.openFilesMu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) IsFileOpen(filepath string) bool {
|
||||
uri := fmt.Sprintf("file://%s", filepath)
|
||||
c.openFilesMu.RLock()
|
||||
defer c.openFilesMu.RUnlock()
|
||||
_, exists := c.openFiles[uri]
|
||||
return exists
|
||||
}
|
||||
|
||||
// CloseAllFiles closes all currently open files
|
||||
func (c *Client) CloseAllFiles(ctx context.Context) {
|
||||
cnf := config.Get()
|
||||
c.openFilesMu.Lock()
|
||||
filesToClose := make([]string, 0, len(c.openFiles))
|
||||
|
||||
// First collect all URIs that need to be closed
|
||||
for uri := range c.openFiles {
|
||||
// Convert URI back to file path by trimming "file://" prefix
|
||||
filePath := strings.TrimPrefix(uri, "file://")
|
||||
filesToClose = append(filesToClose, filePath)
|
||||
}
|
||||
c.openFilesMu.Unlock()
|
||||
|
||||
// Then close them all
|
||||
for _, filePath := range filesToClose {
|
||||
err := c.CloseFile(ctx, filePath)
|
||||
if err != nil && cnf.DebugLSP {
|
||||
slog.Warn("Error closing file", "file", filePath, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
if cnf.DebugLSP {
|
||||
slog.Debug("Closed all files", "files", filesToClose)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) GetFileDiagnostics(uri protocol.DocumentUri) []protocol.Diagnostic {
|
||||
c.diagnosticsMu.RLock()
|
||||
defer c.diagnosticsMu.RUnlock()
|
||||
|
||||
return c.diagnostics[uri]
|
||||
}
|
||||
|
||||
// GetDiagnostics returns all diagnostics for all files
|
||||
func (c *Client) GetDiagnostics() map[protocol.DocumentUri][]protocol.Diagnostic {
|
||||
return c.diagnostics
|
||||
}
|
||||
|
||||
// OpenFileOnDemand opens a file only if it's not already open
|
||||
// This is used for lazy-loading files when they're actually needed
|
||||
func (c *Client) OpenFileOnDemand(ctx context.Context, filepath string) error {
|
||||
// Check if the file is already open
|
||||
if c.IsFileOpen(filepath) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Open the file
|
||||
return c.OpenFile(ctx, filepath)
|
||||
}
|
||||
|
||||
// GetDiagnosticsForFile ensures a file is open and returns its diagnostics
|
||||
// This is useful for on-demand diagnostics when using lazy loading
|
||||
func (c *Client) GetDiagnosticsForFile(ctx context.Context, filepath string) ([]protocol.Diagnostic, error) {
|
||||
uri := fmt.Sprintf("file://%s", filepath)
|
||||
documentUri := protocol.DocumentUri(uri)
|
||||
|
||||
// Make sure the file is open
|
||||
if !c.IsFileOpen(filepath) {
|
||||
if err := c.OpenFile(ctx, filepath); err != nil {
|
||||
return nil, fmt.Errorf("failed to open file for diagnostics: %w", err)
|
||||
}
|
||||
|
||||
// Give the LSP server a moment to process the file
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
// Get diagnostics
|
||||
c.diagnosticsMu.RLock()
|
||||
diagnostics := c.diagnostics[documentUri]
|
||||
c.diagnosticsMu.RUnlock()
|
||||
|
||||
return diagnostics, nil
|
||||
}
|
||||
|
||||
// ClearDiagnosticsForURI removes diagnostics for a specific URI from the cache
|
||||
func (c *Client) ClearDiagnosticsForURI(uri protocol.DocumentUri) {
|
||||
c.diagnosticsMu.Lock()
|
||||
defer c.diagnosticsMu.Unlock()
|
||||
delete(c.diagnostics, uri)
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/sst/opencode/internal/config"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
// IntegrateLSPServers discovers languages and LSP servers and integrates them into the application configuration
|
||||
func IntegrateLSPServers(workingDir string) error {
|
||||
// Get the current configuration
|
||||
cfg := config.Get()
|
||||
if cfg == nil {
|
||||
return fmt.Errorf("config not loaded")
|
||||
}
|
||||
|
||||
// Check if this is the first run
|
||||
shouldInit, err := config.ShouldShowInitDialog()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check initialization status: %w", err)
|
||||
}
|
||||
|
||||
// Always run language detection, but log differently for first run vs. subsequent runs
|
||||
if shouldInit || len(cfg.LSP) == 0 {
|
||||
slog.Info("Running initial LSP auto-discovery...")
|
||||
} else {
|
||||
slog.Debug("Running LSP auto-discovery to detect new languages...")
|
||||
}
|
||||
|
||||
// Configure LSP servers
|
||||
servers, err := ConfigureLSPServers(workingDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to configure LSP servers: %w", err)
|
||||
}
|
||||
|
||||
// Update the configuration with discovered servers
|
||||
for langID, serverInfo := range servers {
|
||||
// Skip languages that already have a configured server
|
||||
if _, exists := cfg.LSP[langID]; exists {
|
||||
slog.Debug("LSP server already configured for language", "language", langID)
|
||||
continue
|
||||
}
|
||||
|
||||
if serverInfo.Available {
|
||||
// Only add servers that were found
|
||||
cfg.LSP[langID] = config.LSPConfig{
|
||||
Disabled: false,
|
||||
Command: serverInfo.Path,
|
||||
Args: serverInfo.Args,
|
||||
}
|
||||
slog.Info("Added LSP server to configuration",
|
||||
"language", langID,
|
||||
"command", serverInfo.Command,
|
||||
"path", serverInfo.Path)
|
||||
} else {
|
||||
slog.Warn("LSP server not available",
|
||||
"language", langID,
|
||||
"command", serverInfo.Command,
|
||||
"installCmd", serverInfo.InstallCmd)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,298 +0,0 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/sst/opencode/internal/lsp"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
// LanguageInfo stores information about a detected language
|
||||
type LanguageInfo struct {
|
||||
// Language identifier (e.g., "go", "typescript", "python")
|
||||
ID string
|
||||
|
||||
// Number of files detected for this language
|
||||
FileCount int
|
||||
|
||||
// Project files associated with this language (e.g., go.mod, package.json)
|
||||
ProjectFiles []string
|
||||
|
||||
// Whether this is likely a primary language in the project
|
||||
IsPrimary bool
|
||||
}
|
||||
|
||||
// ProjectFile represents a project configuration file
|
||||
type ProjectFile struct {
|
||||
// File name or pattern to match
|
||||
Name string
|
||||
|
||||
// Associated language ID
|
||||
LanguageID string
|
||||
|
||||
// Whether this file strongly indicates the language is primary
|
||||
IsPrimary bool
|
||||
}
|
||||
|
||||
// Common project files that indicate specific languages
|
||||
var projectFilePatterns = []ProjectFile{
|
||||
{Name: "go.mod", LanguageID: "go", IsPrimary: true},
|
||||
{Name: "go.sum", LanguageID: "go", IsPrimary: false},
|
||||
{Name: "package.json", LanguageID: "javascript", IsPrimary: true}, // Could be TypeScript too
|
||||
{Name: "tsconfig.json", LanguageID: "typescript", IsPrimary: true},
|
||||
{Name: "jsconfig.json", LanguageID: "javascript", IsPrimary: true},
|
||||
{Name: "pyproject.toml", LanguageID: "python", IsPrimary: true},
|
||||
{Name: "setup.py", LanguageID: "python", IsPrimary: true},
|
||||
{Name: "requirements.txt", LanguageID: "python", IsPrimary: true},
|
||||
{Name: "Cargo.toml", LanguageID: "rust", IsPrimary: true},
|
||||
{Name: "Cargo.lock", LanguageID: "rust", IsPrimary: false},
|
||||
{Name: "CMakeLists.txt", LanguageID: "cmake", IsPrimary: true},
|
||||
{Name: "pom.xml", LanguageID: "java", IsPrimary: true},
|
||||
{Name: "build.gradle", LanguageID: "java", IsPrimary: true},
|
||||
{Name: "build.gradle.kts", LanguageID: "kotlin", IsPrimary: true},
|
||||
{Name: "composer.json", LanguageID: "php", IsPrimary: true},
|
||||
{Name: "Gemfile", LanguageID: "ruby", IsPrimary: true},
|
||||
{Name: "Rakefile", LanguageID: "ruby", IsPrimary: true},
|
||||
{Name: "mix.exs", LanguageID: "elixir", IsPrimary: true},
|
||||
{Name: "rebar.config", LanguageID: "erlang", IsPrimary: true},
|
||||
{Name: "dune-project", LanguageID: "ocaml", IsPrimary: true},
|
||||
{Name: "stack.yaml", LanguageID: "haskell", IsPrimary: true},
|
||||
{Name: "cabal.project", LanguageID: "haskell", IsPrimary: true},
|
||||
{Name: "Makefile", LanguageID: "make", IsPrimary: false},
|
||||
{Name: "Dockerfile", LanguageID: "dockerfile", IsPrimary: false},
|
||||
}
|
||||
|
||||
// Map of file extensions to language IDs
|
||||
var extensionToLanguage = map[string]string{
|
||||
".go": "go",
|
||||
".js": "javascript",
|
||||
".jsx": "javascript",
|
||||
".ts": "typescript",
|
||||
".tsx": "typescript",
|
||||
".py": "python",
|
||||
".rs": "rust",
|
||||
".java": "java",
|
||||
".c": "c",
|
||||
".cpp": "cpp",
|
||||
".h": "c",
|
||||
".hpp": "cpp",
|
||||
".rb": "ruby",
|
||||
".php": "php",
|
||||
".cs": "csharp",
|
||||
".fs": "fsharp",
|
||||
".swift": "swift",
|
||||
".kt": "kotlin",
|
||||
".scala": "scala",
|
||||
".hs": "haskell",
|
||||
".ml": "ocaml",
|
||||
".ex": "elixir",
|
||||
".exs": "elixir",
|
||||
".erl": "erlang",
|
||||
".lua": "lua",
|
||||
".r": "r",
|
||||
".sh": "shell",
|
||||
".bash": "shell",
|
||||
".zsh": "shell",
|
||||
".html": "html",
|
||||
".css": "css",
|
||||
".scss": "scss",
|
||||
".sass": "sass",
|
||||
".less": "less",
|
||||
".json": "json",
|
||||
".xml": "xml",
|
||||
".yaml": "yaml",
|
||||
".yml": "yaml",
|
||||
".md": "markdown",
|
||||
".dart": "dart",
|
||||
}
|
||||
|
||||
// Directories to exclude from scanning
|
||||
var excludedDirs = map[string]bool{
|
||||
".git": true,
|
||||
"node_modules": true,
|
||||
"vendor": true,
|
||||
"dist": true,
|
||||
"build": true,
|
||||
"target": true,
|
||||
".idea": true,
|
||||
".vscode": true,
|
||||
".github": true,
|
||||
".gitlab": true,
|
||||
"__pycache__": true,
|
||||
".next": true,
|
||||
".nuxt": true,
|
||||
"venv": true,
|
||||
"env": true,
|
||||
".env": true,
|
||||
}
|
||||
|
||||
// DetectLanguages scans a directory to identify programming languages used in the project
|
||||
func DetectLanguages(rootDir string) (map[string]LanguageInfo, error) {
|
||||
languages := make(map[string]LanguageInfo)
|
||||
var mutex sync.Mutex
|
||||
|
||||
// Walk the directory tree
|
||||
err := filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil // Skip files that can't be accessed
|
||||
}
|
||||
|
||||
// Skip excluded directories
|
||||
if info.IsDir() {
|
||||
if excludedDirs[info.Name()] || strings.HasPrefix(info.Name(), ".") {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip hidden files
|
||||
if strings.HasPrefix(info.Name(), ".") {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check for project files
|
||||
for _, pattern := range projectFilePatterns {
|
||||
if info.Name() == pattern.Name {
|
||||
mutex.Lock()
|
||||
lang, exists := languages[pattern.LanguageID]
|
||||
if !exists {
|
||||
lang = LanguageInfo{
|
||||
ID: pattern.LanguageID,
|
||||
FileCount: 0,
|
||||
ProjectFiles: []string{},
|
||||
IsPrimary: pattern.IsPrimary,
|
||||
}
|
||||
}
|
||||
lang.ProjectFiles = append(lang.ProjectFiles, path)
|
||||
if pattern.IsPrimary {
|
||||
lang.IsPrimary = true
|
||||
}
|
||||
languages[pattern.LanguageID] = lang
|
||||
mutex.Unlock()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Check file extension
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
if langID, ok := extensionToLanguage[ext]; ok {
|
||||
mutex.Lock()
|
||||
lang, exists := languages[langID]
|
||||
if !exists {
|
||||
lang = LanguageInfo{
|
||||
ID: langID,
|
||||
FileCount: 0,
|
||||
ProjectFiles: []string{},
|
||||
}
|
||||
}
|
||||
lang.FileCount++
|
||||
languages[langID] = lang
|
||||
mutex.Unlock()
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Determine primary languages based on file count if not already marked
|
||||
determinePrimaryLanguages(languages)
|
||||
|
||||
// Log detected languages
|
||||
for id, info := range languages {
|
||||
if info.IsPrimary {
|
||||
slog.Debug("Detected primary language", "language", id, "files", info.FileCount, "projectFiles", len(info.ProjectFiles))
|
||||
} else {
|
||||
slog.Debug("Detected secondary language", "language", id, "files", info.FileCount)
|
||||
}
|
||||
}
|
||||
|
||||
return languages, nil
|
||||
}
|
||||
|
||||
// determinePrimaryLanguages marks languages as primary based on file count
|
||||
func determinePrimaryLanguages(languages map[string]LanguageInfo) {
|
||||
// Find the language with the most files
|
||||
var maxFiles int
|
||||
for _, info := range languages {
|
||||
if info.FileCount > maxFiles {
|
||||
maxFiles = info.FileCount
|
||||
}
|
||||
}
|
||||
|
||||
// Mark languages with at least 20% of the max files as primary
|
||||
threshold := max(maxFiles/5, 5) // At least 5 files to be considered primary
|
||||
|
||||
for id, info := range languages {
|
||||
if !info.IsPrimary && info.FileCount >= threshold {
|
||||
info.IsPrimary = true
|
||||
languages[id] = info
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetLanguageIDFromExtension returns the language ID for a given file extension
|
||||
func GetLanguageIDFromExtension(ext string) string {
|
||||
ext = strings.ToLower(ext)
|
||||
if langID, ok := extensionToLanguage[ext]; ok {
|
||||
return langID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetLanguageIDFromProtocol converts a protocol.LanguageKind to our language ID string
|
||||
func GetLanguageIDFromProtocol(langKind string) string {
|
||||
// Convert protocol language kind to our language ID
|
||||
switch langKind {
|
||||
case "go":
|
||||
return "go"
|
||||
case "typescript":
|
||||
return "typescript"
|
||||
case "typescriptreact":
|
||||
return "typescript"
|
||||
case "javascript":
|
||||
return "javascript"
|
||||
case "javascriptreact":
|
||||
return "javascript"
|
||||
case "python":
|
||||
return "python"
|
||||
case "rust":
|
||||
return "rust"
|
||||
case "java":
|
||||
return "java"
|
||||
case "c":
|
||||
return "c"
|
||||
case "cpp":
|
||||
return "cpp"
|
||||
default:
|
||||
// Try to normalize the language kind
|
||||
return strings.ToLower(langKind)
|
||||
}
|
||||
}
|
||||
|
||||
// GetLanguageIDFromPath determines the language ID from a file path
|
||||
func GetLanguageIDFromPath(path string) string {
|
||||
// Check file extension first
|
||||
ext := filepath.Ext(path)
|
||||
if langID := GetLanguageIDFromExtension(ext); langID != "" {
|
||||
return langID
|
||||
}
|
||||
|
||||
// Check if it's a known project file
|
||||
filename := filepath.Base(path)
|
||||
for _, pattern := range projectFilePatterns {
|
||||
if filename == pattern.Name {
|
||||
return pattern.LanguageID
|
||||
}
|
||||
}
|
||||
|
||||
// Use LSP's detection as a fallback
|
||||
uri := "file://" + path
|
||||
langKind := lsp.DetectLanguageID(uri)
|
||||
return GetLanguageIDFromProtocol(string(langKind))
|
||||
}
|
||||
@@ -1,306 +0,0 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
// ServerInfo contains information about an LSP server
|
||||
type ServerInfo struct {
|
||||
// Command to run the server
|
||||
Command string
|
||||
|
||||
// Arguments to pass to the command
|
||||
Args []string
|
||||
|
||||
// Command to install the server (for user guidance)
|
||||
InstallCmd string
|
||||
|
||||
// Whether this server is available
|
||||
Available bool
|
||||
|
||||
// Full path to the executable (if found)
|
||||
Path string
|
||||
}
|
||||
|
||||
// LanguageServerMap maps language IDs to their corresponding LSP servers
|
||||
var LanguageServerMap = map[string]ServerInfo{
|
||||
"go": {
|
||||
Command: "gopls",
|
||||
InstallCmd: "go install golang.org/x/tools/gopls@latest",
|
||||
},
|
||||
"typescript": {
|
||||
Command: "typescript-language-server",
|
||||
Args: []string{"--stdio"},
|
||||
InstallCmd: "npm install -g typescript-language-server typescript",
|
||||
},
|
||||
"javascript": {
|
||||
Command: "typescript-language-server",
|
||||
Args: []string{"--stdio"},
|
||||
InstallCmd: "npm install -g typescript-language-server typescript",
|
||||
},
|
||||
"python": {
|
||||
Command: "pylsp",
|
||||
InstallCmd: "pip install python-lsp-server",
|
||||
},
|
||||
"rust": {
|
||||
Command: "rust-analyzer",
|
||||
InstallCmd: "rustup component add rust-analyzer",
|
||||
},
|
||||
"java": {
|
||||
Command: "jdtls",
|
||||
InstallCmd: "Install Eclipse JDT Language Server",
|
||||
},
|
||||
"c": {
|
||||
Command: "clangd",
|
||||
InstallCmd: "Install clangd from your package manager",
|
||||
},
|
||||
"cpp": {
|
||||
Command: "clangd",
|
||||
InstallCmd: "Install clangd from your package manager",
|
||||
},
|
||||
"php": {
|
||||
Command: "intelephense",
|
||||
Args: []string{"--stdio"},
|
||||
InstallCmd: "npm install -g intelephense",
|
||||
},
|
||||
"ruby": {
|
||||
Command: "solargraph",
|
||||
Args: []string{"stdio"},
|
||||
InstallCmd: "gem install solargraph",
|
||||
},
|
||||
"lua": {
|
||||
Command: "lua-language-server",
|
||||
InstallCmd: "Install lua-language-server from your package manager",
|
||||
},
|
||||
"html": {
|
||||
Command: "vscode-html-language-server",
|
||||
Args: []string{"--stdio"},
|
||||
InstallCmd: "npm install -g vscode-langservers-extracted",
|
||||
},
|
||||
"css": {
|
||||
Command: "vscode-css-language-server",
|
||||
Args: []string{"--stdio"},
|
||||
InstallCmd: "npm install -g vscode-langservers-extracted",
|
||||
},
|
||||
"json": {
|
||||
Command: "vscode-json-language-server",
|
||||
Args: []string{"--stdio"},
|
||||
InstallCmd: "npm install -g vscode-langservers-extracted",
|
||||
},
|
||||
"yaml": {
|
||||
Command: "yaml-language-server",
|
||||
Args: []string{"--stdio"},
|
||||
InstallCmd: "npm install -g yaml-language-server",
|
||||
},
|
||||
}
|
||||
|
||||
// FindLSPServer searches for an LSP server for the given language
|
||||
func FindLSPServer(languageID string) (ServerInfo, error) {
|
||||
// Get server info for the language
|
||||
serverInfo, exists := LanguageServerMap[languageID]
|
||||
if !exists {
|
||||
return ServerInfo{}, fmt.Errorf("no LSP server defined for language: %s", languageID)
|
||||
}
|
||||
|
||||
// Check if the command is in PATH
|
||||
path, err := exec.LookPath(serverInfo.Command)
|
||||
if err == nil {
|
||||
serverInfo.Available = true
|
||||
serverInfo.Path = path
|
||||
slog.Debug("Found LSP server in PATH", "language", languageID, "command", serverInfo.Command, "path", path)
|
||||
return serverInfo, nil
|
||||
}
|
||||
|
||||
// If not in PATH, search in common installation locations
|
||||
paths := getCommonLSPPaths(languageID, serverInfo.Command)
|
||||
for _, searchPath := range paths {
|
||||
if _, err := os.Stat(searchPath); err == nil {
|
||||
// Found the server
|
||||
serverInfo.Available = true
|
||||
serverInfo.Path = searchPath
|
||||
slog.Debug("Found LSP server in common location", "language", languageID, "command", serverInfo.Command, "path", searchPath)
|
||||
return serverInfo, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Server not found
|
||||
slog.Debug("LSP server not found", "language", languageID, "command", serverInfo.Command)
|
||||
return serverInfo, fmt.Errorf("LSP server for %s not found. Install with: %s", languageID, serverInfo.InstallCmd)
|
||||
}
|
||||
|
||||
// getCommonLSPPaths returns common installation paths for LSP servers based on language and OS
|
||||
func getCommonLSPPaths(languageID, command string) []string {
|
||||
var paths []string
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
slog.Error("Failed to get user home directory", "error", err)
|
||||
return paths
|
||||
}
|
||||
|
||||
// Add platform-specific paths
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
// macOS paths
|
||||
paths = append(paths,
|
||||
fmt.Sprintf("/usr/local/bin/%s", command),
|
||||
fmt.Sprintf("/opt/homebrew/bin/%s", command),
|
||||
fmt.Sprintf("%s/.local/bin/%s", homeDir, command),
|
||||
)
|
||||
case "linux":
|
||||
// Linux paths
|
||||
paths = append(paths,
|
||||
fmt.Sprintf("/usr/bin/%s", command),
|
||||
fmt.Sprintf("/usr/local/bin/%s", command),
|
||||
fmt.Sprintf("%s/.local/bin/%s", homeDir, command),
|
||||
)
|
||||
case "windows":
|
||||
// Windows paths
|
||||
paths = append(paths,
|
||||
fmt.Sprintf("%s\\AppData\\Local\\Programs\\%s.exe", homeDir, command),
|
||||
fmt.Sprintf("C:\\Program Files\\%s\\bin\\%s.exe", command, command),
|
||||
)
|
||||
}
|
||||
|
||||
// Add language-specific paths
|
||||
switch languageID {
|
||||
case "go":
|
||||
gopath := os.Getenv("GOPATH")
|
||||
if gopath == "" {
|
||||
gopath = filepath.Join(homeDir, "go")
|
||||
}
|
||||
paths = append(paths, filepath.Join(gopath, "bin", command))
|
||||
if runtime.GOOS == "windows" {
|
||||
paths = append(paths, filepath.Join(gopath, "bin", command+".exe"))
|
||||
}
|
||||
case "typescript", "javascript", "html", "css", "json", "yaml", "php":
|
||||
// Node.js global packages
|
||||
if runtime.GOOS == "windows" {
|
||||
paths = append(paths,
|
||||
fmt.Sprintf("%s\\AppData\\Roaming\\npm\\%s.cmd", homeDir, command),
|
||||
fmt.Sprintf("%s\\AppData\\Roaming\\npm\\node_modules\\.bin\\%s.cmd", homeDir, command),
|
||||
)
|
||||
} else {
|
||||
paths = append(paths,
|
||||
fmt.Sprintf("%s/.npm-global/bin/%s", homeDir, command),
|
||||
fmt.Sprintf("%s/.nvm/versions/node/*/bin/%s", homeDir, command),
|
||||
fmt.Sprintf("/usr/local/lib/node_modules/.bin/%s", command),
|
||||
)
|
||||
}
|
||||
case "python":
|
||||
// Python paths
|
||||
if runtime.GOOS == "windows" {
|
||||
paths = append(paths,
|
||||
fmt.Sprintf("%s\\AppData\\Local\\Programs\\Python\\Python*\\Scripts\\%s.exe", homeDir, command),
|
||||
fmt.Sprintf("C:\\Python*\\Scripts\\%s.exe", command),
|
||||
)
|
||||
} else {
|
||||
paths = append(paths,
|
||||
fmt.Sprintf("%s/.local/bin/%s", homeDir, command),
|
||||
fmt.Sprintf("%s/.pyenv/shims/%s", homeDir, command),
|
||||
fmt.Sprintf("/usr/local/bin/%s", command),
|
||||
)
|
||||
}
|
||||
case "rust":
|
||||
// Rust paths
|
||||
if runtime.GOOS == "windows" {
|
||||
paths = append(paths,
|
||||
fmt.Sprintf("%s\\.rustup\\toolchains\\*\\bin\\%s.exe", homeDir, command),
|
||||
fmt.Sprintf("%s\\.cargo\\bin\\%s.exe", homeDir, command),
|
||||
)
|
||||
} else {
|
||||
paths = append(paths,
|
||||
fmt.Sprintf("%s/.rustup/toolchains/*/bin/%s", homeDir, command),
|
||||
fmt.Sprintf("%s/.cargo/bin/%s", homeDir, command),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Add VSCode extensions path
|
||||
vscodePath := getVSCodeExtensionsPath(homeDir)
|
||||
if vscodePath != "" {
|
||||
paths = append(paths, vscodePath)
|
||||
}
|
||||
|
||||
// Expand any glob patterns in paths
|
||||
var expandedPaths []string
|
||||
for _, path := range paths {
|
||||
if strings.Contains(path, "*") {
|
||||
// This is a glob pattern, expand it
|
||||
matches, err := filepath.Glob(path)
|
||||
if err == nil {
|
||||
expandedPaths = append(expandedPaths, matches...)
|
||||
}
|
||||
} else {
|
||||
expandedPaths = append(expandedPaths, path)
|
||||
}
|
||||
}
|
||||
|
||||
return expandedPaths
|
||||
}
|
||||
|
||||
// getVSCodeExtensionsPath returns the path to VSCode extensions directory
|
||||
func getVSCodeExtensionsPath(homeDir string) string {
|
||||
var basePath string
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
basePath = filepath.Join(homeDir, "Library", "Application Support", "Code", "User", "globalStorage")
|
||||
case "linux":
|
||||
basePath = filepath.Join(homeDir, ".config", "Code", "User", "globalStorage")
|
||||
case "windows":
|
||||
basePath = filepath.Join(homeDir, "AppData", "Roaming", "Code", "User", "globalStorage")
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
||||
// Check if the directory exists
|
||||
if _, err := os.Stat(basePath); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return basePath
|
||||
}
|
||||
|
||||
// ConfigureLSPServers detects languages and configures LSP servers
|
||||
func ConfigureLSPServers(rootDir string) (map[string]ServerInfo, error) {
|
||||
// Detect languages in the project
|
||||
languages, err := DetectLanguages(rootDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to detect languages: %w", err)
|
||||
}
|
||||
|
||||
// Find LSP servers for detected languages
|
||||
servers := make(map[string]ServerInfo)
|
||||
for langID, langInfo := range languages {
|
||||
// Prioritize primary languages but include all languages that have server definitions
|
||||
if !langInfo.IsPrimary && langInfo.FileCount < 3 {
|
||||
// Skip non-primary languages with very few files
|
||||
slog.Debug("Skipping non-primary language with few files", "language", langID, "files", langInfo.FileCount)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if we have a server for this language
|
||||
serverInfo, err := FindLSPServer(langID)
|
||||
if err != nil {
|
||||
slog.Warn("LSP server not found", "language", langID, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Add to the map of configured servers
|
||||
servers[langID] = serverInfo
|
||||
if langInfo.IsPrimary {
|
||||
slog.Info("Configured LSP server for primary language", "language", langID, "command", serverInfo.Command, "path", serverInfo.Path)
|
||||
} else {
|
||||
slog.Info("Configured LSP server for secondary language", "language", langID, "command", serverInfo.Command, "path", serverInfo.Path)
|
||||
}
|
||||
}
|
||||
|
||||
return servers, nil
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
package lsp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/sst/opencode/internal/config"
|
||||
"github.com/sst/opencode/internal/lsp/protocol"
|
||||
"github.com/sst/opencode/internal/lsp/util"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
// Requests
|
||||
|
||||
func HandleWorkspaceConfiguration(params json.RawMessage) (any, error) {
|
||||
return []map[string]any{{}}, nil
|
||||
}
|
||||
|
||||
func HandleRegisterCapability(params json.RawMessage) (any, error) {
|
||||
var registerParams protocol.RegistrationParams
|
||||
if err := json.Unmarshal(params, ®isterParams); err != nil {
|
||||
slog.Error("Error unmarshaling registration params", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, reg := range registerParams.Registrations {
|
||||
switch reg.Method {
|
||||
case "workspace/didChangeWatchedFiles":
|
||||
// Parse the registration options
|
||||
optionsJSON, err := json.Marshal(reg.RegisterOptions)
|
||||
if err != nil {
|
||||
slog.Error("Error marshaling registration options", "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
var options protocol.DidChangeWatchedFilesRegistrationOptions
|
||||
if err := json.Unmarshal(optionsJSON, &options); err != nil {
|
||||
slog.Error("Error unmarshaling registration options", "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Store the file watchers registrations
|
||||
notifyFileWatchRegistration(reg.ID, options.Watchers)
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func HandleApplyEdit(params json.RawMessage) (any, error) {
|
||||
var edit protocol.ApplyWorkspaceEditParams
|
||||
if err := json.Unmarshal(params, &edit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err := util.ApplyWorkspaceEdit(edit.Edit)
|
||||
if err != nil {
|
||||
slog.Error("Error applying workspace edit", "error", err)
|
||||
return protocol.ApplyWorkspaceEditResult{Applied: false, FailureReason: err.Error()}, nil
|
||||
}
|
||||
|
||||
return protocol.ApplyWorkspaceEditResult{Applied: true}, nil
|
||||
}
|
||||
|
||||
// FileWatchRegistrationHandler is a function that will be called when file watch registrations are received
|
||||
type FileWatchRegistrationHandler func(id string, watchers []protocol.FileSystemWatcher)
|
||||
|
||||
// fileWatchHandler holds the current handler for file watch registrations
|
||||
var fileWatchHandler FileWatchRegistrationHandler
|
||||
|
||||
// RegisterFileWatchHandler sets the handler for file watch registrations
|
||||
func RegisterFileWatchHandler(handler FileWatchRegistrationHandler) {
|
||||
fileWatchHandler = handler
|
||||
}
|
||||
|
||||
// notifyFileWatchRegistration notifies the handler about new file watch registrations
|
||||
func notifyFileWatchRegistration(id string, watchers []protocol.FileSystemWatcher) {
|
||||
if fileWatchHandler != nil {
|
||||
fileWatchHandler(id, watchers)
|
||||
}
|
||||
}
|
||||
|
||||
// Notifications
|
||||
|
||||
func HandleServerMessage(params json.RawMessage) {
|
||||
cnf := config.Get()
|
||||
var msg struct {
|
||||
Type int `json:"type"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(params, &msg); err == nil {
|
||||
if cnf.DebugLSP {
|
||||
slog.Debug("Server message", "type", msg.Type, "message", msg.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func HandleDiagnostics(client *Client, params json.RawMessage) {
|
||||
var diagParams protocol.PublishDiagnosticsParams
|
||||
if err := json.Unmarshal(params, &diagParams); err != nil {
|
||||
slog.Error("Error unmarshaling diagnostics params", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
client.diagnosticsMu.Lock()
|
||||
defer client.diagnosticsMu.Unlock()
|
||||
|
||||
client.diagnostics[diagParams.URI] = diagParams.Diagnostics
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
package lsp
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/sst/opencode/internal/lsp/protocol"
|
||||
)
|
||||
|
||||
func DetectLanguageID(uri string) protocol.LanguageKind {
|
||||
ext := strings.ToLower(filepath.Ext(uri))
|
||||
switch ext {
|
||||
case ".abap":
|
||||
return protocol.LangABAP
|
||||
case ".bat":
|
||||
return protocol.LangWindowsBat
|
||||
case ".bib", ".bibtex":
|
||||
return protocol.LangBibTeX
|
||||
case ".clj":
|
||||
return protocol.LangClojure
|
||||
case ".coffee":
|
||||
return protocol.LangCoffeescript
|
||||
case ".c":
|
||||
return protocol.LangC
|
||||
case ".cpp", ".cxx", ".cc", ".c++":
|
||||
return protocol.LangCPP
|
||||
case ".cs":
|
||||
return protocol.LangCSharp
|
||||
case ".css":
|
||||
return protocol.LangCSS
|
||||
case ".d":
|
||||
return protocol.LangD
|
||||
case ".pas", ".pascal":
|
||||
return protocol.LangDelphi
|
||||
case ".diff", ".patch":
|
||||
return protocol.LangDiff
|
||||
case ".dart":
|
||||
return protocol.LangDart
|
||||
case ".dockerfile":
|
||||
return protocol.LangDockerfile
|
||||
case ".ex", ".exs":
|
||||
return protocol.LangElixir
|
||||
case ".erl", ".hrl":
|
||||
return protocol.LangErlang
|
||||
case ".fs", ".fsi", ".fsx", ".fsscript":
|
||||
return protocol.LangFSharp
|
||||
case ".gitcommit":
|
||||
return protocol.LangGitCommit
|
||||
case ".gitrebase":
|
||||
return protocol.LangGitRebase
|
||||
case ".go":
|
||||
return protocol.LangGo
|
||||
case ".groovy":
|
||||
return protocol.LangGroovy
|
||||
case ".hbs", ".handlebars":
|
||||
return protocol.LangHandlebars
|
||||
case ".hs":
|
||||
return protocol.LangHaskell
|
||||
case ".html", ".htm":
|
||||
return protocol.LangHTML
|
||||
case ".ini":
|
||||
return protocol.LangIni
|
||||
case ".java":
|
||||
return protocol.LangJava
|
||||
case ".js":
|
||||
return protocol.LangJavaScript
|
||||
case ".jsx":
|
||||
return protocol.LangJavaScriptReact
|
||||
case ".json":
|
||||
return protocol.LangJSON
|
||||
case ".tex", ".latex":
|
||||
return protocol.LangLaTeX
|
||||
case ".less":
|
||||
return protocol.LangLess
|
||||
case ".lua":
|
||||
return protocol.LangLua
|
||||
case ".makefile", "makefile":
|
||||
return protocol.LangMakefile
|
||||
case ".md", ".markdown":
|
||||
return protocol.LangMarkdown
|
||||
case ".m":
|
||||
return protocol.LangObjectiveC
|
||||
case ".mm":
|
||||
return protocol.LangObjectiveCPP
|
||||
case ".pl":
|
||||
return protocol.LangPerl
|
||||
case ".pm":
|
||||
return protocol.LangPerl6
|
||||
case ".php":
|
||||
return protocol.LangPHP
|
||||
case ".ps1", ".psm1":
|
||||
return protocol.LangPowershell
|
||||
case ".pug", ".jade":
|
||||
return protocol.LangPug
|
||||
case ".py":
|
||||
return protocol.LangPython
|
||||
case ".r":
|
||||
return protocol.LangR
|
||||
case ".cshtml", ".razor":
|
||||
return protocol.LangRazor
|
||||
case ".rb":
|
||||
return protocol.LangRuby
|
||||
case ".rs":
|
||||
return protocol.LangRust
|
||||
case ".scss":
|
||||
return protocol.LangSCSS
|
||||
case ".sass":
|
||||
return protocol.LangSASS
|
||||
case ".scala":
|
||||
return protocol.LangScala
|
||||
case ".shader":
|
||||
return protocol.LangShaderLab
|
||||
case ".sh", ".bash", ".zsh", ".ksh":
|
||||
return protocol.LangShellScript
|
||||
case ".sql":
|
||||
return protocol.LangSQL
|
||||
case ".swift":
|
||||
return protocol.LangSwift
|
||||
case ".ts":
|
||||
return protocol.LangTypeScript
|
||||
case ".tsx":
|
||||
return protocol.LangTypeScriptReact
|
||||
case ".xml":
|
||||
return protocol.LangXML
|
||||
case ".xsl":
|
||||
return protocol.LangXSL
|
||||
case ".yaml", ".yml":
|
||||
return protocol.LangYAML
|
||||
default:
|
||||
return protocol.LanguageKind("") // Unknown language
|
||||
}
|
||||
}
|
||||
@@ -1,554 +0,0 @@
|
||||
// Generated code. Do not edit
|
||||
package lsp
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/sst/opencode/internal/lsp/protocol"
|
||||
)
|
||||
|
||||
// Implementation sends a textDocument/implementation request to the LSP server.
|
||||
// A request to resolve the implementation locations of a symbol at a given text document position. The request's parameter is of type TextDocumentPositionParams the response is of type Definition or a Thenable that resolves to such.
|
||||
func (c *Client) Implementation(ctx context.Context, params protocol.ImplementationParams) (protocol.Or_Result_textDocument_implementation, error) {
|
||||
var result protocol.Or_Result_textDocument_implementation
|
||||
err := c.Call(ctx, "textDocument/implementation", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// TypeDefinition sends a textDocument/typeDefinition request to the LSP server.
|
||||
// A request to resolve the type definition locations of a symbol at a given text document position. The request's parameter is of type TextDocumentPositionParams the response is of type Definition or a Thenable that resolves to such.
|
||||
func (c *Client) TypeDefinition(ctx context.Context, params protocol.TypeDefinitionParams) (protocol.Or_Result_textDocument_typeDefinition, error) {
|
||||
var result protocol.Or_Result_textDocument_typeDefinition
|
||||
err := c.Call(ctx, "textDocument/typeDefinition", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// DocumentColor sends a textDocument/documentColor request to the LSP server.
|
||||
// A request to list all color symbols found in a given text document. The request's parameter is of type DocumentColorParams the response is of type ColorInformation ColorInformation[] or a Thenable that resolves to such.
|
||||
func (c *Client) DocumentColor(ctx context.Context, params protocol.DocumentColorParams) ([]protocol.ColorInformation, error) {
|
||||
var result []protocol.ColorInformation
|
||||
err := c.Call(ctx, "textDocument/documentColor", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// ColorPresentation sends a textDocument/colorPresentation request to the LSP server.
|
||||
// A request to list all presentation for a color. The request's parameter is of type ColorPresentationParams the response is of type ColorInformation ColorInformation[] or a Thenable that resolves to such.
|
||||
func (c *Client) ColorPresentation(ctx context.Context, params protocol.ColorPresentationParams) ([]protocol.ColorPresentation, error) {
|
||||
var result []protocol.ColorPresentation
|
||||
err := c.Call(ctx, "textDocument/colorPresentation", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// FoldingRange sends a textDocument/foldingRange request to the LSP server.
|
||||
// A request to provide folding ranges in a document. The request's parameter is of type FoldingRangeParams, the response is of type FoldingRangeList or a Thenable that resolves to such.
|
||||
func (c *Client) FoldingRange(ctx context.Context, params protocol.FoldingRangeParams) ([]protocol.FoldingRange, error) {
|
||||
var result []protocol.FoldingRange
|
||||
err := c.Call(ctx, "textDocument/foldingRange", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// Declaration sends a textDocument/declaration request to the LSP server.
|
||||
// A request to resolve the type definition locations of a symbol at a given text document position. The request's parameter is of type TextDocumentPositionParams the response is of type Declaration or a typed array of DeclarationLink or a Thenable that resolves to such.
|
||||
func (c *Client) Declaration(ctx context.Context, params protocol.DeclarationParams) (protocol.Or_Result_textDocument_declaration, error) {
|
||||
var result protocol.Or_Result_textDocument_declaration
|
||||
err := c.Call(ctx, "textDocument/declaration", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// SelectionRange sends a textDocument/selectionRange request to the LSP server.
|
||||
// A request to provide selection ranges in a document. The request's parameter is of type SelectionRangeParams, the response is of type SelectionRange SelectionRange[] or a Thenable that resolves to such.
|
||||
func (c *Client) SelectionRange(ctx context.Context, params protocol.SelectionRangeParams) ([]protocol.SelectionRange, error) {
|
||||
var result []protocol.SelectionRange
|
||||
err := c.Call(ctx, "textDocument/selectionRange", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// PrepareCallHierarchy sends a textDocument/prepareCallHierarchy request to the LSP server.
|
||||
// A request to result a CallHierarchyItem in a document at a given position. Can be used as an input to an incoming or outgoing call hierarchy. Since 3.16.0
|
||||
func (c *Client) PrepareCallHierarchy(ctx context.Context, params protocol.CallHierarchyPrepareParams) ([]protocol.CallHierarchyItem, error) {
|
||||
var result []protocol.CallHierarchyItem
|
||||
err := c.Call(ctx, "textDocument/prepareCallHierarchy", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// IncomingCalls sends a callHierarchy/incomingCalls request to the LSP server.
|
||||
// A request to resolve the incoming calls for a given CallHierarchyItem. Since 3.16.0
|
||||
func (c *Client) IncomingCalls(ctx context.Context, params protocol.CallHierarchyIncomingCallsParams) ([]protocol.CallHierarchyIncomingCall, error) {
|
||||
var result []protocol.CallHierarchyIncomingCall
|
||||
err := c.Call(ctx, "callHierarchy/incomingCalls", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// OutgoingCalls sends a callHierarchy/outgoingCalls request to the LSP server.
|
||||
// A request to resolve the outgoing calls for a given CallHierarchyItem. Since 3.16.0
|
||||
func (c *Client) OutgoingCalls(ctx context.Context, params protocol.CallHierarchyOutgoingCallsParams) ([]protocol.CallHierarchyOutgoingCall, error) {
|
||||
var result []protocol.CallHierarchyOutgoingCall
|
||||
err := c.Call(ctx, "callHierarchy/outgoingCalls", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// SemanticTokensFull sends a textDocument/semanticTokens/full request to the LSP server.
|
||||
// Since 3.16.0
|
||||
func (c *Client) SemanticTokensFull(ctx context.Context, params protocol.SemanticTokensParams) (protocol.SemanticTokens, error) {
|
||||
var result protocol.SemanticTokens
|
||||
err := c.Call(ctx, "textDocument/semanticTokens/full", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// SemanticTokensFullDelta sends a textDocument/semanticTokens/full/delta request to the LSP server.
|
||||
// Since 3.16.0
|
||||
func (c *Client) SemanticTokensFullDelta(ctx context.Context, params protocol.SemanticTokensDeltaParams) (protocol.Or_Result_textDocument_semanticTokens_full_delta, error) {
|
||||
var result protocol.Or_Result_textDocument_semanticTokens_full_delta
|
||||
err := c.Call(ctx, "textDocument/semanticTokens/full/delta", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// SemanticTokensRange sends a textDocument/semanticTokens/range request to the LSP server.
|
||||
// Since 3.16.0
|
||||
func (c *Client) SemanticTokensRange(ctx context.Context, params protocol.SemanticTokensRangeParams) (protocol.SemanticTokens, error) {
|
||||
var result protocol.SemanticTokens
|
||||
err := c.Call(ctx, "textDocument/semanticTokens/range", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// LinkedEditingRange sends a textDocument/linkedEditingRange request to the LSP server.
|
||||
// A request to provide ranges that can be edited together. Since 3.16.0
|
||||
func (c *Client) LinkedEditingRange(ctx context.Context, params protocol.LinkedEditingRangeParams) (protocol.LinkedEditingRanges, error) {
|
||||
var result protocol.LinkedEditingRanges
|
||||
err := c.Call(ctx, "textDocument/linkedEditingRange", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// WillCreateFiles sends a workspace/willCreateFiles request to the LSP server.
|
||||
// The will create files request is sent from the client to the server before files are actually created as long as the creation is triggered from within the client. The request can return a WorkspaceEdit which will be applied to workspace before the files are created. Hence the WorkspaceEdit can not manipulate the content of the file to be created. Since 3.16.0
|
||||
func (c *Client) WillCreateFiles(ctx context.Context, params protocol.CreateFilesParams) (protocol.WorkspaceEdit, error) {
|
||||
var result protocol.WorkspaceEdit
|
||||
err := c.Call(ctx, "workspace/willCreateFiles", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// WillRenameFiles sends a workspace/willRenameFiles request to the LSP server.
|
||||
// The will rename files request is sent from the client to the server before files are actually renamed as long as the rename is triggered from within the client. Since 3.16.0
|
||||
func (c *Client) WillRenameFiles(ctx context.Context, params protocol.RenameFilesParams) (protocol.WorkspaceEdit, error) {
|
||||
var result protocol.WorkspaceEdit
|
||||
err := c.Call(ctx, "workspace/willRenameFiles", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// WillDeleteFiles sends a workspace/willDeleteFiles request to the LSP server.
|
||||
// The did delete files notification is sent from the client to the server when files were deleted from within the client. Since 3.16.0
|
||||
func (c *Client) WillDeleteFiles(ctx context.Context, params protocol.DeleteFilesParams) (protocol.WorkspaceEdit, error) {
|
||||
var result protocol.WorkspaceEdit
|
||||
err := c.Call(ctx, "workspace/willDeleteFiles", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// Moniker sends a textDocument/moniker request to the LSP server.
|
||||
// A request to get the moniker of a symbol at a given text document position. The request parameter is of type TextDocumentPositionParams. The response is of type Moniker Moniker[] or null.
|
||||
func (c *Client) Moniker(ctx context.Context, params protocol.MonikerParams) ([]protocol.Moniker, error) {
|
||||
var result []protocol.Moniker
|
||||
err := c.Call(ctx, "textDocument/moniker", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// PrepareTypeHierarchy sends a textDocument/prepareTypeHierarchy request to the LSP server.
|
||||
// A request to result a TypeHierarchyItem in a document at a given position. Can be used as an input to a subtypes or supertypes type hierarchy. Since 3.17.0
|
||||
func (c *Client) PrepareTypeHierarchy(ctx context.Context, params protocol.TypeHierarchyPrepareParams) ([]protocol.TypeHierarchyItem, error) {
|
||||
var result []protocol.TypeHierarchyItem
|
||||
err := c.Call(ctx, "textDocument/prepareTypeHierarchy", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// Supertypes sends a typeHierarchy/supertypes request to the LSP server.
|
||||
// A request to resolve the supertypes for a given TypeHierarchyItem. Since 3.17.0
|
||||
func (c *Client) Supertypes(ctx context.Context, params protocol.TypeHierarchySupertypesParams) ([]protocol.TypeHierarchyItem, error) {
|
||||
var result []protocol.TypeHierarchyItem
|
||||
err := c.Call(ctx, "typeHierarchy/supertypes", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// Subtypes sends a typeHierarchy/subtypes request to the LSP server.
|
||||
// A request to resolve the subtypes for a given TypeHierarchyItem. Since 3.17.0
|
||||
func (c *Client) Subtypes(ctx context.Context, params protocol.TypeHierarchySubtypesParams) ([]protocol.TypeHierarchyItem, error) {
|
||||
var result []protocol.TypeHierarchyItem
|
||||
err := c.Call(ctx, "typeHierarchy/subtypes", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// InlineValue sends a textDocument/inlineValue request to the LSP server.
|
||||
// A request to provide inline values in a document. The request's parameter is of type InlineValueParams, the response is of type InlineValue InlineValue[] or a Thenable that resolves to such. Since 3.17.0
|
||||
func (c *Client) InlineValue(ctx context.Context, params protocol.InlineValueParams) ([]protocol.InlineValue, error) {
|
||||
var result []protocol.InlineValue
|
||||
err := c.Call(ctx, "textDocument/inlineValue", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// InlayHint sends a textDocument/inlayHint request to the LSP server.
|
||||
// A request to provide inlay hints in a document. The request's parameter is of type InlayHintsParams, the response is of type InlayHint InlayHint[] or a Thenable that resolves to such. Since 3.17.0
|
||||
func (c *Client) InlayHint(ctx context.Context, params protocol.InlayHintParams) ([]protocol.InlayHint, error) {
|
||||
var result []protocol.InlayHint
|
||||
err := c.Call(ctx, "textDocument/inlayHint", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// Resolve sends a inlayHint/resolve request to the LSP server.
|
||||
// A request to resolve additional properties for an inlay hint. The request's parameter is of type InlayHint, the response is of type InlayHint or a Thenable that resolves to such. Since 3.17.0
|
||||
func (c *Client) Resolve(ctx context.Context, params protocol.InlayHint) (protocol.InlayHint, error) {
|
||||
var result protocol.InlayHint
|
||||
err := c.Call(ctx, "inlayHint/resolve", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// Diagnostic sends a textDocument/diagnostic request to the LSP server.
|
||||
// The document diagnostic request definition. Since 3.17.0
|
||||
func (c *Client) Diagnostic(ctx context.Context, params protocol.DocumentDiagnosticParams) (protocol.DocumentDiagnosticReport, error) {
|
||||
var result protocol.DocumentDiagnosticReport
|
||||
err := c.Call(ctx, "textDocument/diagnostic", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// DiagnosticWorkspace sends a workspace/diagnostic request to the LSP server.
|
||||
// The workspace diagnostic request definition. Since 3.17.0
|
||||
func (c *Client) DiagnosticWorkspace(ctx context.Context, params protocol.WorkspaceDiagnosticParams) (protocol.WorkspaceDiagnosticReport, error) {
|
||||
var result protocol.WorkspaceDiagnosticReport
|
||||
err := c.Call(ctx, "workspace/diagnostic", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// InlineCompletion sends a textDocument/inlineCompletion request to the LSP server.
|
||||
// A request to provide inline completions in a document. The request's parameter is of type InlineCompletionParams, the response is of type InlineCompletion InlineCompletion[] or a Thenable that resolves to such. Since 3.18.0 PROPOSED
|
||||
func (c *Client) InlineCompletion(ctx context.Context, params protocol.InlineCompletionParams) (protocol.Or_Result_textDocument_inlineCompletion, error) {
|
||||
var result protocol.Or_Result_textDocument_inlineCompletion
|
||||
err := c.Call(ctx, "textDocument/inlineCompletion", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// TextDocumentContent sends a workspace/textDocumentContent request to the LSP server.
|
||||
// The workspace/textDocumentContent request is sent from the client to the server to request the content of a text document. Since 3.18.0 PROPOSED
|
||||
func (c *Client) TextDocumentContent(ctx context.Context, params protocol.TextDocumentContentParams) (string, error) {
|
||||
var result string
|
||||
err := c.Call(ctx, "workspace/textDocumentContent", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// Initialize sends a initialize request to the LSP server.
|
||||
// The initialize request is sent from the client to the server. It is sent once as the request after starting up the server. The requests parameter is of type InitializeParams the response if of type InitializeResult of a Thenable that resolves to such.
|
||||
func (c *Client) Initialize(ctx context.Context, params protocol.ParamInitialize) (protocol.InitializeResult, error) {
|
||||
var result protocol.InitializeResult
|
||||
err := c.Call(ctx, "initialize", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// Shutdown sends a shutdown request to the LSP server.
|
||||
// A shutdown request is sent from the client to the server. It is sent once when the client decides to shutdown the server. The only notification that is sent after a shutdown request is the exit event.
|
||||
func (c *Client) Shutdown(ctx context.Context) error {
|
||||
return c.Call(ctx, "shutdown", nil, nil)
|
||||
}
|
||||
|
||||
// WillSaveWaitUntil sends a textDocument/willSaveWaitUntil request to the LSP server.
|
||||
// A document will save request is sent from the client to the server before the document is actually saved. The request can return an array of TextEdits which will be applied to the text document before it is saved. Please note that clients might drop results if computing the text edits took too long or if a server constantly fails on this request. This is done to keep the save fast and reliable.
|
||||
func (c *Client) WillSaveWaitUntil(ctx context.Context, params protocol.WillSaveTextDocumentParams) ([]protocol.TextEdit, error) {
|
||||
var result []protocol.TextEdit
|
||||
err := c.Call(ctx, "textDocument/willSaveWaitUntil", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// Completion sends a textDocument/completion request to the LSP server.
|
||||
// Request to request completion at a given text document position. The request's parameter is of type TextDocumentPosition the response is of type CompletionItem CompletionItem[] or CompletionList or a Thenable that resolves to such. The request can delay the computation of the CompletionItem.detail detail and CompletionItem.documentation documentation properties to the completionItem/resolve request. However, properties that are needed for the initial sorting and filtering, like sortText, filterText, insertText, and textEdit, must not be changed during resolve.
|
||||
func (c *Client) Completion(ctx context.Context, params protocol.CompletionParams) (protocol.Or_Result_textDocument_completion, error) {
|
||||
var result protocol.Or_Result_textDocument_completion
|
||||
err := c.Call(ctx, "textDocument/completion", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// ResolveCompletionItem sends a completionItem/resolve request to the LSP server.
|
||||
// Request to resolve additional information for a given completion item.The request's parameter is of type CompletionItem the response is of type CompletionItem or a Thenable that resolves to such.
|
||||
func (c *Client) ResolveCompletionItem(ctx context.Context, params protocol.CompletionItem) (protocol.CompletionItem, error) {
|
||||
var result protocol.CompletionItem
|
||||
err := c.Call(ctx, "completionItem/resolve", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// Hover sends a textDocument/hover request to the LSP server.
|
||||
// Request to request hover information at a given text document position. The request's parameter is of type TextDocumentPosition the response is of type Hover or a Thenable that resolves to such.
|
||||
func (c *Client) Hover(ctx context.Context, params protocol.HoverParams) (protocol.Hover, error) {
|
||||
var result protocol.Hover
|
||||
err := c.Call(ctx, "textDocument/hover", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// SignatureHelp sends a textDocument/signatureHelp request to the LSP server.
|
||||
func (c *Client) SignatureHelp(ctx context.Context, params protocol.SignatureHelpParams) (protocol.SignatureHelp, error) {
|
||||
var result protocol.SignatureHelp
|
||||
err := c.Call(ctx, "textDocument/signatureHelp", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// Definition sends a textDocument/definition request to the LSP server.
|
||||
// A request to resolve the definition location of a symbol at a given text document position. The request's parameter is of type TextDocumentPosition the response is of either type Definition or a typed array of DefinitionLink or a Thenable that resolves to such.
|
||||
func (c *Client) Definition(ctx context.Context, params protocol.DefinitionParams) (protocol.Or_Result_textDocument_definition, error) {
|
||||
var result protocol.Or_Result_textDocument_definition
|
||||
err := c.Call(ctx, "textDocument/definition", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// References sends a textDocument/references request to the LSP server.
|
||||
// A request to resolve project-wide references for the symbol denoted by the given text document position. The request's parameter is of type ReferenceParams the response is of type Location Location[] or a Thenable that resolves to such.
|
||||
func (c *Client) References(ctx context.Context, params protocol.ReferenceParams) ([]protocol.Location, error) {
|
||||
var result []protocol.Location
|
||||
err := c.Call(ctx, "textDocument/references", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// DocumentHighlight sends a textDocument/documentHighlight request to the LSP server.
|
||||
// Request to resolve a DocumentHighlight for a given text document position. The request's parameter is of type TextDocumentPosition the request response is an array of type DocumentHighlight or a Thenable that resolves to such.
|
||||
func (c *Client) DocumentHighlight(ctx context.Context, params protocol.DocumentHighlightParams) ([]protocol.DocumentHighlight, error) {
|
||||
var result []protocol.DocumentHighlight
|
||||
err := c.Call(ctx, "textDocument/documentHighlight", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// DocumentSymbol sends a textDocument/documentSymbol request to the LSP server.
|
||||
// A request to list all symbols found in a given text document. The request's parameter is of type TextDocumentIdentifier the response is of type SymbolInformation SymbolInformation[] or a Thenable that resolves to such.
|
||||
func (c *Client) DocumentSymbol(ctx context.Context, params protocol.DocumentSymbolParams) (protocol.Or_Result_textDocument_documentSymbol, error) {
|
||||
var result protocol.Or_Result_textDocument_documentSymbol
|
||||
err := c.Call(ctx, "textDocument/documentSymbol", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// CodeAction sends a textDocument/codeAction request to the LSP server.
|
||||
// A request to provide commands for the given text document and range.
|
||||
func (c *Client) CodeAction(ctx context.Context, params protocol.CodeActionParams) ([]protocol.Or_Result_textDocument_codeAction_Item0_Elem, error) {
|
||||
var result []protocol.Or_Result_textDocument_codeAction_Item0_Elem
|
||||
err := c.Call(ctx, "textDocument/codeAction", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// ResolveCodeAction sends a codeAction/resolve request to the LSP server.
|
||||
// Request to resolve additional information for a given code action.The request's parameter is of type CodeAction the response is of type CodeAction or a Thenable that resolves to such.
|
||||
func (c *Client) ResolveCodeAction(ctx context.Context, params protocol.CodeAction) (protocol.CodeAction, error) {
|
||||
var result protocol.CodeAction
|
||||
err := c.Call(ctx, "codeAction/resolve", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// Symbol sends a workspace/symbol request to the LSP server.
|
||||
// A request to list project-wide symbols matching the query string given by the WorkspaceSymbolParams. The response is of type SymbolInformation SymbolInformation[] or a Thenable that resolves to such. Since 3.17.0 - support for WorkspaceSymbol in the returned data. Clients need to advertise support for WorkspaceSymbols via the client capability workspace.symbol.resolveSupport.
|
||||
func (c *Client) Symbol(ctx context.Context, params protocol.WorkspaceSymbolParams) (protocol.Or_Result_workspace_symbol, error) {
|
||||
var result protocol.Or_Result_workspace_symbol
|
||||
err := c.Call(ctx, "workspace/symbol", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// ResolveWorkspaceSymbol sends a workspaceSymbol/resolve request to the LSP server.
|
||||
// A request to resolve the range inside the workspace symbol's location. Since 3.17.0
|
||||
func (c *Client) ResolveWorkspaceSymbol(ctx context.Context, params protocol.WorkspaceSymbol) (protocol.WorkspaceSymbol, error) {
|
||||
var result protocol.WorkspaceSymbol
|
||||
err := c.Call(ctx, "workspaceSymbol/resolve", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// CodeLens sends a textDocument/codeLens request to the LSP server.
|
||||
// A request to provide code lens for the given text document.
|
||||
func (c *Client) CodeLens(ctx context.Context, params protocol.CodeLensParams) ([]protocol.CodeLens, error) {
|
||||
var result []protocol.CodeLens
|
||||
err := c.Call(ctx, "textDocument/codeLens", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// ResolveCodeLens sends a codeLens/resolve request to the LSP server.
|
||||
// A request to resolve a command for a given code lens.
|
||||
func (c *Client) ResolveCodeLens(ctx context.Context, params protocol.CodeLens) (protocol.CodeLens, error) {
|
||||
var result protocol.CodeLens
|
||||
err := c.Call(ctx, "codeLens/resolve", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// DocumentLink sends a textDocument/documentLink request to the LSP server.
|
||||
// A request to provide document links
|
||||
func (c *Client) DocumentLink(ctx context.Context, params protocol.DocumentLinkParams) ([]protocol.DocumentLink, error) {
|
||||
var result []protocol.DocumentLink
|
||||
err := c.Call(ctx, "textDocument/documentLink", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// ResolveDocumentLink sends a documentLink/resolve request to the LSP server.
|
||||
// Request to resolve additional information for a given document link. The request's parameter is of type DocumentLink the response is of type DocumentLink or a Thenable that resolves to such.
|
||||
func (c *Client) ResolveDocumentLink(ctx context.Context, params protocol.DocumentLink) (protocol.DocumentLink, error) {
|
||||
var result protocol.DocumentLink
|
||||
err := c.Call(ctx, "documentLink/resolve", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// Formatting sends a textDocument/formatting request to the LSP server.
|
||||
// A request to format a whole document.
|
||||
func (c *Client) Formatting(ctx context.Context, params protocol.DocumentFormattingParams) ([]protocol.TextEdit, error) {
|
||||
var result []protocol.TextEdit
|
||||
err := c.Call(ctx, "textDocument/formatting", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// RangeFormatting sends a textDocument/rangeFormatting request to the LSP server.
|
||||
// A request to format a range in a document.
|
||||
func (c *Client) RangeFormatting(ctx context.Context, params protocol.DocumentRangeFormattingParams) ([]protocol.TextEdit, error) {
|
||||
var result []protocol.TextEdit
|
||||
err := c.Call(ctx, "textDocument/rangeFormatting", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// RangesFormatting sends a textDocument/rangesFormatting request to the LSP server.
|
||||
// A request to format ranges in a document. Since 3.18.0 PROPOSED
|
||||
func (c *Client) RangesFormatting(ctx context.Context, params protocol.DocumentRangesFormattingParams) ([]protocol.TextEdit, error) {
|
||||
var result []protocol.TextEdit
|
||||
err := c.Call(ctx, "textDocument/rangesFormatting", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// OnTypeFormatting sends a textDocument/onTypeFormatting request to the LSP server.
|
||||
// A request to format a document on type.
|
||||
func (c *Client) OnTypeFormatting(ctx context.Context, params protocol.DocumentOnTypeFormattingParams) ([]protocol.TextEdit, error) {
|
||||
var result []protocol.TextEdit
|
||||
err := c.Call(ctx, "textDocument/onTypeFormatting", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// Rename sends a textDocument/rename request to the LSP server.
|
||||
// A request to rename a symbol.
|
||||
func (c *Client) Rename(ctx context.Context, params protocol.RenameParams) (protocol.WorkspaceEdit, error) {
|
||||
var result protocol.WorkspaceEdit
|
||||
err := c.Call(ctx, "textDocument/rename", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// PrepareRename sends a textDocument/prepareRename request to the LSP server.
|
||||
// A request to test and perform the setup necessary for a rename. Since 3.16 - support for default behavior
|
||||
func (c *Client) PrepareRename(ctx context.Context, params protocol.PrepareRenameParams) (protocol.PrepareRenameResult, error) {
|
||||
var result protocol.PrepareRenameResult
|
||||
err := c.Call(ctx, "textDocument/prepareRename", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// ExecuteCommand sends a workspace/executeCommand request to the LSP server.
|
||||
// A request send from the client to the server to execute a command. The request might return a workspace edit which the client will apply to the workspace.
|
||||
func (c *Client) ExecuteCommand(ctx context.Context, params protocol.ExecuteCommandParams) (any, error) {
|
||||
var result any
|
||||
err := c.Call(ctx, "workspace/executeCommand", params, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// DidChangeWorkspaceFolders sends a workspace/didChangeWorkspaceFolders notification to the LSP server.
|
||||
// The workspace/didChangeWorkspaceFolders notification is sent from the client to the server when the workspace folder configuration changes.
|
||||
func (c *Client) DidChangeWorkspaceFolders(ctx context.Context, params protocol.DidChangeWorkspaceFoldersParams) error {
|
||||
return c.Notify(ctx, "workspace/didChangeWorkspaceFolders", params)
|
||||
}
|
||||
|
||||
// WorkDoneProgressCancel sends a window/workDoneProgress/cancel notification to the LSP server.
|
||||
// The window/workDoneProgress/cancel notification is sent from the client to the server to cancel a progress initiated on the server side.
|
||||
func (c *Client) WorkDoneProgressCancel(ctx context.Context, params protocol.WorkDoneProgressCancelParams) error {
|
||||
return c.Notify(ctx, "window/workDoneProgress/cancel", params)
|
||||
}
|
||||
|
||||
// DidCreateFiles sends a workspace/didCreateFiles notification to the LSP server.
|
||||
// The did create files notification is sent from the client to the server when files were created from within the client. Since 3.16.0
|
||||
func (c *Client) DidCreateFiles(ctx context.Context, params protocol.CreateFilesParams) error {
|
||||
return c.Notify(ctx, "workspace/didCreateFiles", params)
|
||||
}
|
||||
|
||||
// DidRenameFiles sends a workspace/didRenameFiles notification to the LSP server.
|
||||
// The did rename files notification is sent from the client to the server when files were renamed from within the client. Since 3.16.0
|
||||
func (c *Client) DidRenameFiles(ctx context.Context, params protocol.RenameFilesParams) error {
|
||||
return c.Notify(ctx, "workspace/didRenameFiles", params)
|
||||
}
|
||||
|
||||
// DidDeleteFiles sends a workspace/didDeleteFiles notification to the LSP server.
|
||||
// The will delete files request is sent from the client to the server before files are actually deleted as long as the deletion is triggered from within the client. Since 3.16.0
|
||||
func (c *Client) DidDeleteFiles(ctx context.Context, params protocol.DeleteFilesParams) error {
|
||||
return c.Notify(ctx, "workspace/didDeleteFiles", params)
|
||||
}
|
||||
|
||||
// DidOpenNotebookDocument sends a notebookDocument/didOpen notification to the LSP server.
|
||||
// A notification sent when a notebook opens. Since 3.17.0
|
||||
func (c *Client) DidOpenNotebookDocument(ctx context.Context, params protocol.DidOpenNotebookDocumentParams) error {
|
||||
return c.Notify(ctx, "notebookDocument/didOpen", params)
|
||||
}
|
||||
|
||||
// DidChangeNotebookDocument sends a notebookDocument/didChange notification to the LSP server.
|
||||
func (c *Client) DidChangeNotebookDocument(ctx context.Context, params protocol.DidChangeNotebookDocumentParams) error {
|
||||
return c.Notify(ctx, "notebookDocument/didChange", params)
|
||||
}
|
||||
|
||||
// DidSaveNotebookDocument sends a notebookDocument/didSave notification to the LSP server.
|
||||
// A notification sent when a notebook document is saved. Since 3.17.0
|
||||
func (c *Client) DidSaveNotebookDocument(ctx context.Context, params protocol.DidSaveNotebookDocumentParams) error {
|
||||
return c.Notify(ctx, "notebookDocument/didSave", params)
|
||||
}
|
||||
|
||||
// DidCloseNotebookDocument sends a notebookDocument/didClose notification to the LSP server.
|
||||
// A notification sent when a notebook closes. Since 3.17.0
|
||||
func (c *Client) DidCloseNotebookDocument(ctx context.Context, params protocol.DidCloseNotebookDocumentParams) error {
|
||||
return c.Notify(ctx, "notebookDocument/didClose", params)
|
||||
}
|
||||
|
||||
// Initialized sends a initialized notification to the LSP server.
|
||||
// The initialized notification is sent from the client to the server after the client is fully initialized and the server is allowed to send requests from the server to the client.
|
||||
func (c *Client) Initialized(ctx context.Context, params protocol.InitializedParams) error {
|
||||
return c.Notify(ctx, "initialized", params)
|
||||
}
|
||||
|
||||
// Exit sends a exit notification to the LSP server.
|
||||
// The exit event is sent from the client to the server to ask the server to exit its process.
|
||||
func (c *Client) Exit(ctx context.Context) error {
|
||||
return c.Notify(ctx, "exit", nil)
|
||||
}
|
||||
|
||||
// DidChangeConfiguration sends a workspace/didChangeConfiguration notification to the LSP server.
|
||||
// The configuration change notification is sent from the client to the server when the client's configuration has changed. The notification contains the changed configuration as defined by the language client.
|
||||
func (c *Client) DidChangeConfiguration(ctx context.Context, params protocol.DidChangeConfigurationParams) error {
|
||||
return c.Notify(ctx, "workspace/didChangeConfiguration", params)
|
||||
}
|
||||
|
||||
// DidOpen sends a textDocument/didOpen notification to the LSP server.
|
||||
// The document open notification is sent from the client to the server to signal newly opened text documents. The document's truth is now managed by the client and the server must not try to read the document's truth using the document's uri. Open in this sense means it is managed by the client. It doesn't necessarily mean that its content is presented in an editor. An open notification must not be sent more than once without a corresponding close notification send before. This means open and close notification must be balanced and the max open count is one.
|
||||
func (c *Client) DidOpen(ctx context.Context, params protocol.DidOpenTextDocumentParams) error {
|
||||
return c.Notify(ctx, "textDocument/didOpen", params)
|
||||
}
|
||||
|
||||
// DidChange sends a textDocument/didChange notification to the LSP server.
|
||||
// The document change notification is sent from the client to the server to signal changes to a text document.
|
||||
func (c *Client) DidChange(ctx context.Context, params protocol.DidChangeTextDocumentParams) error {
|
||||
return c.Notify(ctx, "textDocument/didChange", params)
|
||||
}
|
||||
|
||||
// DidClose sends a textDocument/didClose notification to the LSP server.
|
||||
// The document close notification is sent from the client to the server when the document got closed in the client. The document's truth now exists where the document's uri points to (e.g. if the document's uri is a file uri the truth now exists on disk). As with the open notification the close notification is about managing the document's content. Receiving a close notification doesn't mean that the document was open in an editor before. A close notification requires a previous open notification to be sent.
|
||||
func (c *Client) DidClose(ctx context.Context, params protocol.DidCloseTextDocumentParams) error {
|
||||
return c.Notify(ctx, "textDocument/didClose", params)
|
||||
}
|
||||
|
||||
// DidSave sends a textDocument/didSave notification to the LSP server.
|
||||
// The document save notification is sent from the client to the server when the document got saved in the client.
|
||||
func (c *Client) DidSave(ctx context.Context, params protocol.DidSaveTextDocumentParams) error {
|
||||
return c.Notify(ctx, "textDocument/didSave", params)
|
||||
}
|
||||
|
||||
// WillSave sends a textDocument/willSave notification to the LSP server.
|
||||
// A document will save notification is sent from the client to the server before the document is actually saved.
|
||||
func (c *Client) WillSave(ctx context.Context, params protocol.WillSaveTextDocumentParams) error {
|
||||
return c.Notify(ctx, "textDocument/willSave", params)
|
||||
}
|
||||
|
||||
// DidChangeWatchedFiles sends a workspace/didChangeWatchedFiles notification to the LSP server.
|
||||
// The watched files notification is sent from the client to the server when the client detects changes to file watched by the language client.
|
||||
func (c *Client) DidChangeWatchedFiles(ctx context.Context, params protocol.DidChangeWatchedFilesParams) error {
|
||||
return c.Notify(ctx, "workspace/didChangeWatchedFiles", params)
|
||||
}
|
||||
|
||||
// SetTrace sends a $/setTrace notification to the LSP server.
|
||||
func (c *Client) SetTrace(ctx context.Context, params protocol.SetTraceParams) error {
|
||||
return c.Notify(ctx, "$/setTrace", params)
|
||||
}
|
||||
|
||||
// Progress sends a $/progress notification to the LSP server.
|
||||
func (c *Client) Progress(ctx context.Context, params protocol.ProgressParams) error {
|
||||
return c.Notify(ctx, "$/progress", params)
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
package lsp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// Message represents a JSON-RPC 2.0 message
|
||||
type Message struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID int32 `json:"id,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error *ResponseError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// ResponseError represents a JSON-RPC 2.0 error
|
||||
type ResponseError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func NewRequest(id int32, method string, params any) (*Message, error) {
|
||||
paramsJSON, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Message{
|
||||
JSONRPC: "2.0",
|
||||
ID: id,
|
||||
Method: method,
|
||||
Params: paramsJSON,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func NewNotification(method string, params any) (*Message, error) {
|
||||
paramsJSON, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Message{
|
||||
JSONRPC: "2.0",
|
||||
Method: method,
|
||||
Params: paramsJSON,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
Copyright 2009 The Go Authors.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are
|
||||
met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above
|
||||
copyright notice, this list of conditions and the following disclaimer
|
||||
in the documentation and/or other materials provided with the
|
||||
distribution.
|
||||
* Neither the name of Google LLC nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -1,117 +0,0 @@
|
||||
package protocol
|
||||
|
||||
import "fmt"
|
||||
|
||||
// TextEditResult is an interface for types that represent workspace symbols
|
||||
type WorkspaceSymbolResult interface {
|
||||
GetName() string
|
||||
GetLocation() Location
|
||||
isWorkspaceSymbol() // marker method
|
||||
}
|
||||
|
||||
func (ws *WorkspaceSymbol) GetName() string { return ws.Name }
|
||||
func (ws *WorkspaceSymbol) GetLocation() Location {
|
||||
switch v := ws.Location.Value.(type) {
|
||||
case Location:
|
||||
return v
|
||||
case LocationUriOnly:
|
||||
return Location{URI: v.URI}
|
||||
}
|
||||
return Location{}
|
||||
}
|
||||
func (ws *WorkspaceSymbol) isWorkspaceSymbol() {}
|
||||
|
||||
func (si *SymbolInformation) GetName() string { return si.Name }
|
||||
func (si *SymbolInformation) GetLocation() Location { return si.Location }
|
||||
func (si *SymbolInformation) isWorkspaceSymbol() {}
|
||||
|
||||
// Results converts the Value to a slice of WorkspaceSymbolResult
|
||||
func (r Or_Result_workspace_symbol) Results() ([]WorkspaceSymbolResult, error) {
|
||||
if r.Value == nil {
|
||||
return make([]WorkspaceSymbolResult, 0), nil
|
||||
}
|
||||
switch v := r.Value.(type) {
|
||||
case []WorkspaceSymbol:
|
||||
results := make([]WorkspaceSymbolResult, len(v))
|
||||
for i := range v {
|
||||
results[i] = &v[i]
|
||||
}
|
||||
return results, nil
|
||||
case []SymbolInformation:
|
||||
results := make([]WorkspaceSymbolResult, len(v))
|
||||
for i := range v {
|
||||
results[i] = &v[i]
|
||||
}
|
||||
return results, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown symbol type: %T", r.Value)
|
||||
}
|
||||
}
|
||||
|
||||
// TextEditResult is an interface for types that represent document symbols
|
||||
type DocumentSymbolResult interface {
|
||||
GetRange() Range
|
||||
GetName() string
|
||||
isDocumentSymbol() // marker method
|
||||
}
|
||||
|
||||
func (ds *DocumentSymbol) GetRange() Range { return ds.Range }
|
||||
func (ds *DocumentSymbol) GetName() string { return ds.Name }
|
||||
func (ds *DocumentSymbol) isDocumentSymbol() {}
|
||||
|
||||
func (si *SymbolInformation) GetRange() Range { return si.Location.Range }
|
||||
|
||||
// Note: SymbolInformation already has GetName() implemented above
|
||||
func (si *SymbolInformation) isDocumentSymbol() {}
|
||||
|
||||
// Results converts the Value to a slice of DocumentSymbolResult
|
||||
func (r Or_Result_textDocument_documentSymbol) Results() ([]DocumentSymbolResult, error) {
|
||||
if r.Value == nil {
|
||||
return make([]DocumentSymbolResult, 0), nil
|
||||
}
|
||||
switch v := r.Value.(type) {
|
||||
case []DocumentSymbol:
|
||||
results := make([]DocumentSymbolResult, len(v))
|
||||
for i := range v {
|
||||
results[i] = &v[i]
|
||||
}
|
||||
return results, nil
|
||||
case []SymbolInformation:
|
||||
results := make([]DocumentSymbolResult, len(v))
|
||||
for i := range v {
|
||||
results[i] = &v[i]
|
||||
}
|
||||
return results, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown document symbol type: %T", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TextEditResult is an interface for types that can be used as text edits
|
||||
type TextEditResult interface {
|
||||
GetRange() Range
|
||||
GetNewText() string
|
||||
isTextEdit() // marker method
|
||||
}
|
||||
|
||||
func (te *TextEdit) GetRange() Range { return te.Range }
|
||||
func (te *TextEdit) GetNewText() string { return te.NewText }
|
||||
func (te *TextEdit) isTextEdit() {}
|
||||
|
||||
// Convert Or_TextDocumentEdit_edits_Elem to TextEdit
|
||||
func (e Or_TextDocumentEdit_edits_Elem) AsTextEdit() (TextEdit, error) {
|
||||
if e.Value == nil {
|
||||
return TextEdit{}, fmt.Errorf("nil text edit")
|
||||
}
|
||||
switch v := e.Value.(type) {
|
||||
case TextEdit:
|
||||
return v, nil
|
||||
case AnnotatedTextEdit:
|
||||
return TextEdit{
|
||||
Range: v.Range,
|
||||
NewText: v.NewText,
|
||||
}, nil
|
||||
default:
|
||||
return TextEdit{}, fmt.Errorf("unknown text edit type: %T", e.Value)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user