Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94ba546ff4 | ||
|
|
4eb60954f8 | ||
|
|
e8c67888a7 | ||
|
|
033b9ea560 | ||
|
|
7bb3fbf095 | ||
|
|
507e117466 | ||
|
|
1549712761 | ||
|
|
4f37be6265 | ||
|
|
7b04288099 | ||
|
|
96339544bf | ||
|
|
b03d760f43 | ||
|
|
b73e75fb6a | ||
|
|
08c15ec1cd | ||
|
|
48f3c2e923 | ||
|
|
3d94a90978 | ||
|
|
2c33922412 | ||
|
|
7e42a897bc | ||
|
|
d8663e1fe8 | ||
|
|
a6b10e3e87 | ||
|
|
3400375fb3 | ||
|
|
ffb51318f6 | ||
|
|
b1157ebb04 | ||
|
|
3e4cdcd180 | ||
|
|
0a6f380db7 | ||
|
|
44336e8b84 | ||
|
|
20cf09752d | ||
|
|
966e718b31 | ||
|
|
cf0c9fb914 | ||
|
|
bebc3640f4 | ||
|
|
aa6a4f93bd | ||
|
|
8433dd732f | ||
|
|
c34d09fe97 | ||
|
|
2fb7985bb7 | ||
|
|
a931e8a9fd | ||
|
|
149acd7141 | ||
|
|
adf4b023d8 | ||
|
|
f406d93a66 | ||
|
|
9faf99a4ab | ||
|
|
19f203837e | ||
|
|
f0f4c94c0a | ||
|
|
9c4c5870b6 | ||
|
|
924c12b291 | ||
|
|
155bc7df75 | ||
|
|
d280eaf961 | ||
|
|
5556ad563d | ||
|
|
629e7aaa31 | ||
|
|
dd2af0e5fc | ||
|
|
a923596ee7 | ||
|
|
91b9bc52a4 | ||
|
|
f46fa72a92 | ||
|
|
14636b9900 | ||
|
|
b020f1018b | ||
|
|
7878744505 | ||
|
|
599bdc982e | ||
|
|
82bb5ff88a | ||
|
|
0159356be6 | ||
|
|
9388b94a97 | ||
|
|
02c3891d9b | ||
|
|
1844c11104 | ||
|
|
c0285b457a | ||
|
|
ffa4c4c730 | ||
|
|
01efd22b2a | ||
|
|
dd68d6eb79 | ||
|
|
1243d09dbc | ||
|
|
a80a00011d | ||
|
|
bf97a0443f | ||
|
|
67b14e9191 | ||
|
|
1b02abcfab | ||
|
|
74dbc509d7 | ||
|
|
760f87fab1 | ||
|
|
df51166711 | ||
|
|
8d8a7bc844 | ||
|
|
a2fc1968be | ||
|
|
c2a39ac592 | ||
|
|
4deda18037 | ||
|
|
0f83038253 | ||
|
|
4c33a253aa | ||
|
|
135995d4fc | ||
|
|
4d9090790b | ||
|
|
621a5b0706 | ||
|
|
16ef62c851 | ||
|
|
b694d2bf26 | ||
|
|
89d37a0f6d | ||
|
|
bc5ff1cfd9 | ||
|
|
c14a1f5acf | ||
|
|
7db941a892 | ||
|
|
83fafda63a | ||
|
|
a565ea8c74 | ||
|
|
3126808e0e | ||
|
|
948047a8ca | ||
|
|
0f931d3939 | ||
|
|
7ef4a1a56e | ||
|
|
b29dab3231 | ||
|
|
d76f968c60 | ||
|
|
b30c4d00d1 | ||
|
|
6712cc4c2c | ||
|
|
2aaa265cdc | ||
|
|
ff72659a47 | ||
|
|
14af33d53d | ||
|
|
a604b7f773 | ||
|
|
41d4a9c45c | ||
|
|
293d60a89f | ||
|
|
34a8938dc7 | ||
|
|
36ef6cc216 | ||
|
|
f02c30eb55 | ||
|
|
ca084b2430 | ||
|
|
3740ec311b | ||
|
|
35bd8ac442 | ||
|
|
1fc05ca590 | ||
|
|
bda798b167 | ||
|
|
3babae35c0 | ||
|
|
3ab5c1433c | ||
|
|
49437a25b1 | ||
|
|
49403a554f | ||
|
|
87d6f93409 | ||
|
|
97d4eaa2ba | ||
|
|
7827dbe396 | ||
|
|
5f9ced439b | ||
|
|
8c1ce954d0 | ||
|
|
07338c5d48 | ||
|
|
46e53e3f2b | ||
|
|
6cf442b545 | ||
|
|
f20f5b68ee | ||
|
|
96dd9f77a9 | ||
|
|
dd786c62af | ||
|
|
87c402a124 | ||
|
|
7076a878a4 | ||
|
|
45b91eed82 | ||
|
|
39e1ce55bc | ||
|
|
d9f54392ba | ||
|
|
d73396ab3d | ||
|
|
0caae608a2 | ||
|
|
96f23508be | ||
|
|
28bb0a7158 | ||
|
|
3d109828ff | ||
|
|
c0d49f101c | ||
|
|
be2446e188 | ||
|
|
107966eddd | ||
|
|
f5e580cde1 | ||
|
|
4428a77acd | ||
|
|
01eb18144b | ||
|
|
01208048dc | ||
|
|
995f76cb63 | ||
|
|
c33de198e3 | ||
|
|
24224bcb57 | ||
|
|
7913c8db59 | ||
|
|
d9987ef9c8 | ||
|
|
f0ed4f67df | ||
|
|
2109d68d39 | ||
|
|
cefb2968e2 | ||
|
|
00d179015f | ||
|
|
1d4e1233e5 | ||
|
|
d14f20b46e | ||
|
|
37049a5a13 | ||
|
|
a64bc3616e | ||
|
|
39021dfd67 | ||
|
|
709ddc0d79 | ||
|
|
041885d838 | ||
|
|
29ce49db0f | ||
|
|
00738c5b2d | ||
|
|
6ec8ca920f | ||
|
|
6585bb7105 | ||
|
|
f954688fbb | ||
|
|
1463dabde9 | ||
|
|
29ea6ee05b | ||
|
|
b170904731 | ||
|
|
88e1fa9304 | ||
|
|
ff1bf315ed | ||
|
|
144ce00e00 | ||
|
|
ad504094f0 | ||
|
|
bad6834a3e | ||
|
|
0c4bbc3cd1 | ||
|
|
ae7dd82126 | ||
|
|
65d5123ead | ||
|
|
4eb46a8885 | ||
|
|
1986e92842 | ||
|
|
14fc63ba9e | ||
|
|
c34ffa117e | ||
|
|
beeb14e910 | ||
|
|
aae42e2e75 | ||
|
|
cc9011c1ae | ||
|
|
6cd938e1e9 | ||
|
|
7de6b3fc15 | ||
|
|
c1c9a13993 | ||
|
|
917d904f18 | ||
|
|
ee5b67eb84 | ||
|
|
048a47e89e | ||
|
|
5335347e80 | ||
|
|
16b18dff13 | ||
|
|
61c2349cef | ||
|
|
684721efb8 | ||
|
|
962c14a49c | ||
|
|
85b98e7da4 | ||
|
|
8061220b08 | ||
|
|
b02cc35f13 | ||
|
|
e23d89c9a9 | ||
|
|
e8b3e19e85 | ||
|
|
5256f30957 | ||
|
|
61ecf404b9 | ||
|
|
92d2b1700f | ||
|
|
56262121ee | ||
|
|
e3b588e7d2 | ||
|
|
1de648cb13 | ||
|
|
03be7f385b | ||
|
|
8118690839 | ||
|
|
a16eedfed7 | ||
|
|
e796f2f9a5 | ||
|
|
20610e6645 | ||
|
|
7f245b0968 | ||
|
|
7013e925f5 | ||
|
|
499c2feaa3 | ||
|
|
03af821aa5 | ||
|
|
c903774556 | ||
|
|
14aaf91e65 | ||
|
|
c832432d89 | ||
|
|
1d431a80df | ||
|
|
19a9e41c28 | ||
|
|
d932cad09c | ||
|
|
e22c1622e0 | ||
|
|
0e4ad0ce76 | ||
|
|
6afcc887c8 | ||
|
|
c1f50659a7 | ||
|
|
32d3535f66 | ||
|
|
9810d98bc2 | ||
|
|
2caba90a63 | ||
|
|
eccf0b3b7b | ||
|
|
808588e9b9 | ||
|
|
c35c211460 | ||
|
|
efb8e23dcc | ||
|
|
ad53e39d2e | ||
|
|
3ddfc15df5 | ||
|
|
ac2426e103 | ||
|
|
0bc8b8dbeb | ||
|
|
dca73ba9e3 | ||
|
|
318a8c1aba | ||
|
|
bee5014f89 | ||
|
|
8652729fdb | ||
|
|
bfcaf1b0cd | ||
|
|
f24fdeca70 | ||
|
|
dc48655743 | ||
|
|
ab52135b4e | ||
|
|
e31f78af78 | ||
|
|
bc3669b97f | ||
|
|
7460d855ac | ||
|
|
683d470fe4 | ||
|
|
ef4938d344 | ||
|
|
b098b0e06f | ||
|
|
27dfac3d36 | ||
|
|
5253661d36 | ||
|
|
ff6b8c21e7 | ||
|
|
8e39040eed | ||
|
|
95cfca73b0 | ||
|
|
f0aff6cfe8 | ||
|
|
b7d707cd64 | ||
|
|
b486652067 | ||
|
|
7d1e212fb5 | ||
|
|
2c369a21c9 | ||
|
|
f2e73dd03f | ||
|
|
88a9688c9c | ||
|
|
8d5eb3bf9c | ||
|
|
6e0857cc0e | ||
|
|
757e565c23 | ||
|
|
f53cc41657 | ||
|
|
1b6acc3347 | ||
|
|
7d126eb1e5 | ||
|
|
bbbac2507b | ||
|
|
7f93d0018c | ||
|
|
33b686feb9 | ||
|
|
fb4422ef62 | ||
|
|
d31ce33ca5 | ||
|
|
56db9053e8 | ||
|
|
c98d9c65dc | ||
|
|
b47824fb63 | ||
|
|
f51dc62721 | ||
|
|
ad15173aa7 | ||
|
|
e64e4d0fbb | ||
|
|
7f39ae9ecb | ||
|
|
b3b030856b | ||
|
|
a117ebb408 | ||
|
|
f2d9ebb886 | ||
|
|
0332a26be6 | ||
|
|
bb7876dfa8 | ||
|
|
dbaa57a21b | ||
|
|
b6bc55764c | ||
|
|
14a3311a61 | ||
|
|
dddb5eb96f | ||
|
|
affa57e40f | ||
|
|
681ea07a92 | ||
|
|
6e89d9e2c3 | ||
|
|
f526727178 | ||
|
|
9c8a63e852 | ||
|
|
fabf56781c | ||
|
|
a25d304201 | ||
|
|
cc8886c8bb | ||
|
|
8ce629be22 | ||
|
|
68b28bdb98 | ||
|
|
150dc69e4b | ||
|
|
d5d4461e67 | ||
|
|
d56ce74373 | ||
|
|
17abc5906b | ||
|
|
8656838a5b | ||
|
|
53179daefa | ||
|
|
bab26d63ea | ||
|
|
f0381e5da3 | ||
|
|
740072694d | ||
|
|
2e4abeb25d | ||
|
|
43f1dad8e1 | ||
|
|
cf4b4c2312 | ||
|
|
3bf8a5a8cf | ||
|
|
ddeb19790a | ||
|
|
fe0d1682ca | ||
|
|
1746672c42 | ||
|
|
3a2203eaac | ||
|
|
38c320ea4c | ||
|
|
5c53cfc342 | ||
|
|
8683406690 | ||
|
|
126294a322 | ||
|
|
eea247598b | ||
|
|
d2bbefbac8 | ||
|
|
ad756ef09b | ||
|
|
94df7a812d | ||
|
|
7af65eff37 | ||
|
|
bf6788b94c | ||
|
|
54fbf6d14d | ||
|
|
cdccde7408 | ||
|
|
51d2b66760 | ||
|
|
60c78ed8ab | ||
|
|
f2bdee6726 | ||
|
|
3584eca0eb | ||
|
|
ad1a4a6539 | ||
|
|
10aa949f43 | ||
|
|
067a528b1d | ||
|
|
788f0affcb | ||
|
|
18eeb3201d | ||
|
|
8864eb507e | ||
|
|
e0ddc47aa4 | ||
|
|
2f06f9d58b | ||
|
|
8e62ad7adc | ||
|
|
956de96d8b | ||
|
|
be4e5a6d06 | ||
|
|
fbacf6a126 | ||
|
|
9c18abce47 | ||
|
|
080b7671de | ||
|
|
dcfe1ec7bd | ||
|
|
ceace24a3e | ||
|
|
19e1357a06 | ||
|
|
4b381ac6a1 | ||
|
|
07d48e1ffb | ||
|
|
9fdcb8da41 | ||
|
|
ba61ac6730 | ||
|
|
94b9133910 | ||
|
|
6f8c5ae0aa | ||
|
|
60673aaef3 | ||
|
|
651529d64e | ||
|
|
532f25d0d4 | ||
|
|
643c4c3500 | ||
|
|
9d531435b4 | ||
|
|
5b9dc35eec | ||
|
|
1814dd9799 | ||
|
|
1e1cd042ea | ||
|
|
02566f6219 | ||
|
|
f488aa3f79 | ||
|
|
97457ec7a3 | ||
|
|
d62049aab4 | ||
|
|
096ac95773 | ||
|
|
4cc9b90f27 | ||
|
|
990463aa9f | ||
|
|
cd39063622 | ||
|
|
4c944a86d7 | ||
|
|
97e833a297 | ||
|
|
b8aa08f260 | ||
|
|
3f0118022b | ||
|
|
6f76c31ca7 | ||
|
|
f90beeb9b8 | ||
|
|
4b9a3d80fc | ||
|
|
1316576720 | ||
|
|
2f0c861af0 | ||
|
|
4d94777d4d | ||
|
|
46ebde65e9 | ||
|
|
932c12ad1d | ||
|
|
6f655dcbab | ||
|
|
ab60f08c69 | ||
|
|
60ed84ecd1 | ||
|
|
8aebed170a | ||
|
|
14148a0ea4 | ||
|
|
7f51fbd878 | ||
|
|
cbdd1f66da | ||
|
|
788eb0fa29 | ||
|
|
58fcad77a8 | ||
|
|
c555559ac1 | ||
|
|
e3a3fa7108 | ||
|
|
6238af397e | ||
|
|
7ffd75faf6 | ||
|
|
1f8ab95695 | ||
|
|
62dc1f7696 | ||
|
|
66f10ab7bf | ||
|
|
1ca8f63a79 | ||
|
|
03bcdd580d | ||
|
|
cc502f7e5f | ||
|
|
64ce8771c0 | ||
|
|
702a73d91a | ||
|
|
991b727eb8 | ||
|
|
ba342ce227 | ||
|
|
1464545665 | ||
|
|
fecacc9e68 | ||
|
|
1f73b4806b | ||
|
|
da2ce02596 | ||
|
|
25f35dcfb8 | ||
|
|
717f81ce08 | ||
|
|
5fdfcc7a80 | ||
|
|
0530c8e512 | ||
|
|
1d8cf4564b | ||
|
|
7e88f6bb18 | ||
|
|
af592fb779 | ||
|
|
7fc3f68007 | ||
|
|
cdcbb0047e | ||
|
|
a1956a7522 | ||
|
|
55bc7fd403 | ||
|
|
3049b1e684 | ||
|
|
1f36a7aff8 | ||
|
|
eb0e26b974 | ||
|
|
6f2b0e7833 | ||
|
|
f153255942 | ||
|
|
fef2fad76f | ||
|
|
f30d06ea34 | ||
|
|
dfa44e94e8 | ||
|
|
b81e10a461 | ||
|
|
65c93b69ed | ||
|
|
b073b052d3 | ||
|
|
728b2b6052 | ||
|
|
cc3ce20ab4 | ||
|
|
4ad5001be2 | ||
|
|
14b3c2ea4b | ||
|
|
558bd54c9b | ||
|
|
7e70f7e1ab | ||
|
|
cb6d95b7ef | ||
|
|
7020944359 | ||
|
|
4b00dd2713 | ||
|
|
b447627f5f | ||
|
|
5848ee0d24 | ||
|
|
839aa25c3e | ||
|
|
c42f1c9232 | ||
|
|
417f6d234d | ||
|
|
45a13af0ee | ||
|
|
e50d845451 | ||
|
|
56816621f8 | ||
|
|
7125f5f8b5 | ||
|
|
01a6ed8d97 | ||
|
|
004583d598 | ||
|
|
cbd911368e | ||
|
|
8bfb247854 | ||
|
|
dd71cdbd84 | ||
|
|
1c90e8f01c | ||
|
|
9843d13c29 | ||
|
|
871db0025e | ||
|
|
7b7a67080e | ||
|
|
3361953fe1 | ||
|
|
e9201ce933 | ||
|
|
b38d72e174 | ||
|
|
e1e2efe8ec | ||
|
|
c2b171d7c4 | ||
|
|
c3bf8864b9 | ||
|
|
3693f56374 | ||
|
|
971f4e558e | ||
|
|
dd95e3f2ed | ||
|
|
dfde4628e3 | ||
|
|
e755fce13d | ||
|
|
5b565e8923 | ||
|
|
ec8d95f1f2 | ||
|
|
ea52d18ec5 | ||
|
|
0f9504d6c5 | ||
|
|
f6353a48b5 | ||
|
|
4facc03371 | ||
|
|
485a027c1a | ||
|
|
988a66ca4f | ||
|
|
98c6b41573 | ||
|
|
4ee9bf0213 | ||
|
|
681338c533 | ||
|
|
5464aa4a74 | ||
|
|
36083c9520 | ||
|
|
168aba6183 | ||
|
|
ec19dc09c7 | ||
|
|
fca4701688 | ||
|
|
c720ca20ed | ||
|
|
dae1e8abbd | ||
|
|
ffe4d1522c | ||
|
|
b1860465cd | ||
|
|
cab11795e5 | ||
|
|
810e79a6d4 | ||
|
|
d81604714f | ||
|
|
d1e828c217 | ||
|
|
437cdc03a9 | ||
|
|
219ecada68 | ||
|
|
9e1fa80fb7 | ||
|
|
278db3023f | ||
|
|
c2067d59af | ||
|
|
f5d40d6f30 | ||
|
|
c8e654410d | ||
|
|
db80806651 | ||
|
|
47f66de8dd | ||
|
|
c076066c33 | ||
|
|
5c09d051e5 | ||
|
|
0a6111291e | ||
|
|
609044ef0a | ||
|
|
4f32bfca57 | ||
|
|
90112f52db | ||
|
|
d4303a9ca5 | ||
|
|
735556eab8 | ||
|
|
10cac9ab5d | ||
|
|
2d0ce64111 | ||
|
|
3355c93efd | ||
|
|
469e1c035e | ||
|
|
b2e3569add | ||
|
|
0ac458b3b3 | ||
|
|
b278ef6b82 | ||
|
|
3e3a4ae46b | ||
|
|
fe0d9579a7 | ||
|
|
a2594ddefb | ||
|
|
3555cef1c8 | ||
|
|
0fa881efd0 | ||
|
|
37ff501cc6 | ||
|
|
fa126d68e8 | ||
|
|
a0571a8b14 | ||
|
|
cae475df37 | ||
|
|
19781a4447 | ||
|
|
e32de90d88 | ||
|
|
0ee5ef6c43 | ||
|
|
a72f988c90 | ||
|
|
2a132d6f50 | ||
|
|
23418b3885 | ||
|
|
b4ac938c3d | ||
|
|
4b25739e93 | ||
|
|
2f97e1324f | ||
|
|
0018f08832 | ||
|
|
b52f241294 | ||
|
|
254ace8594 | ||
|
|
657f700f5d | ||
|
|
7e1d28b549 | ||
|
|
040d2ffb48 | ||
|
|
0675975f98 | ||
|
|
130232feb6 | ||
|
|
54f2356166 | ||
|
|
ac0dd75c6a | ||
|
|
430886142b | ||
|
|
5a02ec776e | ||
|
|
c303a11725 | ||
|
|
dfceca8de7 | ||
|
|
f04c3fd82b | ||
|
|
1685e70c4b | ||
|
|
73e7b7bc79 | ||
|
|
a5b3802ca3 | ||
|
|
f28d1b44e3 | ||
|
|
b81be469c8 | ||
|
|
fca4a8fa3b | ||
|
|
8520617ca8 | ||
|
|
ab3566ab82 | ||
|
|
b642c1d9ea | ||
|
|
c8cc984aa0 | ||
|
|
5a9448acbb | ||
|
|
f6604cd367 | ||
|
|
acfacede2d | ||
|
|
4a27842fe6 | ||
|
|
d797722187 | ||
|
|
7390832f13 | ||
|
|
7689c3654e | ||
|
|
7df0935ada | ||
|
|
bcd43760df | ||
|
|
9073c522ef | ||
|
|
04c296310e | ||
|
|
79d657b8fe | ||
|
|
606ec4fa38 | ||
|
|
2cdd938152 | ||
|
|
4d73b40e59 | ||
|
|
276f2eef3e | ||
|
|
8806d34ed4 | ||
|
|
cda2bc5100 | ||
|
|
ba753b383f | ||
|
|
eb052f70b4 | ||
|
|
5eaed68c2b | ||
|
|
7d7962b2fa | ||
|
|
0537fd47c7 | ||
|
|
9add9384be | ||
|
|
005522786f | ||
|
|
5379a2dfe0 | ||
|
|
6ee156e0b9 | ||
|
|
7c3c50bac2 | ||
|
|
194a006932 | ||
|
|
3c1f87d2d4 | ||
|
|
c57733a647 | ||
|
|
9bf5faa978 | ||
|
|
a062f4e803 | ||
|
|
fc49b16584 | ||
|
|
9b091ebd37 | ||
|
|
12201ee507 | ||
|
|
73bbeb7ce1 | ||
|
|
370b9d484a | ||
|
|
796c423df5 | ||
|
|
978bc53eba | ||
|
|
f7444aba27 | ||
|
|
c987590c5f | ||
|
|
1a3bd3458c | ||
|
|
67a6d52015 | ||
|
|
b1f6d21ef3 | ||
|
|
fa3721fad6 | ||
|
|
02b12aa60e | ||
|
|
1b6a07dda3 | ||
|
|
547d60183d | ||
|
|
12e53ee6d4 | ||
|
|
b629b458f7 | ||
|
|
24dff6929d | ||
|
|
7e2ca5fe26 | ||
|
|
aaed6eaa45 | ||
|
|
01443d5e00 | ||
|
|
70fe186df4 | ||
|
|
fc731d6013 | ||
|
|
4cd6093e31 | ||
|
|
b693c222da | ||
|
|
e31ad14c1d | ||
|
|
6c9115eb75 | ||
|
|
d9dfc5c1d6 | ||
|
|
c921067eb8 | ||
|
|
b76e56eca3 | ||
|
|
3c3ef00b32 | ||
|
|
d54fc305db | ||
|
|
a4aaf18855 | ||
|
|
d0a902815d | ||
|
|
27aaa9ce0e | ||
|
|
0b15abbf9a | ||
|
|
0f22b86670 | ||
|
|
30243ddff9 | ||
|
|
9ae714d49d | ||
|
|
a73eaae6c0 | ||
|
|
1aaa8060d1 | ||
|
|
6a30741c45 | ||
|
|
c31ac3c919 | ||
|
|
4fc5ba1358 | ||
|
|
081eef3b80 | ||
|
|
98f2a87304 | ||
|
|
23402629c5 | ||
|
|
90ac6f9724 | ||
|
|
f02b21a69a | ||
|
|
7dcafbfa0b | ||
|
|
2463612418 | ||
|
|
2226afaea5 | ||
|
|
00a2fc97f7 | ||
|
|
a54f2c12a8 | ||
|
|
648d01eec7 | ||
|
|
cdee29a821 | ||
|
|
2b855fccb2 | ||
|
|
4fa6ca00e5 | ||
|
|
76594882b4 | ||
|
|
75f046212f | ||
|
|
6bc60caab9 | ||
|
|
3468a092c9 | ||
|
|
60b88daddf | ||
|
|
8cf4e6b9b4 | ||
|
|
fee337c462 | ||
|
|
8378f6caa9 | ||
|
|
94804423f4 | ||
|
|
c62d2e8336 | ||
|
|
f64ce6aad4 | ||
|
|
dac296f963 | ||
|
|
55e1b3eb17 | ||
|
|
f1149efee7 | ||
|
|
0ee9f61e3d | ||
|
|
41df55ac15 | ||
|
|
8fc49da6eb | ||
|
|
cbe8823671 | ||
|
|
6e43875123 | ||
|
|
ce380fba29 | ||
|
|
3045156fa6 | ||
|
|
0a3dc16961 | ||
|
|
1d4d9b5fa5 | ||
|
|
752d980770 | ||
|
|
07225172df | ||
|
|
223b0271e6 | ||
|
|
93a37958a7 | ||
|
|
40f4778296 | ||
|
|
e9bb6d490b | ||
|
|
1e697962bd | ||
|
|
4418df5d62 | ||
|
|
052be04466 | ||
|
|
cd3a64b225 | ||
|
|
48c9a0a8de | ||
|
|
48875190ef | ||
|
|
476432de1e | ||
|
|
a71bb4d38c | ||
|
|
5d3019e5a1 | ||
|
|
aeed4b6375 | ||
|
|
82f713421f | ||
|
|
763cac8080 | ||
|
|
c2cf497e19 | ||
|
|
7784b3ee0d | ||
|
|
199aabe9e2 | ||
|
|
8905af5074 | ||
|
|
ce56111a4c | ||
|
|
5ab0167288 | ||
|
|
487e1bd76e | ||
|
|
1eeaaab7a6 | ||
|
|
b901cb28af | ||
|
|
13453f2da8 | ||
|
|
e16c56fe8b | ||
|
|
42ff564913 | ||
|
|
195158c34c | ||
|
|
7a31b5c0f7 | ||
|
|
fb3c10ca66 | ||
|
|
c43cfccc4e | ||
|
|
c5aa7d7e34 | ||
|
|
9c8a4ea4ff | ||
|
|
c82340a97b | ||
|
|
dbc63955b0 | ||
|
|
2816d1c849 | ||
|
|
61d812fcd6 | ||
|
|
e47b9b5453 | ||
|
|
21dac524f3 | ||
|
|
3dde11c392 | ||
|
|
0643a5638e | ||
|
|
81523d4a84 | ||
|
|
7c5a4d01aa | ||
|
|
4143084250 | ||
|
|
c7868ce0e6 | ||
|
|
cf4f1fb45e | ||
|
|
0a21042523 | ||
|
|
6add96698e | ||
|
|
3f579f53f4 | ||
|
|
9f3ba44c4a | ||
|
|
c4fe0f676a | ||
|
|
625c469854 | ||
|
|
d6c22b3bf5 | ||
|
|
bbf4cd4975 | ||
|
|
27027777d2 | ||
|
|
5d1841e0aa | ||
|
|
2253d4c31d | ||
|
|
877b04f0b9 | ||
|
|
41e5d1b6b6 | ||
|
|
60fb97d59e | ||
|
|
b2e6e764a8 | ||
|
|
0d8e64112f | ||
|
|
b0b85cbb77 | ||
|
|
0f26ad8787 | ||
|
|
b08ad82518 | ||
|
|
5319f063d6 | ||
|
|
63f7ceecbe | ||
|
|
770420bdad | ||
|
|
4994a7d694 | ||
|
|
c55ee2a815 | ||
|
|
312651f68e | ||
|
|
654f411a5b | ||
|
|
68b862da49 | ||
|
|
8f2b13e864 | ||
|
|
bb564f96a3 | ||
|
|
a555f24ded | ||
|
|
10ae321bf3 | ||
|
|
6e8ff0c2b7 | ||
|
|
923bda07a2 | ||
|
|
4c34ee5eab | ||
|
|
2df00955cb | ||
|
|
e0deffa083 | ||
|
|
4e97b78d98 | ||
|
|
6bb4b35399 | ||
|
|
0ce1383030 | ||
|
|
010cd6131e | ||
|
|
c2348f8f69 | ||
|
|
71317ec7c9 | ||
|
|
4261fe749d | ||
|
|
9dd7149e75 | ||
|
|
0c0a431c9f | ||
|
|
3368e049d2 | ||
|
|
f0b5da1c11 | ||
|
|
15c525dcfb | ||
|
|
4d12e01824 | ||
|
|
70afbac80c | ||
|
|
1c723c56fa | ||
|
|
181428a2f3 | ||
|
|
9b1891fb7e | ||
|
|
d4bf78b348 | ||
|
|
d4ceffe787 | ||
|
|
872e38055e | ||
|
|
0c1dfa9186 | ||
|
|
8f4d706647 | ||
|
|
929374cdfd | ||
|
|
cfa5ba700e | ||
|
|
45a2ed9a97 | ||
|
|
f6333546f8 | ||
|
|
9e153ce7b3 | ||
|
|
eb357f17cf | ||
|
|
573d76933f | ||
|
|
bb8194395a | ||
|
|
2e8ed86658 | ||
|
|
2695607fbc | ||
|
|
f3ef84556a | ||
|
|
08ff21179c | ||
|
|
98a36fb1a4 | ||
|
|
e22cd0a585 | ||
|
|
8475783700 | ||
|
|
1452aadc87 | ||
|
|
1417976257 | ||
|
|
5ec7dd968c | ||
|
|
43fb543e3b | ||
|
|
ac7f3c5ece | ||
|
|
3edbc88225 | ||
|
|
20aff6d9f6 | ||
|
|
bdb66747e7 | ||
|
|
f91c6d8b25 | ||
|
|
30f8b2f4b6 | ||
|
|
0f67a15f3b | ||
|
|
08ac1e168c | ||
|
|
eb37a7ebc7 | ||
|
|
bf4522ed46 | ||
|
|
571c3c4f00 | ||
|
|
50ed7c41ef | ||
|
|
0bbf29fea6 | ||
|
|
a0a0e3271c | ||
|
|
7ed5223d5e | ||
|
|
1bd85d926b | ||
|
|
c45e425e12 | ||
|
|
7fb79388a4 | ||
|
|
7f2510c5ca | ||
|
|
9ae6b21f6a | ||
|
|
c7dd0c8278 | ||
|
|
85dff53a1f | ||
|
|
297019e321 | ||
|
|
95503c1773 | ||
|
|
ba1448325a | ||
|
|
be2582f316 | ||
|
|
c0cb1c7a91 | ||
|
|
e10408b219 | ||
|
|
bdc143c1d5 | ||
|
|
d52380024d | ||
|
|
f1eed8bf11 | ||
|
|
4ea368e09e | ||
|
|
c6977a836f | ||
|
|
1dcc6551d9 | ||
|
|
9c8fb89979 | ||
|
|
51c926c3ac | ||
|
|
65152b7936 | ||
|
|
3c4c7b41be | ||
|
|
d461154a8d | ||
|
|
cebd25022f | ||
|
|
9128e847bd | ||
|
|
8b92833624 | ||
|
|
f4dd76913f | ||
|
|
ef88566d61 | ||
|
|
b3f765c17d | ||
|
|
ab2366de2e | ||
|
|
c0aa963c13 | ||
|
|
d55d941f3c | ||
|
|
2dea1f3d0e | ||
|
|
fcafe82cdc | ||
|
|
1fe06bb4ed | ||
|
|
c1f4beaf40 | ||
|
|
e628143448 | ||
|
|
148042ab81 | ||
|
|
b7aea8b0ef | ||
|
|
1623ac3ba9 | ||
|
|
f02c5f8648 | ||
|
|
0da0772bc0 | ||
|
|
08e28fb915 | ||
|
|
74b0fa9d1f | ||
|
|
dbd9b18f3d | ||
|
|
b43e1c682b | ||
|
|
d11f5916ee | ||
|
|
f1ce69d2ce | ||
|
|
d39290fbb9 | ||
|
|
6ee2ed7510 | ||
|
|
594635b5ae | ||
|
|
18b05e86fb | ||
|
|
cc501650c6 | ||
|
|
37b6fbc5bf | ||
|
|
d24f8b0810 | ||
|
|
79a6a90862 | ||
|
|
8c5eca5bb2 | ||
|
|
f3128fa241 | ||
|
|
883d16d2ad | ||
|
|
5c30292daa |
No files matched your search
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@opencode/core": patch
|
||||
---
|
||||
|
||||
Correct directory page headings when the read offset is zero.
|
||||
@@ -45,16 +45,16 @@ runs:
|
||||
- name: Get cache directory
|
||||
id: cache
|
||||
shell: bash
|
||||
run: echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT"
|
||||
run: |
|
||||
echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$(bun --version)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore Bun dependencies
|
||||
id: bun-cache
|
||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: ${{ steps.cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ steps.cache.outputs.version }}-${{ hashFiles('bun.lock', 'patches/**') }}
|
||||
|
||||
- name: Install setuptools for distutils compatibility
|
||||
run: python3 -m pip install setuptools || pip install setuptools || true
|
||||
@@ -66,9 +66,9 @@ runs:
|
||||
# e.g. ./patches/ for standard-openapi
|
||||
# https://github.com/oven-sh/bun/issues/28147
|
||||
if [ "$RUNNER_OS" = "Windows" ]; then
|
||||
bun install --linker hoisted ${{ inputs.install-flags }}
|
||||
bun install --frozen-lockfile --linker hoisted ${{ inputs.install-flags }}
|
||||
else
|
||||
bun install ${{ inputs.install-flags }}
|
||||
bun install --frozen-lockfile ${{ inputs.install-flags }}
|
||||
fi
|
||||
shell: bash
|
||||
|
||||
@@ -77,4 +77,4 @@ runs:
|
||||
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: ${{ steps.cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
||||
key: ${{ steps.bun-cache.outputs.cache-primary-key }}
|
||||
@@ -1,4 +1,4 @@
|
||||
name: typecheck
|
||||
name: check
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -7,8 +7,13 @@ on:
|
||||
branches: [dev, v2]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ case(github.ref == 'refs/heads/dev', format('{0}-{1}', github.workflow, github.run_id), format('{0}-{1}', github.workflow, github.event.pull_request.number || github.ref)) }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
typecheck:
|
||||
check:
|
||||
name: typecheck
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -17,5 +22,5 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Run typecheck
|
||||
run: bun typecheck
|
||||
- name: Run checks
|
||||
run: bun run check
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
close-non-compliant:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Close non-compliant issues and PRs after 2 hours
|
||||
- name: Close non-compliant issues and PRs after 72 hours
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
with:
|
||||
script: |
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const twoHours = 2 * 60 * 60 * 1000;
|
||||
const seventyTwoHours = 72 * 60 * 60 * 1000;
|
||||
const orgMemberAssociations = new Set(['OWNER', 'MEMBER']);
|
||||
const agentLogin = 'opencode-agent[bot]';
|
||||
const { data: file } = await github.rest.repos.getContent({
|
||||
@@ -87,14 +87,14 @@ jobs:
|
||||
if (!complianceComment) continue;
|
||||
|
||||
const commentAge = now - new Date(complianceComment.created_at).getTime();
|
||||
if (commentAge < twoHours) {
|
||||
core.info(`${kind} #${item.number} still within 2-hour window (${Math.round(commentAge / 60000)}m elapsed)`);
|
||||
if (commentAge < seventyTwoHours) {
|
||||
core.info(`${kind} #${item.number} still within 72-hour window (${Math.round(commentAge / 60000)}m elapsed)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const closeMessage = isPR
|
||||
? 'This pull request has been automatically closed because it was not updated to meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md) within the 2-hour window.\n\nFeel free to open a new pull request that follows our guidelines.'
|
||||
: 'This issue has been automatically closed because it was not updated to meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md) within the 2-hour window.\n\nFeel free to open a new issue that follows our issue templates.';
|
||||
? 'This pull request has been automatically closed because it was not updated to meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md) within the 72-hour window.\n\nFeel free to open a new pull request that follows our guidelines.'
|
||||
: 'This issue has been automatically closed because it was not updated to meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md) within the 72-hour window.\n\nFeel free to open a new issue that follows our issue templates.';
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
@@ -129,5 +129,5 @@ jobs:
|
||||
});
|
||||
}
|
||||
|
||||
core.info(`Closed non-compliant ${kind} #${item.number} after 2-hour window`);
|
||||
core.info(`Closed non-compliant ${kind} #${item.number} after 72-hour window`);
|
||||
}
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
|
||||
If the issue is NOT compliant and the author association is not OWNER or MEMBER, start the comment with:
|
||||
<!-- issue-compliance -->
|
||||
Then explain what needs to be fixed and that they have 2 hours to edit the issue before it is automatically closed. Also add the label needs:compliance to the issue using: gh issue edit ${{ github.event.issue.number }} --add-label needs:compliance
|
||||
Then explain what needs to be fixed and that they have 72 hours to edit the issue before it is automatically closed. Also add the label needs:compliance to the issue using: gh issue edit ${{ github.event.issue.number }} --add-label needs:compliance
|
||||
|
||||
If duplicates were found, include a section about potential duplicates with links.
|
||||
|
||||
@@ -124,7 +124,7 @@ jobs:
|
||||
**What needs to be fixed:**
|
||||
- [specific reasons]
|
||||
|
||||
Please edit this issue to address the above within **2 hours**, or it will be automatically closed.
|
||||
Please edit this issue to address the above within **72 hours**, or it will be automatically closed.
|
||||
|
||||
[If duplicates found, add:]
|
||||
---
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
echo "=== Flake structure ==="
|
||||
nix flake show --all-systems
|
||||
|
||||
SYSTEMS="x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin"
|
||||
SYSTEMS="x86_64-linux aarch64-linux aarch64-darwin"
|
||||
PACKAGES="opencode"
|
||||
# TODO: move 'desktop' to PACKAGES when #11755 is fixed
|
||||
OPTIONAL_PACKAGES="desktop"
|
||||
|
||||
@@ -34,8 +34,6 @@ jobs:
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
- system: aarch64-linux
|
||||
runner: blacksmith-4vcpu-ubuntu-2404-arm
|
||||
- system: x86_64-darwin
|
||||
runner: macos-15-intel
|
||||
- system: aarch64-darwin
|
||||
runner: macos-latest
|
||||
runs-on: ${{ matrix.runner }}
|
||||
@@ -126,7 +124,7 @@ jobs:
|
||||
|
||||
[ -f "$HASH_FILE" ] || echo '{"nodeModules":{}}' > "$HASH_FILE"
|
||||
|
||||
for SYSTEM in x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin; do
|
||||
for SYSTEM in x86_64-linux aarch64-linux aarch64-darwin; do
|
||||
FILE="hashes/hash-${SYSTEM}/hash.txt"
|
||||
if [ -f "$FILE" ]; then
|
||||
HASH="$(tr -d '[:space:]' < "$FILE")"
|
||||
|
||||
@@ -307,7 +307,7 @@ jobs:
|
||||
**What needs to be fixed:**
|
||||
${issues.map(i => `- ${i}`).join('\n')}
|
||||
|
||||
Please edit this PR description to address the above within **2 hours**, or it will be automatically closed.
|
||||
Please edit this PR description to address the above within **72 hours**, or it will be automatically closed.
|
||||
|
||||
If you believe this was flagged incorrectly, please let a maintainer know.`;
|
||||
|
||||
|
||||
@@ -24,8 +24,12 @@ on:
|
||||
description: "Override version (optional)"
|
||||
required: false
|
||||
type: string
|
||||
release_notes:
|
||||
description: "Reviewed V2 release notes for the Discord announcement (optional)"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.version || inputs.bump }}
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}-${{ (github.ref_name == 'v2' && (inputs.version || inputs.bump) && 'release') || inputs.version || inputs.bump }}
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
@@ -33,7 +37,7 @@ permissions:
|
||||
packages: write
|
||||
|
||||
env:
|
||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'v2' && 'dev') || '' }}
|
||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'v2' && !inputs.bump && !inputs.version && 'dev') || '' }}
|
||||
|
||||
jobs:
|
||||
version:
|
||||
@@ -168,7 +172,7 @@ jobs:
|
||||
fi
|
||||
|
||||
found=0
|
||||
for file in packages/cli/dist/cli-darwin-*/bin/opencode2; do
|
||||
for file in packages/cli/dist/cli-darwin-*/bin/opencode; do
|
||||
if [ ! -f "$file" ]; then
|
||||
continue
|
||||
fi
|
||||
@@ -191,7 +195,7 @@ jobs:
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: opencode-preview-cli
|
||||
name: opencode-preview-cli-macos
|
||||
path: packages/cli/dist/cli-*
|
||||
if-no-files-found: error
|
||||
|
||||
@@ -199,7 +203,7 @@ jobs:
|
||||
needs: version
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 30
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
if: github.repository == 'anomalyco/opencode' && !(github.ref_name == 'v2' && (inputs.bump || inputs.version))
|
||||
steps:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
|
||||
@@ -221,7 +225,7 @@ jobs:
|
||||
needs:
|
||||
- version
|
||||
- build-node-app-archive
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
if: github.repository == 'anomalyco/opencode' && !(github.ref_name == 'v2' && (inputs.bump || inputs.version))
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -276,10 +280,9 @@ jobs:
|
||||
|
||||
sign-cli-windows:
|
||||
needs:
|
||||
- build-cli
|
||||
- version
|
||||
- sign-cli-macos
|
||||
runs-on: blacksmith-4vcpu-windows-2025
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2' && github.ref_name != 'beta'
|
||||
if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'v2' || github.ref_name == 'beta')
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -292,15 +295,8 @@ jobs:
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: opencode-cli-windows
|
||||
path: packages/opencode/dist
|
||||
|
||||
- name: Setup git committer
|
||||
id: committer
|
||||
uses: ./.github/actions/setup-git-committer
|
||||
with:
|
||||
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
|
||||
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
||||
name: opencode-preview-cli-macos
|
||||
path: packages/cli/dist
|
||||
|
||||
- name: Azure login
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
|
||||
@@ -315,9 +311,9 @@ jobs:
|
||||
signing-account-name: ${{ env.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
|
||||
certificate-profile-name: ${{ env.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
|
||||
files: |
|
||||
${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64\bin\opencode.exe
|
||||
${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64\bin\opencode.exe
|
||||
${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline\bin\opencode.exe
|
||||
${{ github.workspace }}\packages\cli\dist\cli-windows-arm64\bin\opencode.exe
|
||||
${{ github.workspace }}\packages\cli\dist\cli-windows-x64\bin\opencode.exe
|
||||
${{ github.workspace }}\packages\cli\dist\cli-windows-x64-baseline\bin\opencode.exe
|
||||
exclude-environment-credential: true
|
||||
exclude-workload-identity-credential: true
|
||||
exclude-managed-identity-credential: true
|
||||
@@ -333,9 +329,9 @@ jobs:
|
||||
shell: pwsh
|
||||
run: |
|
||||
$files = @(
|
||||
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64\bin\opencode.exe",
|
||||
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64\bin\opencode.exe",
|
||||
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline\bin\opencode.exe"
|
||||
"${{ github.workspace }}\packages\cli\dist\cli-windows-arm64\bin\opencode.exe",
|
||||
"${{ github.workspace }}\packages\cli\dist\cli-windows-x64\bin\opencode.exe",
|
||||
"${{ github.workspace }}\packages\cli\dist\cli-windows-x64-baseline\bin\opencode.exe"
|
||||
)
|
||||
|
||||
foreach ($file in $files) {
|
||||
@@ -345,40 +341,17 @@ jobs:
|
||||
}
|
||||
}
|
||||
|
||||
- name: Repack Windows CLI archives
|
||||
working-directory: packages/opencode/dist
|
||||
shell: pwsh
|
||||
run: |
|
||||
Compress-Archive -Path "opencode-windows-arm64\bin\*" -DestinationPath "opencode-windows-arm64.zip" -Force
|
||||
Compress-Archive -Path "opencode-windows-x64\bin\*" -DestinationPath "opencode-windows-x64.zip" -Force
|
||||
Compress-Archive -Path "opencode-windows-x64-baseline\bin\*" -DestinationPath "opencode-windows-x64-baseline.zip" -Force
|
||||
|
||||
- name: Upload signed Windows CLI release assets
|
||||
if: needs.version.outputs.release != ''
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
||||
run: |
|
||||
gh release upload "v${{ needs.version.outputs.version }}" `
|
||||
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64.zip" `
|
||||
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64.zip" `
|
||||
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline.zip" `
|
||||
--clobber `
|
||||
--repo "${{ needs.version.outputs.repo }}"
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: opencode-cli-signed-windows
|
||||
path: |
|
||||
packages/opencode/dist/opencode-windows-arm64
|
||||
packages/opencode/dist/opencode-windows-x64
|
||||
packages/opencode/dist/opencode-windows-x64-baseline
|
||||
name: opencode-preview-cli
|
||||
path: packages/cli/dist/cli-*
|
||||
if-no-files-found: error
|
||||
|
||||
build-electron:
|
||||
needs:
|
||||
- version
|
||||
- sign-cli-macos
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
|
||||
- sign-cli-windows
|
||||
if: github.repository == 'anomalyco/opencode' && (github.ref_name != 'v2' || needs.version.outputs.release != '')
|
||||
continue-on-error: false
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
@@ -684,3 +657,19 @@ jobs:
|
||||
OPENCODE_DESKTOP_DIST: /tmp/desktop
|
||||
CLOUDFLARE_ACCOUNT_ID: 15d29c8639fd3733b1b5486a2acfd968
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
|
||||
notify-discord-v2:
|
||||
needs:
|
||||
- version
|
||||
- publish
|
||||
if: ${{ !cancelled() && github.repository == 'anomalyco/opencode' && github.ref_name == 'v2' && needs.version.outputs.release && needs.publish.result == 'success' }}
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
# Unlike dev, V2 publishes a tag rather than a GitHub Release event.
|
||||
- name: Announce V2 release in Discord
|
||||
uses: SethCohen/github-releases-to-discord@24d166886aee4646d448c8a389ff9e1ebcab3682 # v1.20.0
|
||||
with:
|
||||
webhook_url: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
release_name: OpenCode V2 ${{ needs.version.outputs.tag }}
|
||||
release_body: ${{ inputs.release_notes }}
|
||||
release_html_url: https://github.com/${{ github.repository }}/tree/${{ needs.version.outputs.tag }}
|
||||
@@ -94,12 +94,6 @@ jobs:
|
||||
git config --global user.email "bot@opencode.ai"
|
||||
git config --global user.name "opencode"
|
||||
|
||||
- name: Install ffmpeg
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install --yes ffmpeg
|
||||
|
||||
- name: Cache Turbo
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
@@ -112,11 +106,12 @@ jobs:
|
||||
- name: Run unit tests
|
||||
timeout-minutes: 20
|
||||
run: |
|
||||
# The runners have four vCPUs, and each Bun test process performs its own concurrent work.
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
GITHUB_ACTIONS=false bun turbo test
|
||||
GITHUB_ACTIONS=false bun turbo test --concurrency=3
|
||||
exit 0
|
||||
fi
|
||||
GITHUB_ACTIONS=false bun turbo test --affected
|
||||
GITHUB_ACTIONS=false bun turbo test --affected --concurrency=3
|
||||
env:
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
||||
TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && format('{0}^1', github.sha) || github.event.before }}
|
||||
@@ -250,6 +245,13 @@ jobs:
|
||||
run: bunx playwright test --config e2e/service-worker/playwright.config.ts
|
||||
timeout-minutes: 5
|
||||
|
||||
- name: Run session UI component tests
|
||||
if: ${{ !cancelled() && env.E2E_ENABLED == 'true' }}
|
||||
run: bun --cwd packages/session-ui test:components
|
||||
env:
|
||||
CI: true
|
||||
timeout-minutes: 15
|
||||
|
||||
- name: Upload Playwright artifacts
|
||||
if: always() && env.E2E_ENABLED == 'true'
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
@@ -260,3 +262,5 @@ jobs:
|
||||
path: |
|
||||
packages/app/e2e/test-results
|
||||
packages/app/e2e/playwright-report
|
||||
packages/session-ui/component-tests/test-results
|
||||
packages/session-ui/component-tests/playwright-report
|
||||
@@ -32,6 +32,7 @@ target
|
||||
# Local dev files
|
||||
opencode-dev
|
||||
UPCOMING_CHANGELOG.md
|
||||
RELEASE_REVIEW.md
|
||||
logs/
|
||||
*.bun-build
|
||||
tsconfig.tsbuildinfo
|
||||
@@ -17,4 +17,4 @@ if (process.versions.bun !== expectedBunVersion) {
|
||||
console.warn(`Warning: Bun version ${process.versions.bun} differs from expected ${expectedBunVersion}`);
|
||||
}
|
||||
'
|
||||
bun typecheck
|
||||
bun run check
|
||||
@@ -1,45 +1,115 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicolo-ribaudo/oxc-project.github.io/refs/heads/json-schema/src/public/.oxlintrc.schema.json",
|
||||
"options": {
|
||||
"typeAware": true
|
||||
},
|
||||
"jsPlugins": [
|
||||
{ "name": "anti-slop", "specifier": "./script/oxlint/anti-slop/index.ts" },
|
||||
{ "name": "anti-slop-effect", "specifier": "./script/oxlint/anti-slop/effect/index.ts" }
|
||||
],
|
||||
"categories": {
|
||||
"suspicious": "warn"
|
||||
"correctness": "off",
|
||||
"suspicious": "off",
|
||||
"pedantic": "off",
|
||||
"perf": "off",
|
||||
"style": "off",
|
||||
"restriction": "off",
|
||||
"nursery": "off"
|
||||
},
|
||||
"rules": {
|
||||
"typescript/no-base-to-string": "warn",
|
||||
// Effect uses `function*` with Effect.gen/Effect.fnUntraced that don't always yield
|
||||
"require-yield": "off",
|
||||
// SolidJS uses `let ref: T | undefined` for JSX ref bindings assigned at runtime
|
||||
"no-unassigned-vars": "off",
|
||||
// SolidJS tracks reactive deps by reading properties inside createEffect
|
||||
"no-unused-expressions": "off",
|
||||
// Intentional control char matching (ANSI escapes, null byte sanitization)
|
||||
"no-control-regex": "off",
|
||||
// SST and plugin tools require triple-slash references
|
||||
"triple-slash-reference": "off",
|
||||
|
||||
// Suspicious category: suppress noisy rules
|
||||
// Effect's nested function* closures inherently shadow outer scope
|
||||
"no-shadow": "off",
|
||||
// Namespace-heavy codebase makes this too noisy
|
||||
"unicorn/consistent-function-scoping": "off",
|
||||
// Opinionated — .sort()/.reverse() mutation is fine in this codebase
|
||||
"unicorn/no-array-sort": "off",
|
||||
"unicorn/no-array-reverse": "off",
|
||||
// Not relevant — this isn't a DOM event handler codebase
|
||||
"unicorn/prefer-add-event-listener": "off",
|
||||
// Bundler handles module resolution
|
||||
"unicorn/require-module-specifiers": "off",
|
||||
// postMessage target origin not relevant for this codebase
|
||||
"unicorn/require-post-message-target-origin": "off",
|
||||
// Side-effectful constructors are intentional in some places
|
||||
"no-new": "off",
|
||||
|
||||
// Type-aware: catch unhandled promises
|
||||
"typescript/no-floating-promises": "warn",
|
||||
// Warn when spreading non-plain objects (Headers, class instances, etc.)
|
||||
"typescript/no-misused-spread": "warn"
|
||||
"no-restricted-globals": [
|
||||
"error",
|
||||
{
|
||||
"name": "Reflect",
|
||||
"message": "Use typed property access or direct invocation. Suppress this rule only for genuine reflection."
|
||||
}
|
||||
]
|
||||
},
|
||||
"ignorePatterns": ["**/node_modules", "**/dist", "**/.build", "**/.sst", "**/*.d.ts", "**/sdk.gen.ts"]
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
"packages/app/**",
|
||||
"packages/desktop/**",
|
||||
"packages/gui-extensions/**",
|
||||
"packages/ui/**",
|
||||
"packages/session-ui/**"
|
||||
],
|
||||
"rules": {
|
||||
"oxc/no-accumulating-spread": "warn",
|
||||
"anti-slop/no-array-filter-map": "warn",
|
||||
"anti-slop/no-reduce-accumulator-copy": "warn",
|
||||
"anti-slop/no-chained-type-assertions": "warn",
|
||||
"anti-slop/no-conditional-empty-object-spread": "warn",
|
||||
"anti-slop/no-known-value-widening": "warn",
|
||||
"anti-slop/no-module-mocking": "warn",
|
||||
"anti-slop/no-object-parameters": "warn",
|
||||
"anti-slop/no-reflect-apply": "warn",
|
||||
"anti-slop/no-reflect-get": "warn",
|
||||
"anti-slop/no-runtime-typeof": "warn",
|
||||
"anti-slop/no-shape-in-symbol-names": "warn",
|
||||
"anti-slop/no-unknown-parameters": "warn",
|
||||
"anti-slop/no-unknown-returns": "warn",
|
||||
"anti-slop/no-unknown-type-aliases": "warn",
|
||||
"anti-slop/no-unsafe-dictionary-type": "warn",
|
||||
"anti-slop/no-widen-then-assert": "warn",
|
||||
"anti-slop/require-readable-spacing": "warn",
|
||||
"anti-slop/require-safety-comment-for-type-assertion": "warn"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["packages/app/**", "packages/desktop/**", "packages/gui-extensions/**", "packages/session-ui/**"],
|
||||
"rules": {
|
||||
"anti-slop-effect/no-manual-effect-error-tag": "warn",
|
||||
"anti-slop-effect/no-manual-tag-comparison": "warn",
|
||||
"anti-slop-effect/no-manual-tagged-construction": "warn",
|
||||
"anti-slop-effect/no-service-constructor-imports": "warn",
|
||||
"anti-slop-effect/prefer-effect-match": "warn"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["packages/gui-extensions/src/*/**"],
|
||||
"rules": {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"patterns": [
|
||||
{
|
||||
"regex": "^@opencode/(app|desktop)(/|$)",
|
||||
"message": "GUI extensions never import the app or desktop packages. Use the SDK."
|
||||
},
|
||||
{
|
||||
"regex": "^@/",
|
||||
"message": "GUI extensions never import app internals. Use the SDK."
|
||||
},
|
||||
{
|
||||
"group": ["../*/*", "!../*/contract", "!../sdk/*"],
|
||||
"message": "Import another extension only through its contract.ts."
|
||||
},
|
||||
{
|
||||
"regex": "\\.css$",
|
||||
"message": "Import CSS with ?inline and contribute it with ctx.add(Style, css)."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"ignorePatterns": [
|
||||
"**/node_modules",
|
||||
"**/dist",
|
||||
"**/.build",
|
||||
"**/.sst",
|
||||
"**/*.d.ts",
|
||||
"**/sdk.gen.ts",
|
||||
".agent/**",
|
||||
".agents/**",
|
||||
".claude/**",
|
||||
".codex/**",
|
||||
".continue/**",
|
||||
".cursor/**",
|
||||
".gemini/**",
|
||||
".opencode/**",
|
||||
".pi/**",
|
||||
".roo/**",
|
||||
".windsurf/**",
|
||||
"script/oxlint/anti-slop/**"
|
||||
]
|
||||
}
|
||||
@@ -8,9 +8,9 @@
|
||||
|
||||
## Live V2 TUI Testing
|
||||
|
||||
- Run `bun run dev:live` from a development worktree to test its TUI against the currently elected `opencode2` background server and live sessions.
|
||||
- Run `bun run dev:live` from a development worktree to test its TUI against the currently elected `opencode` background server and live sessions.
|
||||
- Pass a directory after the script when needed, for example `bun run dev:live /path/to/project`.
|
||||
- The script discovers the server with `opencode2 service status`, injects its private local credential from `opencode2 service get password`, and uses the `dev` TUI storage channel so tabs and other client-local state match the installed client.
|
||||
- The script discovers the server with `opencode service status`, injects its private local credential from `opencode service get password`, and uses the `dev` TUI storage channel so tabs and other client-local state match the installed client.
|
||||
- Prefer `dev:live` over plain `bun run dev` for this workflow. An implicit managed-service connection may replace the live server when the worktree client version differs; explicit `--server` warns and continues without replacing it.
|
||||
|
||||
## V2 TUI Stories
|
||||
@@ -170,9 +170,10 @@ const table = sqliteTable("session", {
|
||||
- Test actual implementation, do not duplicate logic into tests
|
||||
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package directories such as `packages/core`.
|
||||
|
||||
## Type Checking
|
||||
## Checks
|
||||
|
||||
- Always run `bun typecheck` from package directories (for example, `packages/core`), never `tsc` directly.
|
||||
- Run `bun run check` from the repository root as the canonical full lint and type-check verification.
|
||||
- During focused iteration, run `bun typecheck` from the affected package directory (for example, `packages/core`). Never run `tsc` directly.
|
||||
|
||||
## V2 Session Core
|
||||
|
||||
@@ -183,6 +184,7 @@ const table = sqliteTable("session", {
|
||||
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
|
||||
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. A logical Step may use generic pre-output retries, one full-context retry after continuation rejection, incomplete-stream continuation, or one overflow-compaction rebuild. Generic retries retain the logical step number and do not consume another agent-step allowance. Do not delegate orchestration to an in-memory tool loop.
|
||||
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. A write-ahead execution claim marks a process-local busy period for restart recovery: terminal completion, failure, or user interruption releases it, while shutdown interruption and process death preserve it. Startup recovery resumes claimed top-level Sessions with durable per-execution attempt accounting. The claim is a recovery marker, not clustered ownership, fencing, or an exactly-once guarantee.
|
||||
- Keep provider-specific native compaction mechanisms in `@opencode/ai` behind `LLMClient.compact`. `SessionCompaction` chooses a summary or native compaction from the model's `compaction` setting and owns route provenance, request shrinking, the retry policy, interruption, usage accounting, and checkpoint persistence.
|
||||
- Keep delivery vocabulary explicit. Prompts steer by default. At safe step boundaries, steered compaction takes priority up to the first steered move control; other steers retain enqueue order. At an idle boundary, steers take priority; otherwise exactly one queued item delivers before the runner reevaluates continuation. Inbox items may be cancelled or changed between queue and steer before delivery. Promoting new user input resets the selected agent's step allowance; a batch of steers resets it once.
|
||||
- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
|
||||
- Keep event replay ownership separate from clustered Session execution ownership.
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
# V2 HTTP API audit checklist
|
||||
|
||||
**Source:** `packages/protocol/openapi.json`
|
||||
**Current endpoint count:** 139
|
||||
**Last regenerated:** 2026-09-13
|
||||
|
||||
## How to use this checklist
|
||||
|
||||
Review endpoints in document order. For each endpoint, select one disposition and capture rationale or follow-up work in Notes. Mark **Reviewed** only after the disposition is agreed.
|
||||
|
||||
### Review criteria
|
||||
|
||||
- Resource and operation naming
|
||||
- HTTP method and idempotency
|
||||
- Request parameters and location scope
|
||||
- Response shape and error taxonomy
|
||||
- Authentication and authorization
|
||||
- Current production consumers
|
||||
- Stability level: public, experimental, or internal
|
||||
- Whether the generated client API is intuitive
|
||||
|
||||
### Disposition legend
|
||||
|
||||
- **Keep:** ship unchanged as a supported V2 API
|
||||
- **Change:** retain after a defined contract change
|
||||
- **Remove:** exclude from the official V2 API
|
||||
- **Experimental-only:** retain outside the stable API commitment
|
||||
|
||||
## Progress
|
||||
|
||||
- [x] Group 1: Foundation and placement (4)
|
||||
- [x] Group 2: Configuration and capability catalogs (16)
|
||||
- [x] Group 3: Credentials, integrations, MCP, and web search (22)
|
||||
- [x] Group 4: Session lifecycle (12)
|
||||
- [x] Group 5: Session execution and inputs (11)
|
||||
- [x] Group 6: Session history and recovery (13)
|
||||
- [x] Group 7: Inbox, permissions, and forms (19)
|
||||
- [x] Group 8: Filesystem, worktrees, and VCS (12)
|
||||
- [x] Group 9: PTYs, persistent terminals, and shells (24)
|
||||
- [x] Group 10: Events, RPC, and experimental operations (6)
|
||||
|
||||
## Resolved during audit
|
||||
|
||||
### [x] `POST /api/plugin/await-activation`
|
||||
|
||||
- **Decision:** Remove
|
||||
- **Notes:** Activation timing is an internal server concern. Catalog reads remain non-blocking.
|
||||
|
||||
### [x] Location response wrappers
|
||||
|
||||
- **Decision:** Reduce generic endpoint response locations to `{ directory }`.
|
||||
- **Notes:** Full project metadata remains available from `GET /api/location`; no consumers used it from wrapped responses.
|
||||
|
||||
### [x] `GET /api/health` and `GET /api/server`
|
||||
|
||||
- **Decision:** Merge and rename
|
||||
- **Replacement:** `GET /api/info` with operation ID `server.info`.
|
||||
- **Notes:** Returns `version`, `pid`, and connection `urls`; readiness is conveyed by HTTP status.
|
||||
|
||||
### [x] `GET /api/project/current`
|
||||
|
||||
- **Decision:** Remove
|
||||
- **Replacement:** `GET /api/location`, using `project` from the response.
|
||||
- **Notes:** The endpoint duplicated `Location.Info.project`; production callers were migrated.
|
||||
|
||||
### [x] `POST /api/workspace` and `DELETE /api/workspace/{workspaceID}`
|
||||
|
||||
- **Decision:** Remove
|
||||
- **Notes:** Provider-backed workspaces are not part of the V2 HTTP contract and can be introduced later. Core and the embedded SDK retain internal workspace support.
|
||||
|
||||
## Group 1: Foundation and placement
|
||||
|
||||
**Endpoints:** 4
|
||||
|
||||
| Done | Method | Path | Operation ID | Decision | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| [x] 001–002 | `GET` | `/api/info` | `server.info` | Keep | Replaces the former health and server endpoints. |
|
||||
| [x] 003 | `GET` | `/api/location` | `location.get` | Keep | Workspace selectors and response fields removed until workspace support ships. |
|
||||
| [x] 004 | `GET` | `/api/project` | `project.list` | Keep | Removed unused `time.initialized`; the database column remains for migration data. |
|
||||
| [x] 005 | `PATCH` | `/api/project/{projectID}` | `project.update` | Keep | Request and response accepted as-is. |
|
||||
|
||||
## Group 2: Configuration and capability catalogs
|
||||
|
||||
**Endpoints:** 16
|
||||
|
||||
| Done | Method | Path | Operation ID | Decision | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| [x] 008 | `GET` | `/api/agent` | `agent.list` | Keep | Request and response accepted as-is. |
|
||||
| [x] 009 | `GET` | `/api/agent/{agentID}` | `agent.get` | Keep | Request, response, and not-found error accepted as-is. |
|
||||
| [x] 010 | `GET` | `/api/plugin` | `plugin.list` | Keep | Request and response accepted as-is. |
|
||||
| [x] 012 | `POST` | `/api/plugin/check` | `plugin.check` | Keep | Request and response accepted as-is. |
|
||||
| [x] 013 | `POST` | `/api/plugin/update` | `plugin.update` | Keep | Request and errors accepted as-is. |
|
||||
| [x] 014 | `GET` | `/api/model` | `model.list` | Keep | Request and response accepted as-is. |
|
||||
| [x] 015 | `GET` | `/api/model/default` | `model.default` | Keep | Request and nullable response accepted as-is. |
|
||||
| [x] 016 | `GET` | `/api/provider` | `provider.list` | Keep | Request and response accepted as-is. |
|
||||
| [x] 017 | `GET` | `/api/provider/{providerID}` | `provider.get` | Keep | Request, response, and not-found error accepted as-is. |
|
||||
| [x] 018 | `GET` | `/api/command` | `command.list` | Keep | Request and response accepted as-is. |
|
||||
| [x] 019 | `GET` | `/api/skill` | `skill.list` | Keep | Renamed `location` to `path`; removed the skill-specific `slash` flag and slash-command behavior. |
|
||||
| [x] 020 | `GET` | `/api/reference` | `reference.list` | Keep | Removed duplicate `description` and `hidden` fields from nested `source`. |
|
||||
| [x] 021 | `GET` | `/api/config` | `config.get` | Keep | Compatibility entries removed; response now contains only documents and OpenCode directories. |
|
||||
| [x] 022 | `GET` | `/api/config/preferences` | `config.preferences` | Remove | Redundant special projection of global config. |
|
||||
| [x] 023 | `PATCH` | `/api/config/preferences` | `config.updatePreferences` | Remove | Redundant field-specific config mutation API. |
|
||||
| [x] 024 | `GET` | `/api/config/shell` | `config.shells` | Keep | Required by the server Terminal shell setting. |
|
||||
| [x] 024a | `PATCH` | `/api/experimental/config` | `experimental.config.update` | Change | Experimental global config mutation; initially accepts only `shell`. |
|
||||
|
||||
## Group 3: Credentials, integrations, MCP, and web search
|
||||
|
||||
**Endpoints:** 22
|
||||
|
||||
| Done | Method | Path | Operation ID | Decision | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| [x] 025 | `GET` | `/api/integration` | `integration.list` | Keep | Full integration inventory is consumed by authentication and integration-selection clients. |
|
||||
| [x] 026 | `GET` | `/api/integration/{integrationID}` | `integration.get` | Change | Missing integration now returns typed `404` instead of optional data. |
|
||||
| [x] 027 | `POST` | `/api/experimental/integration/wellknown` | `experimental.integration.wellknown.add` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] 028 | `POST` | `/api/integration/{integrationID}/connect/key` | `integration.connect.key` | Change | Missing integration returns typed `404`; key form answers retained. |
|
||||
| [x] 029 | `POST` | `/api/integration/{integrationID}/connect/oauth` | `integration.oauth.connect` | Keep | OAuth connection start contract retained. |
|
||||
| [x] 030 | `GET` | `/api/integration/{integrationID}/connect/oauth/{attemptID}` | `integration.oauth.status` | Change | Missing integration or OAuth attempt returns typed `404`. |
|
||||
| [x] 031 | `DELETE` | `/api/integration/{integrationID}/connect/oauth/{attemptID}` | `integration.oauth.cancel` | Keep | Idempotent cancellation remains a no-op for unavailable or terminal attempts. |
|
||||
| [x] 032 | `POST` | `/api/integration/{integrationID}/connect/oauth/{attemptID}/complete` | `integration.oauth.complete` | Change | Missing integration or OAuth attempt returns typed `404`; code remains mode-dependent. |
|
||||
| [x] 033 | `POST` | `/api/integration/{integrationID}/connect/command` | `integration.command.connect` | Change | Missing integration or command method returns typed `404`. |
|
||||
| [x] 034 | `GET` | `/api/integration/{integrationID}/connect/command/{attemptID}` | `integration.command.status` | Change | Missing integration or command attempt returns typed `404`. |
|
||||
| [x] 035 | `DELETE` | `/api/integration/{integrationID}/connect/command/{attemptID}` | `integration.command.cancel` | Keep | Idempotent cancellation remains a no-op for unavailable or terminal attempts. |
|
||||
| [x] 036 | `GET` | `/api/mcp` | `mcp.list` | Keep | MCP inventory and connection status retained. |
|
||||
| [x] 037 | `PUT` | `/api/experimental/mcp/{server}` | `experimental.mcp.add` | Experimental-only | Runtime-only MCP override; does not persist configuration. |
|
||||
| [x] 038 | `DELETE` | `/api/experimental/mcp/{server}` | `experimental.mcp.remove` | Experimental-only | Runtime removal override; missing server returns `404`. |
|
||||
| [x] 039 | `POST` | `/api/experimental/mcp/{server}/connect` | `experimental.mcp.connect` | Experimental-only | Runtime connection override retained outside the stable API. |
|
||||
| [x] 040 | `POST` | `/api/experimental/mcp/{server}/disconnect` | `experimental.mcp.disconnect` | Experimental-only | Runtime disconnection override retained outside the stable API. |
|
||||
| [x] 041 | `GET` | `/api/mcp/resource` | `mcp.resource.catalog` | Keep | Reviewed separately by coworker. |
|
||||
| [x] 042 | `PATCH` | `/api/credential/{credentialID}` | `credential.update` | Change | Removed redundant location query; credentials and events are global. |
|
||||
| [x] 043 | `DELETE` | `/api/credential/{credentialID}` | `credential.remove` | Change | Removed redundant location query; credentials and events are global. |
|
||||
| [x] 044 | `POST` | `/api/credential/{credentialID}/activate` | `credential.activate` | Change | Removed redundant location query; credentials and events are global. |
|
||||
| [x] 045 | `GET` | `/api/websearch/provider` | `websearch.providers` | Keep | Provider availability remains location-scoped; singular resource path retained. |
|
||||
| [x] 046 | `POST` | `/api/websearch` | `websearch.query` | Keep | Unknown provider remains an invalid request; published time documented as Unix epoch milliseconds. |
|
||||
|
||||
## Group 4: Session lifecycle
|
||||
|
||||
**Endpoints:** 12
|
||||
|
||||
| Done | Method | Path | Operation ID | Decision | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| [x] 047 | `GET` | `/api/session` | `session.list` | Keep | Existing filtering, ordering, and cursor contract retained for now. |
|
||||
| [x] 048 | `POST` | `/api/session` | `session.create` | Keep | Existing creation contract retained; model reference includes optional variant. |
|
||||
| [x] 049 | `GET` | `/api/experimental/session/stats` | `experimental.session.stats` | Experimental-only | Session analytics retained outside the stable API commitment. |
|
||||
| [x] 050 | `GET` | `/api/session/active` | `session.active` | Keep | Status record retained for future active-state expansion. |
|
||||
| [x] 051 | `GET` | `/api/session/{sessionID}` | `session.get` | Keep | Specific session read and typed `404` retained. |
|
||||
| [x] 052 | `DELETE` | `/api/session/{sessionID}` | `session.remove` | Keep | Session and child deletion with typed `404` retained. |
|
||||
| [x] 053 | `POST` | `/api/session/{sessionID}/fork` | `session.fork` | Change | Request now accepts optional branded `before` message ID; omission copies full history. |
|
||||
| [x] 054 | `POST` | `/api/session/{sessionID}/agent` | `session.switchAgent` | Keep | Subsequent-execution agent selection retained. |
|
||||
| [x] 055 | `POST` | `/api/session/{sessionID}/model` | `session.switchModel` | Keep | Subsequent-execution model and optional variant selection retained. |
|
||||
| [x] 056 | `PATCH` | `/api/session/{sessionID}` | `session.update` | Change | General session patch updates title and permissions; rules emit `session.permissions`. |
|
||||
| [x] 057 | `POST` | `/api/session/{sessionID}/move` | `session.move` | Change | Removed inaccurate local-change transfer claim; delivery behavior retained. |
|
||||
| [x] 058 | `POST` | `/api/session/{sessionID}/background` | `session.background` | Keep | Backgroundable foreground tools transition to background observation; idle requests remain no-ops. |
|
||||
|
||||
## Group 5: Session execution and inputs
|
||||
|
||||
**Endpoints:** 11
|
||||
|
||||
| Done | Method | Path | Operation ID | Decision | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| [x] 059 | `POST` | `/api/session/{sessionID}/prompt` | `session.prompt` | Keep | Durable admission, delivery mode, and admit-only resume control retained. |
|
||||
| [x] 060 | `POST` | `/api/session/{sessionID}/command` | `session.command` | Change | Renamed request field from `command` to `name`; `204` retained. |
|
||||
| [x] 061 | `POST` | `/api/experimental/session/{sessionID}/skill` | `experimental.session.skill` | Experimental-only | Skill ID is now the `id` field; standalone activation remains experimental. |
|
||||
| [x] 062 | `POST` | `/api/session/{sessionID}/synthetic` | `session.synthetic` | Keep | Durable synthetic admission and delivery controls retained. |
|
||||
| [x] 063 | `POST` | `/api/session/{sessionID}/shell` | `session.shell` | Change | Caller ID is now the optimistic shell message ID; server derives its event ID. |
|
||||
| [x] 064 | `POST` | `/api/session/{sessionID}/compact` | `session.compact` | Keep | Durable compaction admission and delivery controls retained. |
|
||||
| [x] 065 | `POST` | `/api/experimental/session/{sessionID}/wait` | `experimental.session.wait` | Experimental-only | Race-free idle barrier retained outside the stable API. |
|
||||
| [x] 066 | `POST` | `/api/session/{sessionID}/generate` | `session.generate` | Keep | Transient generation from session context retained. |
|
||||
| [x] 067 | `POST` | `/api/session/{sessionID}/interrupt` | `session.interrupt` | Change | Renamed `continue` to `resume` across public and internal interruption APIs. |
|
||||
| [x] 068 | `PUT` | `/api/session/{sessionID}/environment` | `session.environment` | Keep | Process-local environment replacement retained in the stable API. |
|
||||
| [x] 069 | `POST` | `/api/session/{sessionID}/view` | `session.view` | Change | Idle watermark now uses the standard epoch-millisecond timestamp schema. |
|
||||
|
||||
## Group 6: Session history and recovery
|
||||
|
||||
**Endpoints:** 13
|
||||
|
||||
| Done | Method | Path | Operation ID | Decision | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| [x] 070 | `POST` | `/api/experimental/session/import` | `experimental.session.import` | Experimental-only | Existing projected transcript import contract retained outside the stable API. |
|
||||
| [x] 071 | `GET` | `/api/experimental/session/{sessionID}/export` | `experimental.session.export` | Experimental-only | Existing projected transcript export contract retained outside the stable API. |
|
||||
| [x] 072 | `POST` | `/api/session/{sessionID}/revert/stage` | `session.revert.stage` | Keep | Existing staged history and optional file restoration behavior retained. |
|
||||
| [x] 073 | `DELETE` | `/api/session/{sessionID}/revert` | `session.revert.clear` | Change | Clearing staged revert now deletes the session revert resource. |
|
||||
| [x] 074 | `POST` | `/api/session/{sessionID}/revert/commit` | `session.revert.commit` | Keep | Explicit staged-revert commit action retained. |
|
||||
| [x] 075 | `GET` | `/api/session/{sessionID}/context` | `session.context` | Keep | Active model-context projection retained. |
|
||||
| [x] 076 | `GET` | `/api/session/{sessionID}/diff` | `session.diff` | Keep | Turn-range structured diff contract retained. |
|
||||
| [x] 077 | `GET` | `/api/experimental/session/{sessionID}/instructions/entries` | `experimental.session.instructions.entry.list` | Experimental-only | API-managed durable context entries retained outside the stable API. |
|
||||
| [x] 078 | `PUT` | `/api/experimental/session/{sessionID}/instructions/entries/{key}` | `experimental.session.instructions.entry.put` | Experimental-only | API-managed durable context entries retained outside the stable API. |
|
||||
| [x] 079 | `DELETE` | `/api/experimental/session/{sessionID}/instructions/entries/{key}` | `experimental.session.instructions.entry.remove` | Experimental-only | API-managed durable context entries retained outside the stable API. |
|
||||
| [x] 080 | `GET` | `/api/experimental/session/{sessionID}/log` | `session.log` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] 081 | `GET` | `/api/session/{sessionID}/message/{messageID}` | `session.message.get` | Change | Normalized specific-message operation ID. |
|
||||
| [x] 082 | `GET` | `/api/session/{sessionID}/message` | `session.message.list` | Change | Normalized session-scoped message-list operation ID. |
|
||||
|
||||
## Group 7: Inbox, permissions, and forms
|
||||
|
||||
**Endpoints:** 19
|
||||
|
||||
| Done | Method | Path | Operation ID | Decision | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| [x] 083 | `GET` | `/api/session/{sessionID}/inbox` | `session.inbox.list` | Change | Inbox timestamps now use the standard nested `time.created` shape. |
|
||||
| [x] 084 | `DELETE` | `/api/session/{sessionID}/inbox/{inboxID}` | `session.inbox.cancel` | Change | Cancellation is idempotent and returns `204` when the session exists. |
|
||||
| [x] 085 | `PATCH` | `/api/session/{sessionID}/inbox/{inboxID}` | `session.inbox.update` | Change | Consolidated delivery mutation with `delivery: "steer" | "queue"`. |
|
||||
| [x] 086 | — | — | — | Remove | Replaced by `session.inbox.update`. |
|
||||
| [x] 087 | `GET` | `/api/form` | `form.list` | Change | Removed redundant `request` path and operation namespace. |
|
||||
| [x] 088 | `GET` | `/api/session/{sessionID}/form` | `session.form.list` | Keep | Pending session form list retained with temporary MCP sentinel compatibility. |
|
||||
| [x] 089 | `POST` | `/api/session/{sessionID}/form` | `session.form.create` | Keep | External form creation and temporary MCP sentinel ownership retained. |
|
||||
| [x] 090 | `GET` | `/api/session/{sessionID}/form/{formID}` | `session.form.get` | Change | Form definition and lifecycle state are now returned together. |
|
||||
| [x] 091 | — | — | — | Remove | State is included by `session.form.get`. |
|
||||
| [x] 092 | `POST` | `/api/session/{sessionID}/form/{formID}/reply` | `session.form.reply` | Keep | One-shot validated form reply retained. |
|
||||
| [x] 093 | `DELETE` | `/api/session/{sessionID}/form/{formID}` | `session.form.cancel` | Change | Form cancellation now deletes the pending form resource. |
|
||||
| [x] 094 | `GET` | `/api/permission/request` | `permission.request.list` | Keep | Pending-request namespace retained alongside saved permissions. |
|
||||
| [x] 095 | `GET` | `/api/permission/saved` | `permission.saved.list` | Change | Added persisted creation and update timestamps under `time`. |
|
||||
| [x] 096 | `DELETE` | `/api/permission/saved/{id}` | `permission.saved.remove` | Keep | Idempotent saved-permission deletion retained. |
|
||||
| [x] 097 | `POST` | `/api/session/{sessionID}/permission` | `session.permission.create` | Keep | Non-blocking permission evaluation and pending-request creation retained. |
|
||||
| [x] 098 | `GET` | `/api/session/{sessionID}/permission` | `session.permission.list` | Keep | Pending session permission list retained. |
|
||||
| [x] 099 | `GET` | `/api/session/{sessionID}/permission/{requestID}` | `session.permission.get` | Keep | Specific pending permission read with ownership validation retained. |
|
||||
| [x] 100 | `POST` | `/api/session/{sessionID}/permission/{requestID}/reply` | `session.permission.reply` | Change | Renamed request field from `reply` to `decision`. |
|
||||
| [x] 101 | — | — | — | Remove | Permission rules are updated through `session.update`. |
|
||||
|
||||
## Group 8: Filesystem, worktrees, and VCS
|
||||
|
||||
**Endpoints:** 12
|
||||
|
||||
| Done | Method | Path | Operation ID | Decision | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| [x] 102 | `GET` | `/api/fs/read/*` | `fs.read` | Keep | Relative wildcard file reads and raw byte responses retained. |
|
||||
| [x] 103 | `GET` | `/api/fs/list` | `fs.list` | Keep | Existing path scope and minimal entry metadata retained. |
|
||||
| [x] 104 | `GET` | `/api/fs/find` | `fs.find` | Keep | Existing ranked filesystem search retained. |
|
||||
| [x] 105 | `GET` | `/api/worktree` | `worktree.list` | Keep | Reviewed separately by coworker. |
|
||||
| [x] 106 | `POST` | `/api/worktree` | `worktree.create` | Keep | Reviewed separately by coworker. |
|
||||
| [x] 107 | `DELETE` | `/api/worktree` | `worktree.remove` | Keep | Reviewed separately by coworker. |
|
||||
| [x] 108 | `POST` | `/api/worktree/refresh` | `worktree.refresh` | Keep | Reviewed separately by coworker. |
|
||||
| [x] 109 | `GET` | `/api/vcs` | `vcs.get` | Change | Preserved branch nesting and added selected VCS provider ID. |
|
||||
| [x] 110 | `GET` | `/api/vcs/base` | `vcs.base` | Keep | Review-base inference and nullable unavailable state retained. |
|
||||
| [x] 111 | `GET` | `/api/vcs/status` | `vcs.status` | Keep | Existing working-copy status shape retained for now. |
|
||||
| [x] 112 | `GET` | `/api/vcs/branch` | `vcs.branch.list` | Change | Singular collection path and normalized operation ID. |
|
||||
| [x] 113 | `GET` | `/api/vcs/diff` | `vcs.diff` | Keep | Existing working, branch, and committed comparison modes retained. |
|
||||
|
||||
## Group 9: PTYs, persistent terminals, and shells
|
||||
|
||||
**Endpoints:** 24
|
||||
|
||||
| Done | Method | Path | Operation ID | Decision | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| [x] 114 | `GET` | `/api/pty` | `pty.list` | Keep | PTY endpoints reviewed together and retained. |
|
||||
| [x] 115 | `POST` | `/api/pty` | `pty.create` | Keep | PTY endpoints reviewed together and retained. |
|
||||
| [x] 116 | `GET` | `/api/pty/{ptyID}` | `pty.get` | Keep | PTY endpoints reviewed together and retained. |
|
||||
| [x] 117 | `PUT` | `/api/pty/{ptyID}` | `pty.update` | Keep | PTY endpoints reviewed together and retained. |
|
||||
| [x] 118 | `DELETE` | `/api/pty/{ptyID}` | `pty.remove` | Keep | PTY endpoints reviewed together and retained. |
|
||||
| [x] 119 | `POST` | `/api/pty/{ptyID}/connect-token` | `pty.connect.token` | Keep | PTY endpoints reviewed together and retained. |
|
||||
| [x] 120 | `GET` | `/api/pty/{ptyID}/connect` | `pty.connect` | Keep | PTY endpoints reviewed together and retained. |
|
||||
| [x] 121 | `GET` | `/api/experimental/session/{sessionID}/terminal/read` | `server.experimental.persistentPty.read` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] 122 | `GET` | `/api/experimental/session/{sessionID}/terminal` | `server.experimental.persistentPty.list` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] 123 | `POST` | `/api/experimental/session/{sessionID}/terminal` | `server.experimental.persistentPty.create` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] 124 | `POST` | `/api/experimental/persistent-pty/shutdown` | `server.experimental.persistentPty.shutdown` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] 125 | `POST` | `/api/experimental/persistent-pty/handoff` | `server.experimental.persistentPty.handoff` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] 126 | `GET` | `/api/experimental/persistent-pty/{ptyID}` | `server.experimental.persistentPty.get` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] 127 | `PUT` | `/api/experimental/persistent-pty/{ptyID}` | `server.experimental.persistentPty.update` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] 128 | `DELETE` | `/api/experimental/persistent-pty/{ptyID}` | `server.experimental.persistentPty.remove` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] 129 | `GET` | `/api/experimental/persistent-pty/{ptyID}/snapshot` | `server.experimental.persistentPty.snapshot` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] 130 | `POST` | `/api/experimental/persistent-pty/{ptyID}/connect-token` | `server.experimental.persistentPty.connectToken` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] 131 | `GET` | `/api/experimental/persistent-pty/{ptyID}/connect` | `persistentPty.connect` | Experimental-only | Retained outside the stable API commitment. |
|
||||
| [x] 132 | `GET` | `/api/shell` | `shell.list` | Change | Stable shell inventory retained; numeric timestamps documented as epoch milliseconds. |
|
||||
| [x] 133 | `POST` | `/api/shell` | `shell.create` | Change | Timeout is optional and defaults to zero; caller metadata retained. |
|
||||
| [x] 134 | `GET` | `/api/shell/{id}` | `shell.get` | Keep | Specific running or retained shell read retained. |
|
||||
| [x] 135 | `DELETE` | `/api/shell/{id}` | `shell.remove` | Change | Shell deletion is idempotent and returns `204` when already absent. |
|
||||
| [x] 136 | — | — | — | Remove | Timeout mutation remains an internal Core shell operation. |
|
||||
| [x] 137 | `GET` | `/api/shell/{id}/output` | `shell.output` | Keep | Existing byte-cursor text output paging retained. |
|
||||
|
||||
## Group 10: Events, RPC, and experimental operations
|
||||
|
||||
**Endpoints:** 6
|
||||
|
||||
| Done | Method | Path | Operation ID | Decision | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| [x] 138 | `POST` | `/api/experimental/generate` | `experimental.generate.text` | Experimental-only | Stateless generation retained alongside session generation. |
|
||||
| [x] 139 | `POST` | `/api/rpc/{rpcID}/{method}` | `rpc.call` | Keep | Generic typed-error plugin RPC transport retained. |
|
||||
| [x] 140 | `GET` | `/api/event` | `event.subscribe` | Keep | Unified native and dynamic plugin event stream retained. |
|
||||
| [x] 141 | `GET` | `/api/debug/location` | `debug.location.list` | Keep | Loaded-location debug inventory retained. |
|
||||
| [x] 142 | `DELETE` | `/api/debug/location` | `debug.location.evict` | Keep | Idempotent loaded-location eviction retained. |
|
||||
| [x] 143 | `GET` | `/api/experimental/migration/v1` | `experimental.migration.v1.status` | Experimental-only | Retained outside the stable API commitment. |
|
||||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 40 KiB |
@@ -0,0 +1,37 @@
|
||||
// BEGIN: baseline — first-line change
|
||||
type ReviewState = "pending" | "ready" | "failed"
|
||||
type ReviewProps = { title: string; count: number; state: ReviewState }
|
||||
|
||||
export const heading = "Review the old checkout experience"
|
||||
export const count = 8
|
||||
export const obsolete = "delete this isolated row"
|
||||
export const anchor = "unchanged between deletion and addition"
|
||||
|
||||
// Contiguous span versus several separated small spans.
|
||||
export const contiguous = "Keep the original checkout message readable"
|
||||
export const scattered = "alpha: old; beta: cold; gamma: slow"
|
||||
export const oneCharacter = "item-7"
|
||||
export const atStart = "before middle remains ending remains"
|
||||
export const atMiddle = "start remains before ending remains"
|
||||
export const atEnd = "start remains middle remains before"
|
||||
export const punctuation = { label: "Ready", enabled: true }
|
||||
export const spaces = "one two three"
|
||||
export const indent = "indent-only change"
|
||||
export const trailing = "trailing-space-only change"
|
||||
|
||||
// Syntax competition: comment, keyword, type, boolean, string, number.
|
||||
export function ReviewCard(props: ReviewProps) {
|
||||
const status: ReviewState = "pending"
|
||||
const enabled = false
|
||||
const retries = 3
|
||||
// Keep this quiet explanation readable on a tinted row.
|
||||
return <section aria-label="Old review" data-state={status}>
|
||||
<h2>{props.title}</h2>
|
||||
<span className="text-muted">{props.count} old items</span>
|
||||
<button disabled={!enabled}>Continue checkout</button>
|
||||
</section>
|
||||
}
|
||||
|
||||
export const longLine = "Start unchanged | The old checkout flow waits for manual confirmation before showing the receipt | Middle unchanged with punctuation: brackets [one, two], braces {three}, quotes 'four', slash /five/ | The old final instruction asks the reviewer to close the window | End unchanged"
|
||||
|
||||
// END: baseline — last-line change
|
||||
@@ -0,0 +1,66 @@
|
||||
// BEGIN baseline
|
||||
export const first = "old"
|
||||
// quiet context A01
|
||||
// quiet context A02
|
||||
// quiet context A03
|
||||
// quiet context A04
|
||||
// quiet context A05
|
||||
// quiet context A06
|
||||
// quiet context A07
|
||||
// quiet context A08
|
||||
// quiet context A09
|
||||
// quiet context A10
|
||||
// quiet context A11
|
||||
// quiet context A12
|
||||
// quiet context A13
|
||||
// quiet context A14
|
||||
// quiet context A15
|
||||
// quiet context A16
|
||||
// quiet context A17
|
||||
// quiet context A18
|
||||
// quiet context A19
|
||||
// quiet context A20
|
||||
export const second = "old"
|
||||
// quiet context B01
|
||||
// quiet context B02
|
||||
// quiet context B03
|
||||
// quiet context B04
|
||||
// quiet context B05
|
||||
// quiet context B06
|
||||
// quiet context B07
|
||||
// quiet context B08
|
||||
// quiet context B09
|
||||
// quiet context B10
|
||||
// quiet context B11
|
||||
// quiet context B12
|
||||
// quiet context B13
|
||||
// quiet context B14
|
||||
// quiet context B15
|
||||
// quiet context B16
|
||||
// quiet context B17
|
||||
// quiet context B18
|
||||
// quiet context B19
|
||||
// quiet context B20
|
||||
export const third = "old"
|
||||
// quiet context C01
|
||||
// quiet context C02
|
||||
// quiet context C03
|
||||
// quiet context C04
|
||||
// quiet context C05
|
||||
// quiet context C06
|
||||
// quiet context C07
|
||||
// quiet context C08
|
||||
// quiet context C09
|
||||
// quiet context C10
|
||||
// quiet context C11
|
||||
// quiet context C12
|
||||
// quiet context C13
|
||||
// quiet context C14
|
||||
// quiet context C15
|
||||
// quiet context C16
|
||||
// quiet context C17
|
||||
// quiet context C18
|
||||
// quiet context C19
|
||||
// quiet context C20
|
||||
export const fourth = "old"
|
||||
// END baseline
|
||||
@@ -0,0 +1,21 @@
|
||||
# Before: plain-text review
|
||||
|
||||
Unchanged prose should remain quiet, not compete with changed rows.
|
||||
Replace one contiguous phrase: the original checkout experience stays here.
|
||||
Several little edits: red apple, cold tea, slow train.
|
||||
One character: ticket 7.
|
||||
before — the middle and the end remain unchanged.
|
||||
The start remains — before — the end remains.
|
||||
The start and the middle remain — before
|
||||
Punctuation only: hello, world!
|
||||
Whitespace only: one two three
|
||||
Trailing whitespace only.
|
||||
Delete this sentence without a replacement.
|
||||
|
||||
This unchanged sentence separates a deletion from an addition.
|
||||
|
||||
Long prose: The original checkout flow asks the reader to review the old confirmation message before proceeding through the receipt screen, while the unchanged middle of this deliberately long paragraph checks whether horizontal scrolling or line wrapping keeps small inline edits visible at a narrow viewport; the final old phrase appears near the far right edge.
|
||||
|
||||
**Syntax-like Markdown** competes with `inline code`, [links](https://example.com/old), and _emphasis_.
|
||||
|
||||
Final sentence before.
|
||||
@@ -0,0 +1,63 @@
|
||||
// Large file: unchanged prefix and suffix surround a dense block.
|
||||
export const records = [
|
||||
{ id: "row-001", state: "pending", count: 1 },
|
||||
{ id: "row-002", state: "pending", count: 2 },
|
||||
{ id: "row-003", state: "pending", count: 3 },
|
||||
{ id: "row-004", state: "pending", count: 4 },
|
||||
{ id: "row-005", state: "pending", count: 5 },
|
||||
{ id: "row-006", state: "pending", count: 6 },
|
||||
{ id: "row-007", state: "pending", count: 7 },
|
||||
{ id: "row-008", state: "pending", count: 8 },
|
||||
{ id: "row-009", state: "pending", count: 9 },
|
||||
{ id: "row-010", state: "pending", count: 10 },
|
||||
{ id: "row-011", state: "pending", count: 11 },
|
||||
{ id: "row-012", state: "pending", count: 12 },
|
||||
{ id: "row-013", state: "pending", count: 13 },
|
||||
{ id: "row-014", state: "pending", count: 14 },
|
||||
{ id: "row-015", state: "pending", count: 15 },
|
||||
{ id: "row-016", state: "pending", count: 16 },
|
||||
{ id: "row-017", state: "pending", count: 17 },
|
||||
{ id: "row-018", state: "pending", count: 18 },
|
||||
{ id: "row-019", state: "pending", count: 19 },
|
||||
{ id: "row-020", state: "pending", count: 20 },
|
||||
{ id: "row-021", state: "pending", count: 21 },
|
||||
{ id: "row-022", state: "pending", count: 22 },
|
||||
{ id: "row-023", state: "pending", count: 23 },
|
||||
{ id: "row-024", state: "pending", count: 24 },
|
||||
{ id: "row-025", state: "pending", count: 25 },
|
||||
{ id: "row-026", state: "pending", count: 26 },
|
||||
{ id: "row-027", state: "pending", count: 27 },
|
||||
{ id: "row-028", state: "pending", count: 28 },
|
||||
{ id: "row-029", state: "pending", count: 29 },
|
||||
{ id: "row-030", state: "pending", count: 30 },
|
||||
{ id: "row-031", state: "pending", count: 31 },
|
||||
{ id: "row-032", state: "pending", count: 32 },
|
||||
{ id: "row-033", state: "pending", count: 33 },
|
||||
{ id: "row-034", state: "pending", count: 34 },
|
||||
{ id: "row-035", state: "pending", count: 35 },
|
||||
{ id: "row-036", state: "pending", count: 36 },
|
||||
{ id: "row-037", state: "pending", count: 37 },
|
||||
{ id: "row-038", state: "pending", count: 38 },
|
||||
{ id: "row-039", state: "pending", count: 39 },
|
||||
{ id: "row-040", state: "pending", count: 40 },
|
||||
{ id: "row-041", state: "pending", count: 41 },
|
||||
{ id: "row-042", state: "pending", count: 42 },
|
||||
{ id: "row-043", state: "pending", count: 43 },
|
||||
{ id: "row-044", state: "pending", count: 44 },
|
||||
{ id: "row-045", state: "pending", count: 45 },
|
||||
{ id: "row-046", state: "pending", count: 46 },
|
||||
{ id: "row-047", state: "pending", count: 47 },
|
||||
{ id: "row-048", state: "pending", count: 48 },
|
||||
{ id: "row-049", state: "pending", count: 49 },
|
||||
{ id: "row-050", state: "pending", count: 50 },
|
||||
{ id: "row-051", state: "pending", count: 51 },
|
||||
{ id: "row-052", state: "pending", count: 52 },
|
||||
{ id: "row-053", state: "pending", count: 53 },
|
||||
{ id: "row-054", state: "pending", count: 54 },
|
||||
{ id: "row-055", state: "pending", count: 55 },
|
||||
{ id: "row-056", state: "pending", count: 56 },
|
||||
{ id: "row-057", state: "pending", count: 57 },
|
||||
{ id: "row-058", state: "pending", count: 58 },
|
||||
{ id: "row-059", state: "pending", count: 59 },
|
||||
{ id: "row-060", state: "pending", count: 60 },
|
||||
]
|
||||
@@ -0,0 +1,4 @@
|
||||
This entire tracked file will be deleted after the baseline commit.
|
||||
Only deletion tint should be present.
|
||||
No syntax colors should compete with this text.
|
||||
Last deleted row.
|
||||
@@ -0,0 +1,41 @@
|
||||
/* Baseline stylesheet: colors, units, selectors, at-rules. */
|
||||
:root {
|
||||
--brand: #3b5cf6;
|
||||
--radius: 6px;
|
||||
--gap: 8px;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
gap: var(--gap);
|
||||
padding: 12px 16px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: var(--radius);
|
||||
background: #ffffff;
|
||||
color: #161616;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.card[data-state="error"] {
|
||||
color: #b82d35;
|
||||
border-color: #f2bbb7;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.card {
|
||||
flex-direction: column;
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "diff-color-fixture",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"settings": {
|
||||
"theme": "light",
|
||||
"fontSize": 13,
|
||||
"lineHeight": 20,
|
||||
"wrap": false,
|
||||
"tabs": ["review", "files", "terminal"]
|
||||
},
|
||||
"features": {
|
||||
"inlineHighlights": true,
|
||||
"foldUnchanged": true,
|
||||
"splitView": false
|
||||
},
|
||||
"limits": {
|
||||
"maxFiles": 100,
|
||||
"maxLineLength": 1000
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Baseline Python service: decorators, f-strings, indentation, comments."""
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class Review:
|
||||
id: str
|
||||
title: str
|
||||
approved: bool = False
|
||||
|
||||
|
||||
class ReviewService:
|
||||
def __init__(self, timeout: float = 2.0) -> None:
|
||||
self.timeout = timeout
|
||||
self.cache: dict[str, Review] = {}
|
||||
|
||||
async def load(self, review_id: str) -> Review | None:
|
||||
# Return cached reviews before touching the network.
|
||||
if review_id in self.cache:
|
||||
return self.cache[review_id]
|
||||
await asyncio.sleep(self.timeout)
|
||||
return None
|
||||
|
||||
def summary(self, review: Review) -> str:
|
||||
status = "approved" if review.approved else "pending"
|
||||
return f"Review {review.id}: {review.title} ({status})"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
service = ReviewService()
|
||||
review = Review(id="r-1", title="Checkout")
|
||||
print(service.summary(review))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,19 @@
|
||||
// Moved and reordered blocks: the same code appears as deletion here and addition elsewhere.
|
||||
export function first() {
|
||||
return "first"
|
||||
}
|
||||
|
||||
export function second() {
|
||||
const values = [1, 2, 3]
|
||||
return values.map((value) => value * 2)
|
||||
}
|
||||
|
||||
export function third() {
|
||||
return "third"
|
||||
}
|
||||
|
||||
export function fourth() {
|
||||
return "fourth"
|
||||
}
|
||||
|
||||
export const order = ["first", "second", "third", "fourth"]
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
# Baseline shell script: variables, quoting, conditionals.
|
||||
set -euo pipefail
|
||||
|
||||
ENVIRONMENT="${1:-staging}"
|
||||
REGION="us-east-1"
|
||||
RETRIES=3
|
||||
|
||||
log() {
|
||||
echo "[deploy] $*"
|
||||
}
|
||||
|
||||
if [[ "$ENVIRONMENT" == "production" ]]; then
|
||||
log "Deploying to production in $REGION"
|
||||
else
|
||||
log "Deploying to $ENVIRONMENT"
|
||||
fi
|
||||
|
||||
for attempt in $(seq 1 "$RETRIES"); do
|
||||
log "Attempt $attempt of $RETRIES"
|
||||
done
|
||||
@@ -0,0 +1,19 @@
|
||||
name: review
|
||||
on:
|
||||
push:
|
||||
branches: [v2]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install
|
||||
run: bun install
|
||||
- name: Check
|
||||
run: bun run check
|
||||
env:
|
||||
CI: true
|
||||
LOG_LEVEL: info
|
||||
@@ -0,0 +1,12 @@
|
||||
# Unicode and mixed scripts
|
||||
|
||||
Emoji: ✅ ready, ⚠️ warning, ❌ failed.
|
||||
CJK: 代码审查 完成。
|
||||
Japanese: レビューを開始します。
|
||||
Korean: 변경 사항을 검토합니다.
|
||||
Arabic (RTL): مراجعة التغييرات
|
||||
Hebrew (RTL): סקירת שינויים
|
||||
Accents: café, naïve, résumé.
|
||||
Math: a ≤ b, x ≠ y, ∑ values.
|
||||
Box: ┌─┐ │ │ └─┘
|
||||
Combining: e\u0301 vs é
|
||||
@@ -0,0 +1,18 @@
|
||||
package fixture
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Tab-indented Go: tab-width rendering and tab-only changes.
|
||||
type Review struct {
|
||||
ID string
|
||||
Title string
|
||||
Approved bool
|
||||
}
|
||||
|
||||
func (r Review) Summary() string {
|
||||
status := "pending"
|
||||
if r.Approved {
|
||||
status = "approved"
|
||||
}
|
||||
return fmt.Sprintf("%s: %s (%s)", r.ID, r.Title, status)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
This tracked file keeps existing but loses all content.
|
||||
Every row should render as a deletion.
|
||||
The file itself is not deleted.
|
||||
@@ -0,0 +1,2 @@
|
||||
First line
|
||||
Last line without trailing newline
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Review</title>
|
||||
</head>
|
||||
<body class="light">
|
||||
<main id="app" data-state="idle">
|
||||
<h1>Old heading</h1>
|
||||
<button type="button" disabled>Continue</button>
|
||||
<!-- baseline comment -->
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,6 @@
|
||||
SELECT id, title, status
|
||||
FROM reviews
|
||||
WHERE status = 'pending'
|
||||
AND created_at > NOW() - INTERVAL '7 days'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 50;
|
||||
@@ -0,0 +1,15 @@
|
||||
# Temporary diff color review
|
||||
|
||||
Local-only visual fixture. Not imported by product code. Do not format these files.
|
||||
|
||||
After the baseline commit, leave all example edits uncommitted.
|
||||
|
||||
- `01-inline.tsx`: adjacent rows, inline spans, one character, syntax, whitespace, long line.
|
||||
- `02-folded.ts`: isolated hunks and multiple collapsed unchanged sections.
|
||||
- `03-prose.md`: plain prose, punctuation, spaces, file boundaries, long prose.
|
||||
- `04-many-rows.ts`: larger dense changed region.
|
||||
- `05-deleted.txt` / `06-added.txt`: whole-file deletion / addition.
|
||||
|
||||
Open this worktree in OpenCode, open Review, select Uncommitted changes, and choose a file.
|
||||
Compare the default OpenCode theme in Light and Dark with Unified and Split layouts.
|
||||
Use a 1440 × 1000 viewport for comparisons and 800 × 1000 for the narrow case.
|
||||
@@ -0,0 +1,147 @@
|
||||
# File diff color tokens and CSS variables
|
||||
|
||||
Inventory for this worktree's V2 web/desktop file viewer. Source inspection only; no colors or behavior changed. Includes diff-specific colors, syntax colors, and the selection/search/comment colors used inside the viewer—not every unrelated global app variable. Unified and split use the same color system.
|
||||
|
||||
## Main controls and their wiring
|
||||
|
||||
| Visual role | Renderer variable | OpenCode source |
|
||||
|---|---|---|
|
||||
| Neutral file background | `--diffs-bg` | `--opencode-diffs-bg`, falling back to `--color-background-stronger` (alias of `--background-stronger`) |
|
||||
| Default text | `--fg` / `--diffs-fg` | Registered theme foreground: `--text-base`; syntax spans override it |
|
||||
| Addition seed / gutter bar | `--diffs-addition-color` → `--diffs-addition-base` | `--syntax-diff-add` |
|
||||
| Deletion seed / gutter bar | `--diffs-deletion-color` → `--diffs-deletion-base` | `--syntax-diff-delete` |
|
||||
| Modified seed | `--diffs-modified-color` → `--diffs-modified-base` | `--syntax-diff-unknown` |
|
||||
| Added row | `--diffs-bg-addition`, `--diffs-bg-addition-override` | Mix of neutral background and addition seed |
|
||||
| Deleted row | `--diffs-bg-deletion`, `--diffs-bg-deletion-override` | Mix of neutral background and deletion seed |
|
||||
| Added inline span | `--diffs-bg-addition-emphasis`, `--diffs-bg-addition-emphasis-override` | Alpha of addition seed |
|
||||
| Deleted inline span | `--diffs-bg-deletion-emphasis`, `--diffs-bg-deletion-emphasis-override` | Alpha of deletion seed; softened on selected deletion rows |
|
||||
| Folded “unmodified lines” row | `--diffs-bg-separator`, `--diffs-bg-separator-override` | Mix of neutral background and `--diffs-mixer` |
|
||||
| Ordinary line numbers / fold text | `--diffs-fg-number`, `--diffs-fg-number-override` | Foreground/background mix |
|
||||
| Selection background | `--diffs-selection-base` → `--diffs-bg-selection` | `--v2-background-bg-accent` |
|
||||
| Selected line-number text | `--diffs-selection-number-fg` | `--v2-text-text-accent` |
|
||||
| Comment annotation background | `--diffs-comment-bg` | Alpha of `--v2-background-bg-accent` |
|
||||
| Search match / current match | CSS highlight backgrounds | Alpha of `--surface-warning-base` / `--surface-warning-strong` |
|
||||
|
||||
**Important:** `--surface-diff-add-*`, `--surface-diff-delete-*`, and `--surface-diff-hidden-*` exist in the global theme, but are not the direct row/inline/fold controls in the current Pierre rendering path. The older/custom separator CSS in `components/file.css` does use `--surface-diff-hidden-base` and `--surface-diff-hidden-strong`.
|
||||
|
||||
## OpenCode diff-specific theme tokens — complete families
|
||||
|
||||
Each name below is a CSS custom property. Theme JSON override keys omit the leading `--`. Tailwind's `--color-…` aliases are defined in `packages/ui/src/styles/tailwind/colors.css` (for example `--color-surface-diff-add-base` → `--surface-diff-add-base`); these are aliases, not separate color decisions.
|
||||
|
||||
### Surfaces
|
||||
|
||||
```text
|
||||
--surface-diff-unchanged-base
|
||||
--surface-diff-skip-base
|
||||
--surface-diff-hidden-base
|
||||
--surface-diff-hidden-weak
|
||||
--surface-diff-hidden-weaker
|
||||
--surface-diff-hidden-strong
|
||||
--surface-diff-hidden-stronger
|
||||
--surface-diff-add-base
|
||||
--surface-diff-add-weak
|
||||
--surface-diff-add-weaker
|
||||
--surface-diff-add-strong
|
||||
--surface-diff-add-stronger
|
||||
--surface-diff-delete-base
|
||||
--surface-diff-delete-weak
|
||||
--surface-diff-delete-weaker
|
||||
--surface-diff-delete-strong
|
||||
--surface-diff-delete-stronger
|
||||
```
|
||||
|
||||
### Text, icons, and highlighter diff seeds
|
||||
|
||||
```text
|
||||
--text-diff-add-base
|
||||
--text-diff-add-strong
|
||||
--text-diff-delete-base
|
||||
--text-diff-delete-strong
|
||||
--icon-diff-add-base
|
||||
--icon-diff-add-hover
|
||||
--icon-diff-add-active
|
||||
--icon-diff-delete-base
|
||||
--icon-diff-delete-hover
|
||||
--icon-diff-modified-base
|
||||
--syntax-diff-add
|
||||
--syntax-diff-delete
|
||||
--syntax-diff-unknown
|
||||
```
|
||||
|
||||
The built-in OpenCode theme maps `--syntax-diff-add` and `--text-diff-add-base` to `--v2-state-fg-success`, and their delete counterparts to `--v2-state-fg-danger`. Optional palette seeds `diffAdd` and `diffDelete` also exist for theme resolution; they are theme inputs, not CSS variable names, and built-in overrides can supersede generated results.
|
||||
|
||||
## Syntax foreground tokens
|
||||
|
||||
```text
|
||||
--syntax-comment
|
||||
--syntax-regexp
|
||||
--syntax-string
|
||||
--syntax-keyword
|
||||
--syntax-primitive
|
||||
--syntax-operator
|
||||
--syntax-variable
|
||||
--syntax-property
|
||||
--syntax-type
|
||||
--syntax-constant
|
||||
--syntax-punctuation
|
||||
--syntax-object
|
||||
--syntax-success
|
||||
--syntax-warning
|
||||
--syntax-critical
|
||||
--syntax-info
|
||||
--syntax-unknown
|
||||
```
|
||||
|
||||
`--syntax-unknown` is referenced by the registered highlighter, but no definition was found in the current UI theme sources: treat it as an unresolved reference, not an existing resolved theme token. `--syntax-success` is available globally, although not directly used by that registered theme's current token-color rules.
|
||||
|
||||
Built-in syntax overrides additionally reference `--v2-text-text-muted`, `--v2-pink-800`, `--v2-green-800`, `--v2-orange-800`, `--v2-purple-800`, and `--v2-red-800` (with separate dark/light resolutions). Other syntax tokens use the ordinary text tokens or theme-resolved colors.
|
||||
|
||||
## Pierre renderer color variables — complete relevant inventory
|
||||
|
||||
These come from the installed `@pierre/diffs` **1.5.1** stylesheet plus OpenCode's injected CSS. `*-override` variables are hooks; other variables include derived outputs and internal implementation details, not independent OpenCode theme tokens. Some optional hooks are unset by default.
|
||||
|
||||
| Group | Variables |
|
||||
|---|---|
|
||||
| Base | `--diffs-bg`, `--diffs-fg`, `--diffs-mixer`, `--opencode-diffs-bg`, `--bg`, `--fg` |
|
||||
| Theme foreground/background variants | `--diffs-light`, `--diffs-dark`, `--diffs-light-bg`, `--diffs-dark-bg` |
|
||||
| Neutral backgrounds | `--diffs-bg-buffer`, `--diffs-bg-buffer-override`, `--diffs-bg-context`, `--diffs-bg-context-override`, `--diffs-bg-context-gutter`, `--diffs-bg-context-gutter-override`, `--diffs-bg-separator`, `--diffs-bg-separator-override` |
|
||||
| Number / conflict-marker foreground | `--diffs-fg-number`, `--diffs-fg-number-override`, `--diffs-fg-number-addition-override`, `--diffs-fg-number-deletion-override`, `--diffs-fg-conflict-marker`, `--diffs-fg-conflict-marker-override` |
|
||||
| Addition seed | `--diffs-addition-base`, `--diffs-addition-color`, `--diffs-addition-color-override`, `--diffs-light-addition-color`, `--diffs-dark-addition-color` |
|
||||
| Deletion seed | `--diffs-deletion-base`, `--diffs-deletion-color`, `--diffs-deletion-color-override`, `--diffs-light-deletion-color`, `--diffs-dark-deletion-color` |
|
||||
| Modified seed | `--diffs-modified-base`, `--diffs-modified-color`, `--diffs-modified-color-override`, `--diffs-light-modified-color`, `--diffs-dark-modified-color` |
|
||||
| Renderer fallback colors | `--diffs-added-light`, `--diffs-added-dark`, `--diffs-deleted-light`, `--diffs-deleted-dark`, `--diffs-modified-light`, `--diffs-modified-dark`, `--diffs-warning-light`, `--diffs-warning-dark` |
|
||||
| Added row/gutter/inline | `--diffs-bg-addition`, `--diffs-bg-addition-override`, `--diffs-bg-addition-number-override`, `--diffs-bg-addition-emphasis`, `--diffs-bg-addition-emphasis-override` |
|
||||
| Deleted row/gutter/inline | `--diffs-bg-deletion`, `--diffs-bg-deletion-override`, `--diffs-bg-deletion-number-override`, `--diffs-bg-deletion-emphasis`, `--diffs-bg-deletion-emphasis-override` |
|
||||
| Selection | `--diffs-selection-base`, `--diffs-selection-number-fg`, `--diffs-bg-selection`, `--diffs-bg-selection-override`, `--diffs-bg-selection-number`, `--diffs-bg-selection-number-override`, `--diffs-bg-selection-text` |
|
||||
| Hover | `--diffs-bg-hover-override`, `--diffs-hover-mix-target` |
|
||||
| Comments / decoration | `--diffs-comment-bg`, `--diffs-annotation-bg`, `--diffs-decoration-bg`, `--diffs-decoration-bar-color` |
|
||||
| Internal background pipeline | `--diffs-computed-decoration-bg`, `--diffs-computed-diff-line-bg`, `--diffs-computed-selected-line-bg`, `--diffs-computed-editor-active-line-bg`, `--diffs-computed-hovered-line-bg`, `--diffs-line-bg` |
|
||||
| Internal mix targets | `--diffs-diff-line-mix-target`, `--diffs-selection-mix-target`, `--diffs-selection-emphasis-mix-target` |
|
||||
| Per-token light/dark colors | `--diffs-token-light`, `--diffs-token-dark`, `--diffs-token-light-bg`, `--diffs-token-dark-bg` |
|
||||
| Conflict backgrounds (library support; not exercised by the fixture) | `--conflict-bg-current-header-override`, `--conflict-bg-current-number-override`, `--conflict-bg-current-override`, `--conflict-bg-incoming-header-override`, `--conflict-bg-incoming-number-override`, `--conflict-bg-incoming-override` |
|
||||
|
||||
Related mix controls are **percentages, not colors**: `--mix-light`, `--mix-dark`, `--mix-deco-light`, `--mix-deco-dark`, `--mix-selection-light`, `--mix-selection-dark`, and `--diffs-editor-active-line-source-mix`.
|
||||
|
||||
## Other color tokens used inside the file viewer
|
||||
|
||||
- Text: `--text-base`, `--text-weak`, `--text-strong`.
|
||||
- Background fallback: `--background-stronger`, `--color-background-stronger`; comment actions also use `--background-base`.
|
||||
- Selection: `--v2-background-bg-accent`, `--v2-text-text-accent`.
|
||||
- Search: `--surface-warning-base`, `--surface-warning-strong`.
|
||||
- Custom fold UI: `--surface-diff-hidden-base`, `--surface-diff-hidden-strong`, `--icon-strong-base`; `--text-mix-blend-mode` affects compositing but is not a color.
|
||||
- Comment controls: `--icon-interactive-base`, `--white`, `--surface-raised-stronger-non-alpha`, `--surface-base`, `--surface-raised-base-hover`, `--border-base`, `--text-weak`, `--text-strong`, `--background-base`.
|
||||
- Comment shadows (composite shadow tokens, not single colors): `--shadow-xs`, `--shadow-xs-border-focus`, `--shadow-xxs-border`, `--shadow-xs-border-select`.
|
||||
|
||||
Typography, sizing, gaps, gutter widths, font weight/style, and text-decoration variables are deliberately excluded from this color inventory.
|
||||
|
||||
## Source locations
|
||||
|
||||
- `packages/session-ui/src/pierre/index.ts`: viewer color overrides, selection, search, comment tint.
|
||||
- `packages/ui/src/context/marked-theme.tsx`: registered foreground/background, diff seeds, syntax mappings.
|
||||
- `packages/ui/src/theme/resolve.ts`: global diff/syntax token generation.
|
||||
- `packages/ui/src/theme/themes/oc-2.json`: built-in light/dark mappings.
|
||||
- `packages/ui/src/styles/theme.css` and `styles/tailwind/colors.css`: defaults and aliases.
|
||||
- `packages/session-ui/src/components/file.css`: custom fold-separator styling.
|
||||
- `packages/session-ui/src/components/line-comment-styles.ts`: comment controls.
|
||||
- Installed `@pierre/diffs/dist/style.js`: renderer variables and derived backgrounds.
|
||||
- Installed `@pierre/diffs/dist/utils/getHighlighterThemeStyles.js`: highlighter-to-renderer seed bridge.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Diff color review (temporary)
|
||||
|
||||
Review-only material for the diff color PR. This whole commit is dropped before merge: `diff-color-fixture/`,
|
||||
`diff-color-review-artifacts/`, `packages/session-ui/src/pierre/diff-color-tuning.ts`, and its import in
|
||||
`packages/session-ui/src/pierre/index.ts`.
|
||||
|
||||
## See the example diffs
|
||||
|
||||
The fixture baselines are committed; their edits ship as a patch so they show up as uncommitted Git changes.
|
||||
|
||||
```sh
|
||||
git apply diff-color-review-artifacts/fixture-edits.patch
|
||||
```
|
||||
|
||||
Open a session in this worktree, open **Review → Git changes**, and pick a file in `diff-color-fixture/`.
|
||||
Undo with `git checkout -- diff-color-fixture && git clean -fd diff-color-fixture`.
|
||||
|
||||
## Tune colors yourself
|
||||
|
||||
With the desktop dev app running from this worktree (`bun run dev:desktop`):
|
||||
|
||||
```sh
|
||||
bun run diff-color-review-artifacts/tuner/server.ts
|
||||
```
|
||||
|
||||
Open http://127.0.0.1:4455 (any browser, or the OpenCode browser pane).
|
||||
|
||||
- Every change saves to `diff-color-tuning.ts` (hot-reloaded) and is pushed live into open diffs through the dev
|
||||
app's debug port (9222; override with `TUNER_CDP`).
|
||||
- **Override set**: `Reference` is the committed, read-only set used for this PR (it matches the branch colors, so it
|
||||
previews as no change). Editing it saves a copy; use **New** for a blank set. Your sets stay local and untracked.
|
||||
- “Current” leaves the branch colors untouched. Slots can be an exact color or a v2 token, with opacity.
|
||||
- **Copy CSS** / **Paste CSS** share a set with someone else; Undo/Redo and per-category toggles are in the toolbar.
|
||||
|
||||
`REPORT.md` and the screenshots document the colors before this change; `COLOR-TOKENS.md` lists the diff view's
|
||||
color tokens and Pierre variables.
|
||||
@@ -0,0 +1,69 @@
|
||||
# OpenCode V2 diff color review — temporary fixture
|
||||
|
||||
[File diff color token / CSS variable inventory](COLOR-TOKENS.md)
|
||||
|
||||
## Location and refs
|
||||
|
||||
- Worktree: `/Users/usrnk1/.local/share/opencode/worktree/012780/diff-color-review`
|
||||
- Branch: `diff-color-review` (local only).
|
||||
- Fetched `origin/v2`: `7e42a897bc6dbbba580392d779e47137038bcafb`.
|
||||
- Local baseline / HEAD: `5e14ede13c260b3b1bd2e16c5bb25722d12f366b`.
|
||||
- Fixture: `diff-color-fixture/01-inline.tsx`, `02-folded.ts`, `03-prose.md`, `04-many-rows.ts`, `05-deleted.txt` (deleted), `06-added.txt` (untracked addition).
|
||||
- `diff-color-fixture/README.md` is tracked and unchanged.
|
||||
- Only fixtures changed. No renderer, token, behavior, formatter, push, PR, or deployment changes.
|
||||
|
||||
## Actual UI and how to open
|
||||
|
||||
The captures use the real source-backed OpenCode web app from this worktree, connected to the existing V2 background server (2.0.21). No mocked data or copied renderer. The app dev server remains available at `http://127.0.0.1:4444` and was started with `VITE_OPENCODE_SERVER_PORT=49374 bun run dev -- --port 4444` from `packages/app`. Existing app/server processes were not restarted.
|
||||
|
||||
Open:
|
||||
|
||||
`http://127.0.0.1:4444/server/aHR0cDovLzEyNy4wLjAuMTo0OTM3NA/session/ses_f085f0176ffeiNj9364c2CnHqm`
|
||||
|
||||
Alternatively open this session in OpenCode Desktop; its location is now this worktree. Toggle Review, choose **Git changes**, and select a fixture file. This UI calls the working-tree source “Git changes,” not “Uncommitted changes.” Use the file-tree toggle if the list is hidden. Unified/Split controls are in the review toolbar.
|
||||
|
||||
Captures: 1440 × 1000 CSS pixels, default OpenCode (`oc-2`) theme, system light/dark media preference. Chat pane was resized to its minimum and the file tree hidden to give the diff about 964 pixels. Wrapping was enabled by the production viewer. Close-ups below are unchanged crops of those screenshots, not re-rendered mockups.
|
||||
|
||||
## Representative screenshots
|
||||
|
||||
- [Dark inline close-up](dark-inline-closeup.png) / [Light inline close-up](light-inline-closeup.png)
|
||||
- [Dark split inline](dark-split-inline.png) / [Light split inline](light-split-inline.png)
|
||||
- [Dark split folded: all three bars](dark-split-folded.png) / [Light split folded](light-split-folded.png)
|
||||
- [Dark unified folded](dark-unified-folded.png) / [Light unified folded](light-unified-folded.png)
|
||||
- [Dark syntax and wrapped long line](dark-split-syntax-long.png) / [Light syntax and wrapped long line](light-split-syntax-long.png)
|
||||
- [Dark Markdown](dark-split-prose.png) / [Light Markdown](light-split-prose.png)
|
||||
|
||||
Additional unified captures are in this same directory.
|
||||
|
||||
## Case notes
|
||||
|
||||
| Case | Unchanged sections | Red/green scan | Inline emphasis | Text readability |
|
||||
|---|---|---|---|---|
|
||||
| Single additions/deletions/replacements, adjacent rows (`01`, `03`) | Neutral background recedes; unchanged syntax can still draw the eye | Light is clearer; dark relies more on gutter bars and line numbers | Distinct but restrained | TS strings stay green even on red deletion rows, competing with change meaning |
|
||||
| Contiguous versus separated edits (`01` lines 11–12; long line) | Common words inside an inline span do **not** recede | Row direction is still clear from gutter and tint | Current production `word-line` rendering joins separated edits into one broad span; multiple independent highlight islands were not available in this state | Broad wrapped emphasis adds visual weight without improving precision |
|
||||
| One-character, start/middle/end edits (`01` lines 13–16; `03`) | Surrounding text remains readable | Large words are easy to find; one-character changes need deliberate attention | One-character patch is visible but easy to miss at normal scan speed | No observed loss of string readability |
|
||||
| Punctuation and spaces/indent/trailing spaces (`01` lines 17–20; `03`) | Quiet surroundings | Row markers expose that something changed | Small semicolon/space blocks are visible on close inspection; blank blocks give little explanation without visible whitespace glyphs | Readable; punctuation has less salience than colored keywords |
|
||||
| Isolated hunks and collapsed sections (`02`) | Neutral context recedes; three bars visible simultaneously in split | Distinct isolated rows, but dark tints are weak | Small changed-word blocks don't dominate rows | Fold-label text is unusually faint, especially dark; context comments are clearer than the fold labels |
|
||||
| TS/TSX types, keywords, comments and JSX (`01`) | Syntax in unchanged rows remains fairly prominent | Pink keywords/green strings compete with the red/green layer | Emphasis is generally subordinate; the long merged span is the exception | Comments/strings/types readable overall; muted JSX words like “old,” “new,” and button text are weak on tinted/highlighted backgrounds in dark mode |
|
||||
| Markdown and wrapped prose (`03`) | Plain prose is visually quieter than code | Light tints are clearer than dark | Long spans emphasize unchanged middle words as well as changes | Plain prose remains readable but looks muted; bold/heading syntax draws attention more strongly than small edit spans |
|
||||
| Long/wrapped lines (`01`, `03`) | Common middle text is swallowed by merged emphasis | Tinted multi-line blocks are apparent | Broad highlights repeat across wraps and visually dominate small changes | Wrapping works in unified and split; side-by-side requires more vertical scanning |
|
||||
| File beginning/end (`01`, `02`, `03`) | Neutral surrounding rows recede | Changes shown at first/last lines | Same inline behavior as middle edits | Readable |
|
||||
| Dense larger file (`04`: 63 lines, 40 replacements / 80 changed rows) | **Not visually checked** | Not checked | Not checked | Fixture ready for review |
|
||||
| Added/deleted whole files (`05`, `06`) | Viewer lists both with D/A badges | **File contents not visually checked** | Not checked | Fixture ready for review |
|
||||
| Narrow viewport (planned 800 × 1000) | **Not checked** | Not checked | Not checked | Stopped expanding capture scope at user request |
|
||||
|
||||
## Specific problems to carry into a later design pass
|
||||
|
||||
1. Dark row backgrounds have weak separation from neutral context; syntax hue is often more salient than change direction.
|
||||
2. Green string syntax appears on deleted rows too, weakening red/green semantic scanning. Pink keywords similarly attract attention independently of change status.
|
||||
3. Collapsed-section labels recede too far: their subdued text is harder to read than surrounding context comments.
|
||||
4. One-character, punctuation, and whitespace highlights are easy to miss without focused inspection. Their issue is subtlety/size, not overpowering brightness.
|
||||
5. Muted JSX text on dark inline backgrounds is less readable than the surrounding syntax. Broad inline spans across wrapped lines add disproportionate visual mass.
|
||||
|
||||
The merged-span behavior is a renderer observation, not a proposed color fix. No final values selected, no fixes implemented, and no contrast-ratio compliance claim made.
|
||||
|
||||
## GitHub Desktop comparison boundary
|
||||
|
||||
The referenced discussion/image was not included in this session. A precise comparison to that direction is therefore **not verified**. As a provisional hierarchy comparison only: quiet context → recognizable changed row → localized stronger inline emphasis is the useful target. OpenCode already keeps ordinary inline backgrounds subordinate to rows, but dark row separation, syntax competition, faint fold controls, and merged broad spans weaken that hierarchy. This is not a claim that the specific GitHub Desktop reference was inspected.
|
||||
|
||||
Scope was intentionally stopped after the user's request to do less. No full lint/typecheck was run: these are standalone visual examples with deliberate whitespace/formatting changes, not product code.
|
||||
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 222 KiB |
|
After Width: | Height: | Size: 309 KiB |
|
After Width: | Height: | Size: 291 KiB |
|
After Width: | Height: | Size: 322 KiB |
|
After Width: | Height: | Size: 209 KiB |
|
After Width: | Height: | Size: 299 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 249 KiB |
|
After Width: | Height: | Size: 289 KiB |
@@ -0,0 +1,601 @@
|
||||
diff --git a/diff-color-fixture/01-inline.tsx b/diff-color-fixture/01-inline.tsx
|
||||
index 1d16049d61..5074a3bc83 100644
|
||||
--- a/diff-color-fixture/01-inline.tsx
|
||||
+++ b/diff-color-fixture/01-inline.tsx
|
||||
@@ -1,37 +1,37 @@
|
||||
-// BEGIN: baseline — first-line change
|
||||
+// BEGIN: edited — first-line change
|
||||
type ReviewState = "pending" | "ready" | "failed"
|
||||
type ReviewProps = { title: string; count: number; state: ReviewState }
|
||||
|
||||
-export const heading = "Review the old checkout experience"
|
||||
-export const count = 8
|
||||
-export const obsolete = "delete this isolated row"
|
||||
+export const heading = "Review the new checkout experience"
|
||||
+export const count = 9
|
||||
export const anchor = "unchanged between deletion and addition"
|
||||
+export const added = "add this isolated row"
|
||||
|
||||
// Contiguous span versus several separated small spans.
|
||||
-export const contiguous = "Keep the original checkout message readable"
|
||||
-export const scattered = "alpha: old; beta: cold; gamma: slow"
|
||||
-export const oneCharacter = "item-7"
|
||||
-export const atStart = "before middle remains ending remains"
|
||||
-export const atMiddle = "start remains before ending remains"
|
||||
-export const atEnd = "start remains middle remains before"
|
||||
-export const punctuation = { label: "Ready", enabled: true }
|
||||
-export const spaces = "one two three"
|
||||
-export const indent = "indent-only change"
|
||||
-export const trailing = "trailing-space-only change"
|
||||
+export const contiguous = "Keep the redesigned payment summary readable"
|
||||
+export const scattered = "alpha: new; beta: warm; gamma: fast"
|
||||
+export const oneCharacter = "item-8"
|
||||
+export const atStart = "after middle remains ending remains"
|
||||
+export const atMiddle = "start remains after ending remains"
|
||||
+export const atEnd = "start remains middle remains after"
|
||||
+export const punctuation = { label: "Ready", enabled: true };
|
||||
+export const spaces = "one two three"
|
||||
+ export const indent = "indent-only change"
|
||||
+export const trailing = "trailing-space-only change"
|
||||
|
||||
// Syntax competition: comment, keyword, type, boolean, string, number.
|
||||
export function ReviewCard(props: ReviewProps) {
|
||||
- const status: ReviewState = "pending"
|
||||
- const enabled = false
|
||||
- const retries = 3
|
||||
- // Keep this quiet explanation readable on a tinted row.
|
||||
- return <section aria-label="Old review" data-state={status}>
|
||||
+ const status: ReviewState | undefined = "ready"
|
||||
+ const enabled = true
|
||||
+ const retries = 4
|
||||
+ // Keep this revised explanation readable on a tinted row.
|
||||
+ return <article aria-label="New review" data-state={status}>
|
||||
<h2>{props.title}</h2>
|
||||
- <span className="text-muted">{props.count} old items</span>
|
||||
- <button disabled={!enabled}>Continue checkout</button>
|
||||
- </section>
|
||||
+ <span className="text-primary">{props.count} new items</span>
|
||||
+ <button disabled={!enabled}>Confirm payment</button>
|
||||
+ </article>
|
||||
}
|
||||
|
||||
-export const longLine = "Start unchanged | The old checkout flow waits for manual confirmation before showing the receipt | Middle unchanged with punctuation: brackets [one, two], braces {three}, quotes 'four', slash /five/ | The old final instruction asks the reviewer to close the window | End unchanged"
|
||||
+export const longLine = "Start unchanged | The new payment flow waits for automatic confirmation before showing the receipt | Middle unchanged with punctuation: brackets [one, two], braces {three}, quotes 'four', slash /five/ | The new final instruction asks the reviewer to keep the window | End unchanged"
|
||||
|
||||
-// END: baseline — last-line change
|
||||
+// END: edited — last-line change
|
||||
diff --git a/diff-color-fixture/02-folded.ts b/diff-color-fixture/02-folded.ts
|
||||
index 07541ab49d..a5b9b354c3 100644
|
||||
--- a/diff-color-fixture/02-folded.ts
|
||||
+++ b/diff-color-fixture/02-folded.ts
|
||||
@@ -1,5 +1,5 @@
|
||||
-// BEGIN baseline
|
||||
-export const first = "old"
|
||||
+// BEGIN edited
|
||||
+export const first = "new"
|
||||
// quiet context A01
|
||||
// quiet context A02
|
||||
// quiet context A03
|
||||
@@ -20,7 +20,7 @@ export const first = "old"
|
||||
// quiet context A18
|
||||
// quiet context A19
|
||||
// quiet context A20
|
||||
-export const second = "old"
|
||||
+export const second = "new"
|
||||
// quiet context B01
|
||||
// quiet context B02
|
||||
// quiet context B03
|
||||
@@ -41,7 +41,7 @@ export const second = "old"
|
||||
// quiet context B18
|
||||
// quiet context B19
|
||||
// quiet context B20
|
||||
-export const third = "old"
|
||||
+export const third = "new"
|
||||
// quiet context C01
|
||||
// quiet context C02
|
||||
// quiet context C03
|
||||
@@ -62,5 +62,5 @@ export const third = "old"
|
||||
// quiet context C18
|
||||
// quiet context C19
|
||||
// quiet context C20
|
||||
-export const fourth = "old"
|
||||
-// END baseline
|
||||
+export const fourth = "new"
|
||||
+// END edited
|
||||
diff --git a/diff-color-fixture/03-prose.md b/diff-color-fixture/03-prose.md
|
||||
index 4d5b31e3b9..7d01146b6a 100644
|
||||
--- a/diff-color-fixture/03-prose.md
|
||||
+++ b/diff-color-fixture/03-prose.md
|
||||
@@ -1,21 +1,21 @@
|
||||
-# Before: plain-text review
|
||||
+# After: plain-text review
|
||||
|
||||
Unchanged prose should remain quiet, not compete with changed rows.
|
||||
-Replace one contiguous phrase: the original checkout experience stays here.
|
||||
-Several little edits: red apple, cold tea, slow train.
|
||||
-One character: ticket 7.
|
||||
-before — the middle and the end remain unchanged.
|
||||
-The start remains — before — the end remains.
|
||||
-The start and the middle remain — before
|
||||
-Punctuation only: hello, world!
|
||||
-Whitespace only: one two three
|
||||
-Trailing whitespace only.
|
||||
-Delete this sentence without a replacement.
|
||||
+Replace one contiguous phrase: the redesigned payment summary stays here.
|
||||
+Several little edits: green apple, warm tea, fast train.
|
||||
+One character: ticket 8.
|
||||
+after — the middle and the end remain unchanged.
|
||||
+The start remains — after — the end remains.
|
||||
+The start and the middle remain — after
|
||||
+Punctuation only: hello; world?
|
||||
+Whitespace only: one two three
|
||||
+Trailing whitespace only.
|
||||
|
||||
This unchanged sentence separates a deletion from an addition.
|
||||
+Add this sentence without a corresponding deletion.
|
||||
|
||||
-Long prose: The original checkout flow asks the reader to review the old confirmation message before proceeding through the receipt screen, while the unchanged middle of this deliberately long paragraph checks whether horizontal scrolling or line wrapping keeps small inline edits visible at a narrow viewport; the final old phrase appears near the far right edge.
|
||||
+Long prose: The redesigned payment flow asks the reader to review the new confirmation message before proceeding through the receipt screen, while the unchanged middle of this deliberately long paragraph checks whether horizontal scrolling or line wrapping keeps small inline edits visible at a narrow viewport; the final new phrase appears near the far right edge.
|
||||
|
||||
-**Syntax-like Markdown** competes with `inline code`, [links](https://example.com/old), and _emphasis_.
|
||||
+**Syntax-rich Markdown** competes with `inline code`, [links](https://example.com/new), and _emphasis_.
|
||||
|
||||
-Final sentence before.
|
||||
+Final sentence after.
|
||||
diff --git a/diff-color-fixture/04-many-rows.ts b/diff-color-fixture/04-many-rows.ts
|
||||
index 7a8c6ecd49..b3e544ae5f 100644
|
||||
--- a/diff-color-fixture/04-many-rows.ts
|
||||
+++ b/diff-color-fixture/04-many-rows.ts
|
||||
@@ -10,46 +10,46 @@ export const records = [
|
||||
{ id: "row-008", state: "pending", count: 8 },
|
||||
{ id: "row-009", state: "pending", count: 9 },
|
||||
{ id: "row-010", state: "pending", count: 10 },
|
||||
- { id: "row-011", state: "pending", count: 11 },
|
||||
- { id: "row-012", state: "pending", count: 12 },
|
||||
- { id: "row-013", state: "pending", count: 13 },
|
||||
- { id: "row-014", state: "pending", count: 14 },
|
||||
- { id: "row-015", state: "pending", count: 15 },
|
||||
- { id: "row-016", state: "pending", count: 16 },
|
||||
- { id: "row-017", state: "pending", count: 17 },
|
||||
- { id: "row-018", state: "pending", count: 18 },
|
||||
- { id: "row-019", state: "pending", count: 19 },
|
||||
- { id: "row-020", state: "pending", count: 20 },
|
||||
- { id: "row-021", state: "pending", count: 21 },
|
||||
- { id: "row-022", state: "pending", count: 22 },
|
||||
- { id: "row-023", state: "pending", count: 23 },
|
||||
- { id: "row-024", state: "pending", count: 24 },
|
||||
- { id: "row-025", state: "pending", count: 25 },
|
||||
- { id: "row-026", state: "pending", count: 26 },
|
||||
- { id: "row-027", state: "pending", count: 27 },
|
||||
- { id: "row-028", state: "pending", count: 28 },
|
||||
- { id: "row-029", state: "pending", count: 29 },
|
||||
- { id: "row-030", state: "pending", count: 30 },
|
||||
- { id: "row-031", state: "pending", count: 31 },
|
||||
- { id: "row-032", state: "pending", count: 32 },
|
||||
- { id: "row-033", state: "pending", count: 33 },
|
||||
- { id: "row-034", state: "pending", count: 34 },
|
||||
- { id: "row-035", state: "pending", count: 35 },
|
||||
- { id: "row-036", state: "pending", count: 36 },
|
||||
- { id: "row-037", state: "pending", count: 37 },
|
||||
- { id: "row-038", state: "pending", count: 38 },
|
||||
- { id: "row-039", state: "pending", count: 39 },
|
||||
- { id: "row-040", state: "pending", count: 40 },
|
||||
- { id: "row-041", state: "pending", count: 41 },
|
||||
- { id: "row-042", state: "pending", count: 42 },
|
||||
- { id: "row-043", state: "pending", count: 43 },
|
||||
- { id: "row-044", state: "pending", count: 44 },
|
||||
- { id: "row-045", state: "pending", count: 45 },
|
||||
- { id: "row-046", state: "pending", count: 46 },
|
||||
- { id: "row-047", state: "pending", count: 47 },
|
||||
- { id: "row-048", state: "pending", count: 48 },
|
||||
- { id: "row-049", state: "pending", count: 49 },
|
||||
- { id: "row-050", state: "pending", count: 50 },
|
||||
+ { id: "row-011", state: "ready", count: 111 },
|
||||
+ { id: "row-012", state: "ready", count: 112 },
|
||||
+ { id: "row-013", state: "ready", count: 113 },
|
||||
+ { id: "row-014", state: "ready", count: 114 },
|
||||
+ { id: "row-015", state: "ready", count: 115 },
|
||||
+ { id: "row-016", state: "ready", count: 116 },
|
||||
+ { id: "row-017", state: "ready", count: 117 },
|
||||
+ { id: "row-018", state: "ready", count: 118 },
|
||||
+ { id: "row-019", state: "ready", count: 119 },
|
||||
+ { id: "row-020", state: "ready", count: 120 },
|
||||
+ { id: "row-021", state: "ready", count: 121 },
|
||||
+ { id: "row-022", state: "ready", count: 122 },
|
||||
+ { id: "row-023", state: "ready", count: 123 },
|
||||
+ { id: "row-024", state: "ready", count: 124 },
|
||||
+ { id: "row-025", state: "ready", count: 125 },
|
||||
+ { id: "row-026", state: "ready", count: 126 },
|
||||
+ { id: "row-027", state: "ready", count: 127 },
|
||||
+ { id: "row-028", state: "ready", count: 128 },
|
||||
+ { id: "row-029", state: "ready", count: 129 },
|
||||
+ { id: "row-030", state: "ready", count: 130 },
|
||||
+ { id: "row-031", state: "ready", count: 131 },
|
||||
+ { id: "row-032", state: "ready", count: 132 },
|
||||
+ { id: "row-033", state: "ready", count: 133 },
|
||||
+ { id: "row-034", state: "ready", count: 134 },
|
||||
+ { id: "row-035", state: "ready", count: 135 },
|
||||
+ { id: "row-036", state: "ready", count: 136 },
|
||||
+ { id: "row-037", state: "ready", count: 137 },
|
||||
+ { id: "row-038", state: "ready", count: 138 },
|
||||
+ { id: "row-039", state: "ready", count: 139 },
|
||||
+ { id: "row-040", state: "ready", count: 140 },
|
||||
+ { id: "row-041", state: "ready", count: 141 },
|
||||
+ { id: "row-042", state: "ready", count: 142 },
|
||||
+ { id: "row-043", state: "ready", count: 143 },
|
||||
+ { id: "row-044", state: "ready", count: 144 },
|
||||
+ { id: "row-045", state: "ready", count: 145 },
|
||||
+ { id: "row-046", state: "ready", count: 146 },
|
||||
+ { id: "row-047", state: "ready", count: 147 },
|
||||
+ { id: "row-048", state: "ready", count: 148 },
|
||||
+ { id: "row-049", state: "ready", count: 149 },
|
||||
+ { id: "row-050", state: "ready", count: 150 },
|
||||
{ id: "row-051", state: "pending", count: 51 },
|
||||
{ id: "row-052", state: "pending", count: 52 },
|
||||
{ id: "row-053", state: "pending", count: 53 },
|
||||
diff --git a/diff-color-fixture/05-deleted.txt b/diff-color-fixture/05-deleted.txt
|
||||
deleted file mode 100644
|
||||
index e80ad6b276..0000000000
|
||||
--- a/diff-color-fixture/05-deleted.txt
|
||||
+++ /dev/null
|
||||
@@ -1,4 +0,0 @@
|
||||
-This entire tracked file will be deleted after the baseline commit.
|
||||
-Only deletion tint should be present.
|
||||
-No syntax colors should compete with this text.
|
||||
-Last deleted row.
|
||||
diff --git a/diff-color-fixture/06-added.txt b/diff-color-fixture/06-added.txt
|
||||
new file mode 100644
|
||||
index 0000000000..f27ed60c9b
|
||||
--- /dev/null
|
||||
+++ b/diff-color-fixture/06-added.txt
|
||||
@@ -0,0 +1,5 @@
|
||||
+This entire file is newly added, not a replacement for the deleted example.
|
||||
+Only addition tint should be present.
|
||||
+Plain text: keep the words readable against the changed-row background.
|
||||
+A deliberately long added line checks the horizontal extent of the green row while the reviewer compares the quiet surrounding application surface against the color used for new content in both dark and light modes.
|
||||
+Last added row.
|
||||
diff --git a/diff-color-fixture/07-styles.css b/diff-color-fixture/07-styles.css
|
||||
index a757ef7085..4f4937480e 100644
|
||||
--- a/diff-color-fixture/07-styles.css
|
||||
+++ b/diff-color-fixture/07-styles.css
|
||||
@@ -1,32 +1,35 @@
|
||||
-/* Baseline stylesheet: colors, units, selectors, at-rules. */
|
||||
+/* Edited stylesheet: colors, units, selectors, at-rules. */
|
||||
:root {
|
||||
- --brand: #3b5cf6;
|
||||
- --radius: 6px;
|
||||
+ --brand: #0b34f4;
|
||||
+ --radius: 8px;
|
||||
--gap: 8px;
|
||||
+ --shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.card {
|
||||
- display: flex;
|
||||
+ display: grid;
|
||||
gap: var(--gap);
|
||||
- padding: 12px 16px;
|
||||
- border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
+ padding: 12px 20px;
|
||||
+ border: 1px solid rgba(0, 0, 0, 0.12);
|
||||
border-radius: var(--radius);
|
||||
background: #ffffff;
|
||||
color: #161616;
|
||||
+ box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
-.card:hover {
|
||||
- background: #fafafa;
|
||||
+.card:hover,
|
||||
+.card:focus-visible {
|
||||
+ background: #f5f5f5;
|
||||
}
|
||||
|
||||
.card[data-state="error"] {
|
||||
- color: #b82d35;
|
||||
+ color: #d92e3c;
|
||||
border-color: #f2bbb7;
|
||||
}
|
||||
|
||||
-@media (max-width: 768px) {
|
||||
+@media (max-width: 640px) {
|
||||
.card {
|
||||
- flex-direction: column;
|
||||
+ grid-template-columns: 1fr;
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
@@ -34,8 +37,10 @@
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
+ transform: translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
+ transform: none;
|
||||
}
|
||||
}
|
||||
diff --git a/diff-color-fixture/08-config.json b/diff-color-fixture/08-config.json
|
||||
index 09631d931c..8b6f1485fd 100644
|
||||
--- a/diff-color-fixture/08-config.json
|
||||
+++ b/diff-color-fixture/08-config.json
|
||||
@@ -1,21 +1,22 @@
|
||||
{
|
||||
"name": "diff-color-fixture",
|
||||
- "version": "1.0.0",
|
||||
+ "version": "1.1.0",
|
||||
"private": true,
|
||||
"settings": {
|
||||
- "theme": "light",
|
||||
+ "theme": "dark",
|
||||
"fontSize": 13,
|
||||
- "lineHeight": 20,
|
||||
- "wrap": false,
|
||||
- "tabs": ["review", "files", "terminal"]
|
||||
+ "lineHeight": 24,
|
||||
+ "wrap": true,
|
||||
+ "tabs": ["review", "files", "terminal", "browser"]
|
||||
},
|
||||
"features": {
|
||||
"inlineHighlights": true,
|
||||
"foldUnchanged": true,
|
||||
- "splitView": false
|
||||
+ "splitView": true,
|
||||
+ "wordDiff": "word-line"
|
||||
},
|
||||
"limits": {
|
||||
- "maxFiles": 100,
|
||||
+ "maxFiles": 250,
|
||||
"maxLineLength": 1000
|
||||
}
|
||||
}
|
||||
diff --git a/diff-color-fixture/09-service.py b/diff-color-fixture/09-service.py
|
||||
index 64fe32db80..5da0737cf5 100644
|
||||
--- a/diff-color-fixture/09-service.py
|
||||
+++ b/diff-color-fixture/09-service.py
|
||||
@@ -1,36 +1,44 @@
|
||||
-"""Baseline Python service: decorators, f-strings, indentation, comments."""
|
||||
+"""Edited Python service: decorators, f-strings, indentation, comments."""
|
||||
|
||||
import asyncio
|
||||
-from dataclasses import dataclass
|
||||
+import logging
|
||||
+from dataclasses import dataclass, field
|
||||
|
||||
+logger = logging.getLogger(__name__)
|
||||
|
||||
-@dataclass
|
||||
+
|
||||
+@dataclass(frozen=True)
|
||||
class Review:
|
||||
id: str
|
||||
title: str
|
||||
approved: bool = False
|
||||
+ tags: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class ReviewService:
|
||||
- def __init__(self, timeout: float = 2.0) -> None:
|
||||
+ def __init__(self, timeout: float = 5.0) -> None:
|
||||
self.timeout = timeout
|
||||
self.cache: dict[str, Review] = {}
|
||||
|
||||
async def load(self, review_id: str) -> Review | None:
|
||||
# Return cached reviews before touching the network.
|
||||
if review_id in self.cache:
|
||||
+ logger.debug("cache hit %s", review_id)
|
||||
return self.cache[review_id]
|
||||
- await asyncio.sleep(self.timeout)
|
||||
+ try:
|
||||
+ await asyncio.wait_for(asyncio.sleep(0), timeout=self.timeout)
|
||||
+ except asyncio.TimeoutError:
|
||||
+ return None
|
||||
return None
|
||||
|
||||
def summary(self, review: Review) -> str:
|
||||
- status = "approved" if review.approved else "pending"
|
||||
- return f"Review {review.id}: {review.title} ({status})"
|
||||
+ status = "approved" if review.approved else "needs review"
|
||||
+ return f"Review {review.id}: {review.title!r} ({status})"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
service = ReviewService()
|
||||
- review = Review(id="r-1", title="Checkout")
|
||||
+ review = Review(id="r-1", title="Checkout", tags=["ui"])
|
||||
print(service.summary(review))
|
||||
|
||||
|
||||
diff --git a/diff-color-fixture/10-moved-block.ts b/diff-color-fixture/10-moved-block.ts
|
||||
index fbd5ecba84..214094d8eb 100644
|
||||
--- a/diff-color-fixture/10-moved-block.ts
|
||||
+++ b/diff-color-fixture/10-moved-block.ts
|
||||
@@ -3,11 +3,6 @@ export function first() {
|
||||
return "first"
|
||||
}
|
||||
|
||||
-export function second() {
|
||||
- const values = [1, 2, 3]
|
||||
- return values.map((value) => value * 2)
|
||||
-}
|
||||
-
|
||||
export function third() {
|
||||
return "third"
|
||||
}
|
||||
@@ -16,4 +11,9 @@ export function fourth() {
|
||||
return "fourth"
|
||||
}
|
||||
|
||||
-export const order = ["first", "second", "third", "fourth"]
|
||||
+export function second() {
|
||||
+ const values = [1, 2, 3]
|
||||
+ return values.map((value) => value * 2)
|
||||
+}
|
||||
+
|
||||
+export const order = ["first", "third", "fourth", "second"]
|
||||
diff --git a/diff-color-fixture/11-deploy.sh b/diff-color-fixture/11-deploy.sh
|
||||
index 5390c2fe9f..ae367067d6 100644
|
||||
--- a/diff-color-fixture/11-deploy.sh
|
||||
+++ b/diff-color-fixture/11-deploy.sh
|
||||
@@ -1,17 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
-# Baseline shell script: variables, quoting, conditionals.
|
||||
+# Edited shell script: variables, quoting, conditionals.
|
||||
set -euo pipefail
|
||||
|
||||
ENVIRONMENT="${1:-staging}"
|
||||
-REGION="us-east-1"
|
||||
-RETRIES=3
|
||||
+REGION="${REGION:-eu-west-1}"
|
||||
+RETRIES=5
|
||||
|
||||
log() {
|
||||
- echo "[deploy] $*"
|
||||
+ printf '[deploy] %s\n' "$*" >&2
|
||||
}
|
||||
|
||||
if [[ "$ENVIRONMENT" == "production" ]]; then
|
||||
log "Deploying to production in $REGION"
|
||||
+elif [[ "$ENVIRONMENT" == "preview" ]]; then
|
||||
+ log "Skipping preview deploy"
|
||||
+ exit 0
|
||||
else
|
||||
log "Deploying to $ENVIRONMENT"
|
||||
fi
|
||||
diff --git a/diff-color-fixture/12-pipeline.yml b/diff-color-fixture/12-pipeline.yml
|
||||
index 1fbd5aa75c..251a7e4372 100644
|
||||
--- a/diff-color-fixture/12-pipeline.yml
|
||||
+++ b/diff-color-fixture/12-pipeline.yml
|
||||
@@ -1,19 +1,20 @@
|
||||
name: review
|
||||
on:
|
||||
push:
|
||||
- branches: [v2]
|
||||
+ branches: [v2, dev]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
- runs-on: ubuntu-latest
|
||||
- timeout-minutes: 10
|
||||
+ runs-on: ubuntu-24.04
|
||||
+ timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
+ - uses: oven-sh/setup-bun@v2
|
||||
- name: Install
|
||||
- run: bun install
|
||||
+ run: bun install --frozen-lockfile
|
||||
- name: Check
|
||||
run: bun run check
|
||||
env:
|
||||
CI: true
|
||||
- LOG_LEVEL: info
|
||||
+ LOG_LEVEL: debug
|
||||
diff --git a/diff-color-fixture/13-unicode.md b/diff-color-fixture/13-unicode.md
|
||||
index 1470ef9294..5bd43c3b1f 100644
|
||||
--- a/diff-color-fixture/13-unicode.md
|
||||
+++ b/diff-color-fixture/13-unicode.md
|
||||
@@ -1,12 +1,12 @@
|
||||
# Unicode and mixed scripts
|
||||
|
||||
-Emoji: ✅ ready, ⚠️ warning, ❌ failed.
|
||||
-CJK: 代码审查 完成。
|
||||
-Japanese: レビューを開始します。
|
||||
+Emoji: ✅ ready, ⚠️ warning, ❌ failed, 🚀 shipped.
|
||||
+CJK: 代码审查 已完成。
|
||||
+Japanese: レビューを完了しました。
|
||||
Korean: 변경 사항을 검토합니다.
|
||||
-Arabic (RTL): مراجعة التغييرات
|
||||
+Arabic (RTL): مراجعة جميع التغييرات
|
||||
Hebrew (RTL): סקירת שינויים
|
||||
-Accents: café, naïve, résumé.
|
||||
-Math: a ≤ b, x ≠ y, ∑ values.
|
||||
+Accents: café, naïve, résumé, déjà vu.
|
||||
+Math: a ≥ b, x ≠ y, ∏ values.
|
||||
Box: ┌─┐ │ │ └─┘
|
||||
Combining: e\u0301 vs é
|
||||
diff --git a/diff-color-fixture/14-tabs.go b/diff-color-fixture/14-tabs.go
|
||||
index ab9aca86dd..53e5e11a7c 100644
|
||||
--- a/diff-color-fixture/14-tabs.go
|
||||
+++ b/diff-color-fixture/14-tabs.go
|
||||
@@ -7,11 +7,12 @@ type Review struct {
|
||||
ID string
|
||||
Title string
|
||||
Approved bool
|
||||
+ Tags []string
|
||||
}
|
||||
|
||||
func (r Review) Summary() string {
|
||||
status := "pending"
|
||||
- if r.Approved {
|
||||
+ if r.Approved {
|
||||
status = "approved"
|
||||
}
|
||||
return fmt.Sprintf("%s: %s (%s)", r.ID, r.Title, status)
|
||||
diff --git a/diff-color-fixture/15-empty-to-content.txt b/diff-color-fixture/15-empty-to-content.txt
|
||||
index e69de29bb2..aa4be0acdb 100644
|
||||
--- a/diff-color-fixture/15-empty-to-content.txt
|
||||
+++ b/diff-color-fixture/15-empty-to-content.txt
|
||||
@@ -0,0 +1,2 @@
|
||||
+This tracked file was empty in the baseline.
|
||||
+Every row should render as an addition, without a file-added badge.
|
||||
diff --git a/diff-color-fixture/16-content-to-empty.txt b/diff-color-fixture/16-content-to-empty.txt
|
||||
index bb6e474b37..e69de29bb2 100644
|
||||
--- a/diff-color-fixture/16-content-to-empty.txt
|
||||
+++ b/diff-color-fixture/16-content-to-empty.txt
|
||||
@@ -1,3 +0,0 @@
|
||||
-This tracked file keeps existing but loses all content.
|
||||
-Every row should render as a deletion.
|
||||
-The file itself is not deleted.
|
||||
diff --git a/diff-color-fixture/17-no-newline.txt b/diff-color-fixture/17-no-newline.txt
|
||||
index 51b84a61e2..dc6945a1fa 100644
|
||||
--- a/diff-color-fixture/17-no-newline.txt
|
||||
+++ b/diff-color-fixture/17-no-newline.txt
|
||||
@@ -1,2 +1,2 @@
|
||||
First line
|
||||
-Last line without trailing newline
|
||||
\ No newline at end of file
|
||||
+Last line now with trailing newline
|
||||
diff --git a/diff-color-fixture/18-page.html b/diff-color-fixture/18-page.html
|
||||
index bc1b2d9daa..9f4df11c24 100644
|
||||
--- a/diff-color-fixture/18-page.html
|
||||
+++ b/diff-color-fixture/18-page.html
|
||||
@@ -2,13 +2,14 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
- <title>Review</title>
|
||||
+ <meta name="viewport" content="width=device-width" />
|
||||
+ <title>Review changes</title>
|
||||
</head>
|
||||
- <body class="light">
|
||||
- <main id="app" data-state="idle">
|
||||
- <h1>Old heading</h1>
|
||||
- <button type="button" disabled>Continue</button>
|
||||
- <!-- baseline comment -->
|
||||
+ <body class="dark">
|
||||
+ <main id="app" data-state="ready">
|
||||
+ <h1>New heading</h1>
|
||||
+ <button type="button">Continue</button>
|
||||
+ <!-- edited comment -->
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
diff --git a/diff-color-fixture/19-query.sql b/diff-color-fixture/19-query.sql
|
||||
index b078ad0869..88b64001e0 100644
|
||||
--- a/diff-color-fixture/19-query.sql
|
||||
+++ b/diff-color-fixture/19-query.sql
|
||||
@@ -1,6 +1,6 @@
|
||||
-SELECT id, title, status
|
||||
+SELECT id, title, status, author_id
|
||||
FROM reviews
|
||||
-WHERE status = 'pending'
|
||||
- AND created_at > NOW() - INTERVAL '7 days'
|
||||
-ORDER BY created_at DESC
|
||||
-LIMIT 50;
|
||||
+WHERE status IN ('pending', 'blocked')
|
||||
+ AND created_at > NOW() - INTERVAL '14 days'
|
||||
+ORDER BY updated_at DESC
|
||||
+LIMIT 100;
|
||||
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 222 KiB |
|
After Width: | Height: | Size: 309 KiB |
|
After Width: | Height: | Size: 293 KiB |
|
After Width: | Height: | Size: 323 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 298 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 250 KiB |
|
After Width: | Height: | Size: 290 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 194 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 194 KiB |
@@ -0,0 +1 @@
|
||||
local.json
|
||||
@@ -0,0 +1,697 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Diff color tuner</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: light-dark(#fafafa, #121212);
|
||||
--panel: light-dark(#ffffff, #1b1b1b);
|
||||
--line: light-dark(#e6e6e6, #2c2c2c);
|
||||
--text: light-dark(#161616, #ededed);
|
||||
--muted: light-dark(#6b6b6b, #9a9a9a);
|
||||
--accent: #3b5cf6;
|
||||
font: 13px/20px Inter, ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--bg); color: var(--text); }
|
||||
header {
|
||||
position: sticky; top: 0; z-index: 5; display: flex; gap: 12px; align-items: center; flex-wrap: wrap;
|
||||
padding: 12px 16px; background: var(--panel); border-bottom: 1px solid var(--line);
|
||||
}
|
||||
header h1 { font-size: 14px; line-height: 20px; margin: 0 8px 0 0; font-weight: 600; }
|
||||
button, select, input[type="text"] {
|
||||
font: inherit; color: inherit; background: var(--panel); border: 1px solid var(--line);
|
||||
border-radius: 6px; height: 28px; padding: 0 8px;
|
||||
}
|
||||
button { cursor: default; }
|
||||
button:hover { background: light-dark(#f2f2f2, #242424); }
|
||||
label.toggle { display: inline-flex; gap: 6px; align-items: center; }
|
||||
#status { color: var(--muted); margin-left: auto; font-variant-numeric: tabular-nums; }
|
||||
main { padding: 12px 16px 48px; max-width: 1180px; }
|
||||
h2 { font-size: 12px; line-height: 16px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin: 20px 0 8px; font-weight: 600; }
|
||||
dialog { width: min(720px, calc(100vw - 32px)); padding: 16px; color: var(--text); background: var(--panel); border: 1px solid var(--line); border-radius: 10px; box-shadow: 0 12px 32px #0004; }
|
||||
.sets { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 4px 0 8px; }
|
||||
.sets label { font-weight: 600; }
|
||||
#set { min-width: 200px; }
|
||||
#name-input { width: 100%; margin: 8px 0 12px; }
|
||||
dialog::backdrop { background: #0006; }
|
||||
dialog h3 { margin: 0 0 4px; font-size: 14px; line-height: 20px; }
|
||||
#paste-text { width: 100%; height: 280px; margin-top: 8px; padding: 8px; resize: vertical; color: var(--text); background: var(--bg);
|
||||
border: 1px solid var(--line); border-radius: 6px; font: 12px/18px "IBM Plex Mono", ui-monospace, monospace; }
|
||||
#paste-result { min-height: 16px; }
|
||||
#paste-result[data-error] { color: #d92e3c; }
|
||||
.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
h2 { display: flex; align-items: center; gap: 12px; }
|
||||
.group-toggle { text-transform: none; letter-spacing: 0; font-weight: 500; font-size: 12px; color: var(--text); }
|
||||
.grid.off { opacity: .45; }
|
||||
.grid { display: grid; grid-template-columns: minmax(220px, 1.1fr) 1fr 1fr; border: 1px solid var(--line); border-radius: 8px; background: var(--panel); overflow: hidden; }
|
||||
.grid > div { padding: 8px 10px; border-top: 1px solid var(--line); min-width: 0; }
|
||||
.grid > .head { border-top: 0; font-weight: 600; color: var(--muted); font-size: 12px; line-height: 16px; }
|
||||
.slot-label { font-weight: 500; }
|
||||
.slot-help { color: var(--muted); font-size: 12px; line-height: 16px; }
|
||||
.cell { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
||||
.cell[data-mode="dark"] { background: light-dark(#f6f6f6, #161616); }
|
||||
.swatch { width: 28px; height: 28px; border-radius: 6px; border: 1px solid var(--line); flex: none;
|
||||
background-image: linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 75%), linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 75%);
|
||||
background-size: 8px 8px; background-position: 0 0, 4px 4px; position: relative; overflow: hidden; }
|
||||
.swatch::after { content: ""; position: absolute; inset: 0; background: var(--swatch, transparent); }
|
||||
.swatch[data-current]::after { background: repeating-linear-gradient(-45deg, transparent 0 4px, light-dark(#0001, #fff1) 4px 8px); }
|
||||
input[type="color"] { width: 36px; height: 28px; padding: 0 2px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); }
|
||||
input[type="range"] { width: 90px; }
|
||||
button.token { display: inline-flex; align-items: center; gap: 6px; min-width: 200px; max-width: 100%; text-align: left; }
|
||||
button.token .token-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 12px/16px "IBM Plex Mono", ui-monospace, monospace; }
|
||||
button.token .caret { color: var(--muted); }
|
||||
.mini { width: 14px; height: 14px; border-radius: 3px; border: 1px solid var(--line); background: var(--swatch, transparent); flex: none; }
|
||||
#picker { position: fixed; z-index: 50; width: 560px; max-width: calc(100vw - 16px); max-height: min(520px, calc(100vh - 16px)); display: flex; flex-direction: column;
|
||||
background: var(--panel); border: 1px solid var(--line); border-radius: 10px; box-shadow: 0 12px 32px #0003; }
|
||||
.picker-head { display: flex; gap: 8px; align-items: center; padding: 8px; border-bottom: 1px solid var(--line); }
|
||||
#picker-search { flex: 1; }
|
||||
#picker-mode { color: var(--muted); font-size: 12px; }
|
||||
#picker-body { overflow: auto; padding: 4px 8px 8px; }
|
||||
.family { margin-top: 8px; }
|
||||
.family-name { color: var(--muted); font-size: 11px; line-height: 16px; text-transform: uppercase; letter-spacing: .04em; margin-bottom: 4px; }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.chip { width: 36px; height: 28px; padding: 0; border-radius: 5px; border: 1px solid var(--line); position: relative; overflow: hidden;
|
||||
background-image: linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 75%), linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 75%);
|
||||
background-size: 8px 8px; background-position: 0 0, 4px 4px; }
|
||||
.chip::after { content: ""; position: absolute; inset: 0; background: var(--swatch); }
|
||||
.chip span { position: absolute; z-index: 1; inset: auto 0 1px; font-size: 9px; line-height: 10px; text-align: center; color: var(--chip-text); }
|
||||
.chip[aria-selected="true"] { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.rows { display: grid; grid-template-columns: 1fr 1fr; gap: 2px 8px; }
|
||||
.row { display: flex; align-items: center; gap: 8px; height: 28px; padding: 0 6px; border: 0; background: transparent; text-align: left; min-width: 0; }
|
||||
.row:hover, .row[aria-selected="true"] { background: light-dark(#f0f0f0, #262626); }
|
||||
.row[aria-selected="true"] { box-shadow: inset 0 0 0 1px var(--accent); }
|
||||
.row .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 12px/16px "IBM Plex Mono", ui-monospace, monospace; }
|
||||
.alpha { display: inline-flex; align-items: center; gap: 2px; color: var(--muted); }
|
||||
.alpha-input { width: 56px; height: 28px; padding: 0 6px; font: inherit; font-variant-numeric: tabular-nums; color: var(--text);
|
||||
background: var(--panel); border: 1px solid var(--line); border-radius: 6px; text-align: right; }
|
||||
.hidden { display: none !important; }
|
||||
pre { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 12px; overflow: auto; font: 12px/18px "IBM Plex Mono", ui-monospace, monospace; max-height: 360px; }
|
||||
.note { color: var(--muted); max-width: 820px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Diff color tuner</h1>
|
||||
<label class="toggle"><input type="checkbox" id="enabled" checked /> Overrides on</label>
|
||||
<button id="undo" disabled title="Undo last change (⌘Z)">Undo</button>
|
||||
<button id="redo" disabled title="Redo (⇧⌘Z)">Redo</button>
|
||||
<button id="reset">Reset all</button>
|
||||
<button id="copy">Copy CSS</button>
|
||||
<button id="paste">Paste CSS</button>
|
||||
<span id="status">Loading…</span>
|
||||
</header>
|
||||
<main>
|
||||
<div class="sets">
|
||||
<label for="set">Override set</label>
|
||||
<select id="set"></select>
|
||||
<button id="set-new">New</button>
|
||||
<button id="set-duplicate">Duplicate</button>
|
||||
<button id="set-rename">Rename</button>
|
||||
<button id="set-delete">Delete</button>
|
||||
<span id="set-note" class="slot-help"></span>
|
||||
</div>
|
||||
<p class="note">
|
||||
Each change is pushed live into open diff views in the dev app and saved to
|
||||
<code>packages/session-ui/src/pierre/diff-color-tuning.ts</code>. “Current” leaves the branch's colors untouched.
|
||||
Picked fills are exact final colors; tokens resolve per mode from OpenCode v2 tokens.
|
||||
</p>
|
||||
<div id="groups"></div>
|
||||
<h2>Generated CSS</h2>
|
||||
<pre id="css"></pre>
|
||||
</main>
|
||||
<dialog id="name-dialog">
|
||||
<form method="dialog">
|
||||
<h3 id="name-title">Name</h3>
|
||||
<input type="text" id="name-input" spellcheck="false" autocomplete="off" />
|
||||
<div class="dialog-actions">
|
||||
<button value="cancel">Cancel</button>
|
||||
<button id="name-ok" value="ok" class="primary">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="paste-dialog">
|
||||
<form method="dialog">
|
||||
<h3>Paste CSS</h3>
|
||||
<p class="slot-help">Paste CSS copied from this tuner. It replaces the current settings; Undo restores them.</p>
|
||||
<textarea id="paste-text" spellcheck="false" placeholder="/* diff-color-tuner: … */"></textarea>
|
||||
<p id="paste-result" class="slot-help"></p>
|
||||
<div class="dialog-actions">
|
||||
<button value="cancel">Cancel</button>
|
||||
<button id="paste-apply" value="apply" class="primary">Apply</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
<div id="picker" class="hidden" role="dialog" aria-label="Choose a v2 token">
|
||||
<div class="picker-head">
|
||||
<input type="text" id="picker-search" placeholder="Search tokens, e.g. red, state, 1200" spellcheck="false" autocomplete="off" />
|
||||
<span id="picker-mode"></span>
|
||||
</div>
|
||||
<div id="picker-body"></div>
|
||||
</div>
|
||||
<script type="module">
|
||||
const LIGHT = ':host(:not([data-color-scheme="dark"]))'
|
||||
const DARK = ':host([data-color-scheme="dark"])'
|
||||
const ADD = '[data-line-type="change-addition"]'
|
||||
const DEL = '[data-line-type="change-deletion"]'
|
||||
const CONTEXT = ':is([data-line-type="context"], [data-line-type="context-expanded"])'
|
||||
const ROW = ':is([data-line], [data-no-newline])'
|
||||
const GUTTER = ':is([data-column-number], [data-gutter-buffer])'
|
||||
const exactRow = (sel) => (host, v) => `${host} ${sel} { --diffs-diff-line-mix-target: ${v}; --mix-light: 0%; --mix-dark: 0%; }`
|
||||
const hostVar = (name) => (host, v) => `${host} { ${name}: ${v}; }`
|
||||
const scopedVar = (name) => (host, v) => `${host} :is([data-diff], [data-file]) { ${name}: ${v}; }`
|
||||
const direct = (sel, decl) => (host, v) => `${host} ${sel} { ${decl(v)} }`
|
||||
// Syntax colors are inline style attributes on the token spans, so only !important can override them.
|
||||
const inlineText = (line) => (host, v) =>
|
||||
`${host} ${line} [data-diff-span], ${host} ${line} [data-diff-span] * { color: ${v} !important; }`
|
||||
|
||||
const GROUPS = [
|
||||
["Backgrounds", [
|
||||
{ id: "bg", label: "File background", help: "--diffs-bg. Base for context rows and Pierre's derived mixes.", rule: hostVar("--diffs-bg"), light: "v2-grey-50", dark: "v2-grey-1100" },
|
||||
{ id: "context-row", label: "Unchanged row", help: "Context code rows.", rule: exactRow(`${CONTEXT}${ROW}`), light: "v2-grey-50", dark: "v2-grey-1100" },
|
||||
{ id: "context-gutter", label: "Unchanged gutter", help: "Context line-number cells.", rule: exactRow(`${CONTEXT}${GUTTER}`), light: "v2-grey-50", dark: "v2-grey-1100" },
|
||||
{ id: "fold-row", label: "Folded “unmodified lines” row", help: "--diffs-bg-separator-override.", rule: hostVar("--diffs-bg-separator-override"), light: "v2-grey-100", dark: "v2-grey-1000" },
|
||||
{ id: "buffer-bg", label: "Split empty side", help: "--diffs-bg-context-gutter-override (blank side of split rows).", rule: hostVar("--diffs-bg-context-gutter-override"), light: "v2-grey-100", dark: "v2-grey-1000" },
|
||||
{ id: "buffer-hatch", label: "Split empty side hatch", help: "--diffs-bg-buffer-override (Pierre stripes; OpenCode currently hides them).", rule: hostVar("--diffs-bg-buffer-override"), light: "v2-grey-200", dark: "v2-grey-900" },
|
||||
{ id: "hover", label: "Hover tint", help: "--diffs-bg-hover-override. Mixed in at ~3% light / ~9% dark, not exact.", rule: hostVar("--diffs-bg-hover-override"), light: "v2-grey-1200", dark: "v2-grey-50" },
|
||||
{ id: "selection", label: "Selected lines", help: "--diffs-selection-base (row and number tints derive from it).", rule: scopedVar("--diffs-selection-base"), light: "v2-background-bg-accent", dark: "v2-background-bg-accent" },
|
||||
{ id: "comment", label: "Comment annotation", help: "--diffs-comment-bg.", rule: scopedVar("--diffs-comment-bg"), light: "v2-blue-100", dark: "v2-blue-1200" },
|
||||
]],
|
||||
["Additions", [
|
||||
{ id: "add-row", label: "Added row", help: "Exact fill of added code rows.", rule: exactRow(`${ADD}${ROW}`), light: "v2-green-100", dark: "v2-green-1200" },
|
||||
{ id: "add-gutter", label: "Added gutter", help: "Exact fill of added line-number cells.", rule: exactRow(`${ADD}${GUTTER}`), light: "v2-green-100", dark: "v2-green-1200" },
|
||||
{ id: "add-inline", label: "Added inline highlight", help: "Word/char emphasis span, painted over the row.", rule: direct(`${ADD} [data-diff-span]`, (v) => `background-color: ${v};`), light: "v2-green-200", dark: "v2-green-1100" },
|
||||
{ id: "add-inline-text", label: "Added inline text", help: "Text color inside the highlight. Replaces syntax colors there.", rule: inlineText(ADD), light: "v2-green-1000", dark: "v2-green-300" },
|
||||
{ id: "add-bar", label: "Added change bar", help: "2px gutter bar.", rule: direct(`[data-column-number]${ADD}::before`, (v) => `background-color: ${v}; background-image: none;`), light: "v2-state-fg-success", dark: "v2-state-fg-success" },
|
||||
{ id: "add-number", label: "Added line-number text", help: "--diffs-fg-number-addition-override.", rule: hostVar("--diffs-fg-number-addition-override"), light: "v2-state-fg-success", dark: "v2-state-fg-success" },
|
||||
{ id: "add-seed", label: "Addition seed", help: "--diffs-addition-color-override. Feeds anything above left on Current.", rule: hostVar("--diffs-addition-color-override"), light: "v2-state-fg-success", dark: "v2-state-fg-success" },
|
||||
]],
|
||||
["Deletions", [
|
||||
{ id: "del-row", label: "Deleted row", help: "Exact fill of deleted code rows.", rule: exactRow(`${DEL}${ROW}`), light: "v2-red-100", dark: "v2-red-1200" },
|
||||
{ id: "del-gutter", label: "Deleted gutter", help: "Exact fill of deleted line-number cells.", rule: exactRow(`${DEL}${GUTTER}`), light: "v2-red-100", dark: "v2-red-1200" },
|
||||
{ id: "del-inline", label: "Deleted inline highlight", help: "Word/char emphasis span, painted over the row.", rule: direct(`${DEL} [data-diff-span]`, (v) => `background-color: ${v};`), light: "v2-red-200", dark: "v2-red-1100" },
|
||||
{ id: "del-inline-text", label: "Deleted inline text", help: "Text color inside the highlight. Replaces syntax colors there.", rule: inlineText(DEL), light: "v2-red-1000", dark: "v2-red-300" },
|
||||
{ id: "del-bar", label: "Deleted change bar", help: "2px gutter bar.", rule: direct(`[data-column-number]${DEL}::before`, (v) => `background-color: ${v}; background-image: none;`), light: "v2-state-fg-danger", dark: "v2-state-fg-danger" },
|
||||
{ id: "del-number", label: "Deleted line-number text", help: "--diffs-fg-number-deletion-override.", rule: hostVar("--diffs-fg-number-deletion-override"), light: "v2-state-fg-danger", dark: "v2-state-fg-danger" },
|
||||
{ id: "del-seed", label: "Deletion seed", help: "--diffs-deletion-color-override. Feeds anything above left on Current.", rule: hostVar("--diffs-deletion-color-override"), light: "v2-state-fg-danger", dark: "v2-state-fg-danger" },
|
||||
]],
|
||||
["Text", [
|
||||
{ id: "number", label: "Unchanged line-number text", help: "--diffs-fg-number-override. Also fold labels unless set below.", rule: hostVar("--diffs-fg-number-override"), light: "v2-text-text-faint", dark: "v2-text-text-faint" },
|
||||
{ id: "fold-text", label: "Fold label and expand icon", help: "“N unmodified lines” text and expand button.", rule: direct(":is([data-separator-content], [data-expand-button])", (v) => `color: ${v};`), light: "v2-text-text-muted", dark: "v2-text-text-muted" },
|
||||
]],
|
||||
]
|
||||
const SLOTS = GROUPS.flatMap((group) => group[1])
|
||||
|
||||
let tokens = []
|
||||
let tokenMap = new Map()
|
||||
let state = { enabled: true, slots: {} }
|
||||
let liveTimer, saveTimer, liveBusy = false, livePending = false
|
||||
|
||||
const $ = (sel) => document.querySelector(sel)
|
||||
const status = (text) => ($("#status").textContent = text)
|
||||
|
||||
function hex8(hex, alpha) {
|
||||
return alpha >= 100 ? hex : hex + Math.round((alpha / 100) * 255).toString(16).padStart(2, "0")
|
||||
}
|
||||
function tokenHex(name, mode) {
|
||||
const value = tokenMap.get(name)?.[mode]
|
||||
return value && /^#[0-9a-f]{6}/i.test(value) ? value.slice(0, 7) : "#888888"
|
||||
}
|
||||
function valueOf(entry) {
|
||||
if (!entry || entry.kind === "current") return null
|
||||
if (entry.kind === "color") return hex8(entry.hex, entry.alpha)
|
||||
if (!tokenMap.has(entry.token)) return null
|
||||
return entry.alpha >= 100 ? `var(--${entry.token})` : `rgb(from var(--${entry.token}) r g b / ${entry.alpha / 100})`
|
||||
}
|
||||
function swatchOf(entry, mode) {
|
||||
if (!entry || entry.kind === "current") return null
|
||||
if (entry.kind === "color") return hex8(entry.hex, entry.alpha)
|
||||
const value = tokenMap.get(entry.token)?.[mode]
|
||||
if (!value) return null
|
||||
return entry.alpha >= 100 ? value : `rgb(from ${value} r g b / ${entry.alpha / 100})`
|
||||
}
|
||||
|
||||
function buildCSS() {
|
||||
if (!state.enabled) return ""
|
||||
const active = GROUPS.filter(([title]) => !state.disabledGroups?.[title]).flatMap((group) => group[1])
|
||||
return [["light", LIGHT], ["dark", DARK]]
|
||||
.flatMap(([mode, host]) =>
|
||||
active.flatMap((slot) => {
|
||||
const value = valueOf(state.slots[slot.id]?.[mode])
|
||||
return value ? [`/* ${mode}: ${slot.label} */\n${slot.rule(host, value)}`] : []
|
||||
}),
|
||||
)
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
function entryFor(slot, mode) {
|
||||
state.slots[slot.id] ??= {}
|
||||
state.slots[slot.id][mode] ??= { kind: "current" }
|
||||
return state.slots[slot.id][mode]
|
||||
}
|
||||
|
||||
function render() {
|
||||
const root = $("#groups")
|
||||
root.textContent = ""
|
||||
for (const [title, slots] of GROUPS) {
|
||||
const heading = document.createElement("h2")
|
||||
heading.innerHTML = `<span></span><label class="toggle group-toggle"><input type="checkbox" /> <span>On</span></label>`
|
||||
heading.firstElementChild.textContent = title
|
||||
const enabled = !state.disabledGroups?.[title]
|
||||
const toggle = heading.querySelector("input")
|
||||
toggle.checked = enabled
|
||||
heading.querySelector(".group-toggle span").textContent = enabled ? "On" : "Off"
|
||||
toggle.onchange = () => {
|
||||
state.disabledGroups ??= {}
|
||||
if (toggle.checked) delete state.disabledGroups[title]
|
||||
else state.disabledGroups[title] = true
|
||||
render()
|
||||
changed(true)
|
||||
}
|
||||
const grid = document.createElement("div")
|
||||
grid.className = "grid"
|
||||
grid.classList.toggle("off", !enabled)
|
||||
grid.innerHTML = `<div class="head">Slot</div><div class="head">Light</div><div class="head">Dark</div>`
|
||||
for (const slot of slots) {
|
||||
const info = document.createElement("div")
|
||||
info.innerHTML = `<div class="slot-label"></div><div class="slot-help"></div>`
|
||||
info.children[0].textContent = slot.label
|
||||
info.children[1].textContent = slot.help
|
||||
grid.append(info, cell(slot, "light"), cell(slot, "dark"))
|
||||
}
|
||||
root.append(heading, grid)
|
||||
}
|
||||
$("#enabled").checked = state.enabled
|
||||
$("#css").textContent = buildCSS() || "/* No overrides */"
|
||||
}
|
||||
|
||||
function cell(slot, mode) {
|
||||
const entry = entryFor(slot, mode)
|
||||
const el = document.createElement("div")
|
||||
el.className = "cell"
|
||||
el.dataset.mode = mode
|
||||
el.innerHTML = `
|
||||
<span class="swatch"></span>
|
||||
<select class="kind"><option value="current">Current</option><option value="color">Color</option><option value="token">v2 token</option></select>
|
||||
<input type="color" class="hex" />
|
||||
<button type="button" class="token"><span class="mini"></span><span class="token-name"></span><span class="caret">▾</span></button>
|
||||
<input type="range" class="alpha-range" min="0" max="100" step="1" />
|
||||
<span class="alpha"><input type="number" class="alpha-input" min="0" max="100" step="any" aria-label="Opacity percent" /><span>%</span></span>`
|
||||
const swatch = el.querySelector(".swatch")
|
||||
const kind = el.querySelector(".kind")
|
||||
const hex = el.querySelector(".hex")
|
||||
const token = el.querySelector(".token")
|
||||
const alpha = el.querySelector(".alpha-range")
|
||||
const alphaText = el.querySelector(".alpha")
|
||||
const alphaInput = el.querySelector(".alpha-input")
|
||||
const sync = () => {
|
||||
kind.value = entry.kind
|
||||
hex.classList.toggle("hidden", entry.kind !== "color")
|
||||
token.classList.toggle("hidden", entry.kind !== "token")
|
||||
alpha.classList.toggle("hidden", entry.kind === "current")
|
||||
alphaText.classList.toggle("hidden", entry.kind === "current")
|
||||
if (entry.kind === "color") hex.value = entry.hex
|
||||
if (entry.kind === "token") {
|
||||
token.querySelector(".token-name").textContent = entry.token
|
||||
token.querySelector(".mini").style.setProperty("--swatch", tokenMap.get(entry.token)?.[mode] ?? "transparent")
|
||||
}
|
||||
alpha.value = entry.alpha ?? 100
|
||||
if (document.activeElement !== alphaInput) alphaInput.value = entry.alpha ?? 100
|
||||
const color = swatchOf(entry, mode)
|
||||
if (color) swatch.style.setProperty("--swatch", color)
|
||||
swatch.toggleAttribute("data-current", !color)
|
||||
swatch.title = entry.kind === "token" ? `${entry.token}: ${tokenMap.get(entry.token)?.[mode] ?? "unknown token"}` : (color ?? "Current (unchanged)")
|
||||
token.style.outline = entry.kind === "token" && !tokenMap.has(entry.token) ? "1px solid #d92e3c" : ""
|
||||
}
|
||||
kind.onchange = () => {
|
||||
const previous = { ...entry }
|
||||
entry.kind = kind.value
|
||||
entry.alpha ??= 100
|
||||
if (entry.kind === "color") entry.hex = previous.kind === "token" ? tokenHex(previous.token, mode) : (previous.hex ?? tokenHex(slot[mode], mode))
|
||||
if (entry.kind === "token") entry.token = previous.token ?? slot[mode]
|
||||
sync()
|
||||
changed(true)
|
||||
}
|
||||
hex.oninput = () => { entry.hex = hex.value; sync(); changed(false) }
|
||||
hex.onchange = () => changed(true)
|
||||
token.onclick = () => openPicker(token, mode, entry.token, (name) => { entry.token = name; sync(); changed(true) })
|
||||
alpha.oninput = () => { entry.alpha = Number(alpha.value); sync(); changed(false) }
|
||||
alpha.onchange = () => changed(true)
|
||||
alphaInput.oninput = () => {
|
||||
const value = Number(alphaInput.value)
|
||||
if (alphaInput.value === "" || !Number.isFinite(value)) return
|
||||
entry.alpha = Math.min(100, Math.max(0, value))
|
||||
sync()
|
||||
changed(false)
|
||||
}
|
||||
alphaInput.onchange = () => { alphaInput.value = entry.alpha ?? 100; changed(true) }
|
||||
alphaInput.onkeydown = (event) => { if (event.key === "Enter") alphaInput.blur() }
|
||||
sync()
|
||||
return el
|
||||
}
|
||||
|
||||
const RAMP_ORDER = ["grey", "red", "orange", "yellow", "green", "cyan", "blue", "purple", "pink", "alpha-dark", "alpha-light"]
|
||||
const picker = { anchor: null, mode: "light", selected: "", onPick: null }
|
||||
|
||||
function familyOf(name) {
|
||||
const ramp = /^v2-([a-z]+(?:-dark|-light)?)-(\d+)$/.exec(name)
|
||||
if (ramp && RAMP_ORDER.includes(ramp[1])) return { family: ramp[1], step: ramp[2], ramp: true }
|
||||
return { family: name.split("-")[1] ?? "other", step: "", ramp: false }
|
||||
}
|
||||
function readableOn(value) {
|
||||
const hex = /^#([0-9a-f]{6})/i.exec(value)?.[1]
|
||||
if (!hex) return "#000"
|
||||
const [r, g, b] = [0, 2, 4].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255)
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.55 ? "#0009" : "#fffb"
|
||||
}
|
||||
|
||||
function openPicker(anchor, mode, selected, onPick) {
|
||||
Object.assign(picker, { anchor, mode, selected, onPick })
|
||||
$("#picker-mode").textContent = `${mode} values`
|
||||
$("#picker-search").value = ""
|
||||
$("#picker").classList.remove("hidden")
|
||||
renderPicker()
|
||||
placePicker()
|
||||
$("#picker-search").focus()
|
||||
const current = $(`#picker-body [aria-selected="true"]`)
|
||||
$("#picker-body").scrollTop = current ? current.offsetTop - $("#picker-body").offsetTop - 60 : 0
|
||||
}
|
||||
function closePicker() {
|
||||
$("#picker").classList.add("hidden")
|
||||
picker.anchor?.focus()
|
||||
picker.anchor = null
|
||||
}
|
||||
function placePicker() {
|
||||
const el = $("#picker")
|
||||
const rect = picker.anchor.getBoundingClientRect()
|
||||
const height = el.offsetHeight
|
||||
const top = rect.bottom + 4 + height > innerHeight ? Math.max(8, rect.top - 4 - height) : rect.bottom + 4
|
||||
el.style.top = `${top}px`
|
||||
el.style.left = `${Math.min(Math.max(8, rect.left), innerWidth - el.offsetWidth - 8)}px`
|
||||
}
|
||||
function renderPicker() {
|
||||
const terms = $("#picker-search").value.toLowerCase().split(/\s+/).filter(Boolean)
|
||||
const matches = tokens.filter((token) => terms.every((term) => token.name.includes(term)))
|
||||
const groups = new Map()
|
||||
for (const token of matches) {
|
||||
const info = familyOf(token.name)
|
||||
if (!groups.has(info.family)) groups.set(info.family, { ramp: info.ramp, items: [] })
|
||||
groups.get(info.family).items.push({ token, step: info.step })
|
||||
}
|
||||
const order = [...groups.keys()].sort((a, b) => {
|
||||
const ia = RAMP_ORDER.indexOf(a), ib = RAMP_ORDER.indexOf(b)
|
||||
if (ia !== -1 || ib !== -1) return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib)
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
const body = $("#picker-body")
|
||||
body.textContent = ""
|
||||
if (!matches.length) body.innerHTML = `<p class="slot-help">No tokens match.</p>`
|
||||
for (const family of order) {
|
||||
const group = groups.get(family)
|
||||
const section = document.createElement("div")
|
||||
section.className = "family"
|
||||
const title = document.createElement("div")
|
||||
title.className = "family-name"
|
||||
title.textContent = family
|
||||
const list = document.createElement("div")
|
||||
list.className = group.ramp ? "chips" : "rows"
|
||||
for (const item of group.items) {
|
||||
const value = item.token[picker.mode]
|
||||
const button = document.createElement("button")
|
||||
button.type = "button"
|
||||
button.title = `${item.token.name}\nlight ${item.token.light}\ndark ${item.token.dark}`
|
||||
button.setAttribute("aria-selected", String(item.token.name === picker.selected))
|
||||
button.style.setProperty("--swatch", value)
|
||||
if (group.ramp) {
|
||||
button.className = "chip"
|
||||
button.style.setProperty("--chip-text", readableOn(value))
|
||||
button.innerHTML = `<span>${item.step}</span>`
|
||||
} else {
|
||||
button.className = "row"
|
||||
button.innerHTML = `<span class="mini"></span><span class="name"></span>`
|
||||
button.querySelector(".name").textContent = item.token.name.replace(/^v2-/, "")
|
||||
}
|
||||
button.onclick = () => {
|
||||
picker.onPick(item.token.name)
|
||||
closePicker()
|
||||
}
|
||||
list.append(button)
|
||||
}
|
||||
section.append(title, list)
|
||||
body.append(section)
|
||||
}
|
||||
}
|
||||
$("#picker-search").oninput = renderPicker
|
||||
$("#picker-search").onkeydown = (event) => {
|
||||
if (event.key === "Escape") closePicker()
|
||||
if (event.key === "Enter") $("#picker-body button")?.click()
|
||||
}
|
||||
document.addEventListener("mousedown", (event) => {
|
||||
if (!picker.anchor || $("#picker").contains(event.target) || picker.anchor.contains(event.target)) return
|
||||
closePicker()
|
||||
})
|
||||
window.addEventListener("resize", () => picker.anchor && placePicker())
|
||||
document.addEventListener("scroll", () => picker.anchor && placePicker(), true)
|
||||
|
||||
// History holds committed snapshots only, so one drag of a picker or slider is one undo step.
|
||||
const history = { undo: [], redo: [], committed: "" }
|
||||
function record() {
|
||||
const snapshot = JSON.stringify(state)
|
||||
if (snapshot === history.committed) return
|
||||
history.undo.push(history.committed)
|
||||
history.redo = []
|
||||
history.committed = snapshot
|
||||
syncHistory()
|
||||
}
|
||||
function travel(from, to) {
|
||||
if (!from.length) return
|
||||
to.push(history.committed)
|
||||
history.committed = from.pop()
|
||||
state = JSON.parse(history.committed)
|
||||
render()
|
||||
syncHistory()
|
||||
changed(false)
|
||||
clearTimeout(saveTimer)
|
||||
saveTimer = setTimeout(save, 150)
|
||||
}
|
||||
function syncHistory() {
|
||||
$("#undo").disabled = !history.undo.length
|
||||
$("#redo").disabled = !history.redo.length
|
||||
}
|
||||
$("#undo").onclick = () => travel(history.undo, history.redo)
|
||||
$("#redo").onclick = () => travel(history.redo, history.undo)
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "z") return
|
||||
if (event.target instanceof HTMLInputElement && ["text", "number"].includes(event.target.type)) return
|
||||
event.preventDefault()
|
||||
if (event.shiftKey) travel(history.redo, history.undo)
|
||||
else travel(history.undo, history.redo)
|
||||
})
|
||||
|
||||
// Live pushes stay responsive while dragging; file writes are debounced to limit dev-server hot reloads.
|
||||
function changed(commit) {
|
||||
if (commit) record()
|
||||
const css = buildCSS()
|
||||
$("#css").textContent = css || "/* No overrides */"
|
||||
clearTimeout(liveTimer)
|
||||
liveTimer = setTimeout(() => live(css), 30)
|
||||
clearTimeout(saveTimer)
|
||||
saveTimer = setTimeout(save, commit ? 150 : 700)
|
||||
status("Saving…")
|
||||
}
|
||||
async function live(css) {
|
||||
if (liveBusy) { livePending = true; return }
|
||||
liveBusy = true
|
||||
await fetch("/api/live", { method: "POST", body: JSON.stringify({ css }) }).catch(() => {})
|
||||
liveBusy = false
|
||||
if (livePending) { livePending = false; live(buildCSS()) }
|
||||
}
|
||||
async function save() {
|
||||
// The committed reference set is read-only; the first edit forks it so the reference stays intact.
|
||||
if (current.readonly) {
|
||||
const name = `${state.name} copy`
|
||||
const created = await fetch("/api/sets", { method: "POST", body: JSON.stringify({ name, set: state }) }).then((r) => r.json())
|
||||
state.name = name
|
||||
state.readonly = false
|
||||
await loadSets(created.id)
|
||||
$("#set-note").textContent = `Reference is read-only, so your edits were saved as “${name}”.`
|
||||
}
|
||||
const response = await fetch(`/api/sets/${current.id}`, { method: "PUT", body: JSON.stringify({ set: state, css: buildCSS() }) }).catch(() => null)
|
||||
if (!response?.ok) return status("Save failed — is server.ts running?")
|
||||
const result = await response.json()
|
||||
status(`Saved ${new Date().toLocaleTimeString()} · live in ${result.live} diff view${result.live === 1 ? "" : "s"}`)
|
||||
}
|
||||
|
||||
$("#enabled").onchange = () => { state.enabled = $("#enabled").checked; render(); changed(true) }
|
||||
$("#reset").onclick = () => { if (!confirm("Reset every slot to Current?")) return; state = { ...state, enabled: true, disabledGroups: {}, slots: {} }; render(); changed(true) }
|
||||
// Shared CSS carries the exact settings in a header so pasting restores token choices, opacity and category toggles.
|
||||
function shareableCSS() {
|
||||
const css = buildCSS()
|
||||
if (!css) return ""
|
||||
const slots = Object.fromEntries(
|
||||
Object.entries(state.slots).flatMap(([id, modes]) => {
|
||||
const active = Object.fromEntries(Object.entries(modes).filter(([, entry]) => entry.kind !== "current"))
|
||||
return Object.keys(active).length ? [[id, active]] : []
|
||||
}),
|
||||
)
|
||||
const settings = { enabled: state.enabled, disabledGroups: state.disabledGroups ?? {}, slots }
|
||||
return `/* diff-color-tuner: ${JSON.stringify(settings)} */\n${css}`
|
||||
}
|
||||
|
||||
function parseShared(text) {
|
||||
// Chat apps often render "*…*" as Markdown emphasis and drop the asterisks, so don't require comment delimiters.
|
||||
// The settings JSON is always on one line, so a greedy match within the line is safe.
|
||||
const header = /diff-color-tuner:\s*(\{.*\})/.exec(text)
|
||||
if (header) {
|
||||
const settings = JSON.parse(header[1])
|
||||
const slots = Object.fromEntries(Object.entries(settings.slots ?? {}).filter(([id]) => SLOTS.some((slot) => slot.id === id)))
|
||||
return { state: { enabled: settings.enabled ?? true, disabledGroups: settings.disabledGroups ?? {}, slots }, count: Object.values(slots).reduce((sum, modes) => sum + Object.keys(modes).length, 0) }
|
||||
}
|
||||
// Fallback for CSS without the header: read each "/* mode: Label */" comment and the first color value after it.
|
||||
const slots = {}
|
||||
let count = 0
|
||||
for (const match of text.matchAll(/^\s*\/\*?\s*(light|dark):\s*(.+?)\s*\*?\/\s*\n\s*([^\n]+)/gm)) {
|
||||
const label = (value) => value.replace(/[“”"]/g, '"').replace(/\s*\*$/, "")
|
||||
const slot = SLOTS.find((candidate) => label(candidate.label) === label(match[2]))
|
||||
const entry = slot && parseValue(match[3].slice(match[3].indexOf("{")))
|
||||
if (!entry) continue
|
||||
slots[slot.id] ??= {}
|
||||
slots[slot.id][match[1]] = entry
|
||||
count++
|
||||
}
|
||||
return count ? { state: { enabled: true, disabledGroups: {}, slots }, count } : null
|
||||
}
|
||||
|
||||
function parseValue(rule) {
|
||||
const alphaToken = /rgb\(from var\(--([\w-]+)\) r g b \/ ([\d.]+)\)/.exec(rule)
|
||||
if (alphaToken && tokenMap.has(alphaToken[1])) return { kind: "token", token: alphaToken[1], alpha: Number(alphaToken[2]) * 100 }
|
||||
const token = /var\(--(v2-[\w-]+)\)/.exec(rule)
|
||||
if (token && tokenMap.has(token[1])) return { kind: "token", token: token[1], alpha: 100 }
|
||||
const hex = /#([0-9a-f]{6})([0-9a-f]{2})?\b/i.exec(rule)
|
||||
if (hex) return { kind: "color", hex: `#${hex[1].toLowerCase()}`, alpha: hex[2] ? Math.round((parseInt(hex[2], 16) / 255) * 1000) / 10 : 100 }
|
||||
return null
|
||||
}
|
||||
|
||||
$("#paste").onclick = () => {
|
||||
$("#paste-text").value = ""
|
||||
$("#paste-result").textContent = ""
|
||||
$("#paste-result").removeAttribute("data-error")
|
||||
$("#paste-dialog").showModal()
|
||||
$("#paste-text").focus()
|
||||
}
|
||||
$("#paste-apply").onclick = (event) => {
|
||||
let parsed = null
|
||||
try {
|
||||
parsed = parseShared($("#paste-text").value)
|
||||
} catch {}
|
||||
if (!parsed) {
|
||||
event.preventDefault()
|
||||
$("#paste-result").textContent = "No tuner rules found in that text."
|
||||
$("#paste-result").setAttribute("data-error", "")
|
||||
return
|
||||
}
|
||||
state = { ...parsed.state, name: state.name, readonly: state.readonly }
|
||||
render()
|
||||
changed(true)
|
||||
status(`Applied ${parsed.count} pasted override${parsed.count === 1 ? "" : "s"}`)
|
||||
}
|
||||
|
||||
$("#copy").onclick = async () => {
|
||||
const css = shareableCSS()
|
||||
if (!css) return status("Nothing to copy — no active overrides")
|
||||
const result = await fetch("/api/copy", { method: "POST", body: css }).then((r) => r.json()).catch(() => null)
|
||||
if (result?.ok) return status(`Copied ${css.split("\n").filter((line) => /^\/\* (light|dark):/.test(line)).length} rules`)
|
||||
const copied = await navigator.clipboard.writeText(css).then(() => true, () => false)
|
||||
status(copied ? "CSS copied" : "Copy failed — select the CSS at the bottom manually")
|
||||
}
|
||||
|
||||
let current = { id: "reference", readonly: true }
|
||||
|
||||
async function loadSets(selected) {
|
||||
const data = await fetch("/api/sets").then((r) => r.json())
|
||||
const id = selected ?? data.active
|
||||
$("#set").innerHTML = ""
|
||||
for (const set of data.sets) {
|
||||
const option = document.createElement("option")
|
||||
option.value = set.id
|
||||
option.textContent = set.readonly ? `${set.name} (read-only)` : set.name
|
||||
$("#set").append(option)
|
||||
}
|
||||
const match = data.sets.find((set) => set.id === id) ?? data.sets[0]
|
||||
current = { id: match.id, readonly: match.readonly }
|
||||
$("#set").value = current.id
|
||||
$("#set-rename").disabled = current.readonly
|
||||
$("#set-delete").disabled = current.readonly
|
||||
return current.id
|
||||
}
|
||||
|
||||
async function switchSet(id) {
|
||||
clearTimeout(saveTimer)
|
||||
await loadSets(id)
|
||||
state = await fetch(`/api/sets/${current.id}`).then((r) => r.json())
|
||||
history.undo = []
|
||||
history.redo = []
|
||||
history.committed = JSON.stringify(state)
|
||||
syncHistory()
|
||||
render()
|
||||
$("#set-note").textContent = current.readonly ? "Edits to the reference are saved as a new set." : ""
|
||||
const result = await fetch("/api/active", { method: "POST", body: JSON.stringify({ id: current.id, css: buildCSS() }) }).then((r) => r.json())
|
||||
status(`Showing “${state.name}” · live in ${result.live} diff view${result.live === 1 ? "" : "s"}`)
|
||||
}
|
||||
|
||||
// window.prompt isn't available in Electron, so names come from an in-page dialog.
|
||||
function askName(title, value) {
|
||||
return new Promise((resolve) => {
|
||||
const dialog = $("#name-dialog")
|
||||
$("#name-title").textContent = title
|
||||
$("#name-input").value = value
|
||||
dialog.onclose = () => resolve(dialog.returnValue === "ok" ? $("#name-input").value.trim() || null : null)
|
||||
dialog.returnValue = ""
|
||||
$("#name-input").onkeydown = (event) => {
|
||||
if (event.key !== "Enter") return
|
||||
event.preventDefault()
|
||||
dialog.close("ok")
|
||||
}
|
||||
dialog.showModal()
|
||||
$("#name-input").select()
|
||||
})
|
||||
}
|
||||
|
||||
async function createSet(name, set) {
|
||||
const created = await fetch("/api/sets", { method: "POST", body: JSON.stringify({ name, set }) }).then((r) => r.json())
|
||||
await switchSet(created.id)
|
||||
}
|
||||
|
||||
$("#set").onchange = () => switchSet($("#set").value)
|
||||
$("#set-new").onclick = async () => {
|
||||
const name = await askName("New override set", "My overrides")
|
||||
if (name) await createSet(name, { enabled: true, disabledGroups: {}, slots: {} })
|
||||
}
|
||||
$("#set-duplicate").onclick = async () => {
|
||||
const name = await askName("Duplicate set", `${state.name} copy`)
|
||||
if (name) await createSet(name, state)
|
||||
}
|
||||
$("#set-rename").onclick = async () => {
|
||||
const name = await askName("Rename set", state.name)
|
||||
if (!name) return
|
||||
state.name = name
|
||||
await save()
|
||||
await loadSets(current.id)
|
||||
}
|
||||
$("#set-delete").onclick = async () => {
|
||||
if (!confirm(`Delete “${state.name}”?`)) return
|
||||
await fetch(`/api/sets/${current.id}`, { method: "DELETE" })
|
||||
await switchSet("reference")
|
||||
}
|
||||
|
||||
const tokenList = await fetch("/api/tokens").then((r) => r.json())
|
||||
tokens = tokenList
|
||||
tokenMap = new Map(tokens.map((token) => [token.name, token]))
|
||||
await switchSet()
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,201 @@
|
||||
// Temporary diff color tuner. Delete this directory, packages/session-ui/src/pierre/diff-color-tuning.ts,
|
||||
// and the matching import/interpolation in packages/session-ui/src/pierre/index.ts when finished.
|
||||
import path from "node:path"
|
||||
import { resolveThemeVariantV2 } from "../../packages/ui/src/theme/v2/resolve"
|
||||
|
||||
const root = path.resolve(import.meta.dir, "../..")
|
||||
const setsDir = path.join(import.meta.dir, "sets")
|
||||
const localPath = path.join(import.meta.dir, "local.json")
|
||||
const outputPath = path.join(root, "packages/session-ui/src/pierre/diff-color-tuning.ts")
|
||||
const cdp = process.env.TUNER_CDP ?? "http://127.0.0.1:9222"
|
||||
const port = Number(process.env.TUNER_PORT ?? 4455)
|
||||
|
||||
const theme = await Bun.file(path.join(root, "packages/ui/src/theme/themes/oc-2.json")).json()
|
||||
// Alpha ramps are static CSS, not part of the resolved theme.
|
||||
const alpha = Object.fromEntries(
|
||||
[...(await Bun.file(path.join(root, "packages/ui/src/styles/tokens/colors.css")).text()).matchAll(
|
||||
/--(v2-alpha-(?:dark|light)-\d+):\s*([^;]+);/g,
|
||||
)].map((match) => [match[1]!, match[2]!.trim()]),
|
||||
)
|
||||
const tokens = buildTokens()
|
||||
|
||||
const server = Bun.serve({
|
||||
port,
|
||||
hostname: "127.0.0.1",
|
||||
routes: {
|
||||
"/": () => new Response(Bun.file(path.join(import.meta.dir, "index.html"))),
|
||||
"/api/tokens": Response.json(tokens),
|
||||
"/api/sets": {
|
||||
GET: async () => Response.json({ active: await activeSet(), sets: await listSets() }),
|
||||
POST: async (request) => {
|
||||
const body = await request.json()
|
||||
const id = await uniqueID(body.name)
|
||||
await writeSet(id, { ...body.set, name: body.name, readonly: false })
|
||||
return Response.json({ id })
|
||||
},
|
||||
},
|
||||
"/api/sets/:id": {
|
||||
GET: async (request) => {
|
||||
const file = Bun.file(setPath(request.params.id))
|
||||
return (await file.exists()) ? Response.json(await file.json()) : new Response("Not found", { status: 404 })
|
||||
},
|
||||
PUT: async (request) => {
|
||||
const id = request.params.id
|
||||
const current = await readSet(id)
|
||||
if (current?.readonly) return new Response("Read-only set", { status: 403 })
|
||||
const body = await request.json()
|
||||
await writeSet(id, { ...body.set, name: body.set.name ?? current?.name ?? id, readonly: false })
|
||||
return Response.json(await apply(id, body.css))
|
||||
},
|
||||
DELETE: async (request) => {
|
||||
const current = await readSet(request.params.id)
|
||||
if (!current || current.readonly) return new Response("Cannot delete", { status: 403 })
|
||||
await Bun.file(setPath(request.params.id)).delete()
|
||||
return Response.json({ ok: true })
|
||||
},
|
||||
},
|
||||
"/api/active": {
|
||||
POST: async (request) => {
|
||||
const body = await request.json()
|
||||
return Response.json(await apply(body.id, body.css))
|
||||
},
|
||||
},
|
||||
// The embedded browser pane may deny navigator.clipboard, so copy through the OS clipboard instead.
|
||||
"/api/copy": {
|
||||
POST: async (request) => {
|
||||
const process = Bun.spawn(["pbcopy"], { stdin: new Blob([await request.text()]) })
|
||||
return Response.json({ ok: (await process.exited) === 0 })
|
||||
},
|
||||
},
|
||||
"/api/live": {
|
||||
POST: async (request) => Response.json({ live: await push((await request.json()).css) }),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
console.log(`Diff color tuner: http://127.0.0.1:${server.port}`)
|
||||
|
||||
type OverrideSet = { name: string; readonly?: boolean; enabled: boolean; disabledGroups?: Record<string, boolean>; slots: Record<string, unknown> }
|
||||
|
||||
function setPath(id: string) {
|
||||
return path.join(setsDir, `${id.replace(/[^\w-]/g, "")}.json`)
|
||||
}
|
||||
|
||||
async function readSet(id: string): Promise<OverrideSet | undefined> {
|
||||
const file = Bun.file(setPath(id))
|
||||
return (await file.exists()) ? file.json() : undefined
|
||||
}
|
||||
|
||||
function writeSet(id: string, set: OverrideSet) {
|
||||
return Bun.write(setPath(id), JSON.stringify(set, null, 2) + "\n")
|
||||
}
|
||||
|
||||
async function listSets() {
|
||||
const ids = [...new Bun.Glob("*.json").scanSync(setsDir)].map((file) => file.slice(0, -5))
|
||||
const sets = await Promise.all(ids.map(async (id) => ({ id, ...(await readSet(id))! })))
|
||||
return sets
|
||||
.map((set) => ({ id: set.id, name: set.name, readonly: Boolean(set.readonly) }))
|
||||
.sort((a, b) => Number(b.readonly) - Number(a.readonly) || a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
async function activeSet() {
|
||||
const file = Bun.file(localPath)
|
||||
const active = (await file.exists()) ? (await file.json()).active : undefined
|
||||
return active && (await readSet(active)) ? active : "reference"
|
||||
}
|
||||
|
||||
async function uniqueID(name: string) {
|
||||
const base = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "set"
|
||||
const taken = new Set((await listSets()).map((set) => set.id))
|
||||
return Array.from({ length: 100 }, (_, i) => (i ? `${base}-${i + 1}` : base)).find((id) => !taken.has(id))!
|
||||
}
|
||||
|
||||
// Activating a set always rewrites the tuning module and pushes live CSS, so the dev app shows exactly the selected set.
|
||||
async function apply(id: string, css: string) {
|
||||
await Bun.write(localPath, JSON.stringify({ active: id }, null, 2) + "\n")
|
||||
await Bun.write(outputPath, renderModule(css))
|
||||
return { live: await push(css) }
|
||||
}
|
||||
|
||||
function renderModule(css: string) {
|
||||
const escaped = css.replaceAll("\\", "\\\\").replaceAll("`", "\\`").replaceAll("${", "\\${")
|
||||
return `// Temporary diff color tuning output. Generated by diff-color-review-artifacts/tuner; delete with the tuner.\nexport const diffColorTuningCSS = \`${escaped}\`\n`
|
||||
}
|
||||
|
||||
function buildTokens() {
|
||||
const light = { ...alpha, ...resolveThemeVariantV2(theme.light, false) }
|
||||
const dark = { ...alpha, ...resolveThemeVariantV2(theme.dark, true) }
|
||||
const resolve = (map: Record<string, string>, value: string, depth = 0): string => {
|
||||
const match = /^var\(--([\w-]+)\)$/.exec(value.trim())
|
||||
if (!match || depth > 8) return value
|
||||
return map[match[1]!] ? resolve(map, map[match[1]!]!, depth + 1) : value
|
||||
}
|
||||
return Object.keys(light)
|
||||
.filter((name) => !name.includes("elevation") && !name.includes("font"))
|
||||
.map((name) => ({ name, light: resolve(light, light[name]!), dark: resolve(dark, dark[name] ?? light[name]!) }))
|
||||
.filter((token) => /^(#|rgb|hsl|oklch)/.test(token.light))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }))
|
||||
}
|
||||
|
||||
// Live preview: Pierre reads unsafeCSS when a viewer mounts, so already-open diffs need the CSS pushed into their shadow roots.
|
||||
async function push(css: string) {
|
||||
const targets: { type: string; webSocketDebuggerUrl: string }[] = await fetch(`${cdp}/json/list`)
|
||||
.then((response) => response.json())
|
||||
.catch(() => [])
|
||||
const counts = await Promise.all(
|
||||
targets.filter((target) => target.type === "page").map((target) => evaluate(target.webSocketDebuggerUrl, injector(css))),
|
||||
)
|
||||
return counts.reduce<number>((sum, count) => sum + (typeof count === "number" ? count : 0), 0)
|
||||
}
|
||||
|
||||
function injector(css: string) {
|
||||
return `(() => {
|
||||
window.__diffColorTuning = ${JSON.stringify(css)}
|
||||
const apply = (host) => {
|
||||
const root = host.shadowRoot
|
||||
if (!root) return
|
||||
let style = root.querySelector("style[data-diff-color-tuning]")
|
||||
if (!style) {
|
||||
style = document.createElement("style")
|
||||
style.dataset.diffColorTuning = ""
|
||||
root.appendChild(style)
|
||||
}
|
||||
if (style.textContent !== window.__diffColorTuning) style.textContent = window.__diffColorTuning
|
||||
}
|
||||
const all = () => document.querySelectorAll("diffs-container").forEach(apply)
|
||||
all()
|
||||
if (!window.__diffColorTuningObserver) {
|
||||
let queued = false
|
||||
window.__diffColorTuningObserver = new MutationObserver(() => {
|
||||
if (queued) return
|
||||
queued = true
|
||||
requestAnimationFrame(() => { queued = false; all() })
|
||||
})
|
||||
window.__diffColorTuningObserver.observe(document.body, { childList: true, subtree: true })
|
||||
}
|
||||
return document.querySelectorAll("diffs-container").length
|
||||
})()`
|
||||
}
|
||||
|
||||
function evaluate(url: string, expression: string) {
|
||||
return new Promise<unknown>((resolve) => {
|
||||
const socket = new WebSocket(url)
|
||||
const timer = setTimeout(() => {
|
||||
socket.close()
|
||||
resolve(undefined)
|
||||
}, 2000)
|
||||
socket.onopen = () =>
|
||||
socket.send(JSON.stringify({ id: 1, method: "Runtime.evaluate", params: { expression, returnByValue: true } }))
|
||||
socket.onmessage = (event) => {
|
||||
const message = JSON.parse(String(event.data))
|
||||
if (message.id !== 1) return
|
||||
clearTimeout(timer)
|
||||
socket.close()
|
||||
resolve(message.result?.result?.value)
|
||||
}
|
||||
socket.onerror = () => {
|
||||
clearTimeout(timer)
|
||||
resolve(undefined)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
*
|
||||
!.gitignore
|
||||
!reference.json
|
||||
@@ -0,0 +1,176 @@
|
||||
{
|
||||
"name": "Reference",
|
||||
"readonly": true,
|
||||
"enabled": true,
|
||||
"disabledGroups": {},
|
||||
"slots": {
|
||||
"add-row": {
|
||||
"light": {
|
||||
"kind": "token",
|
||||
"token": "v2-green-600",
|
||||
"alpha": 5
|
||||
},
|
||||
"dark": {
|
||||
"kind": "token",
|
||||
"token": "v2-green-800",
|
||||
"alpha": 10
|
||||
}
|
||||
},
|
||||
"add-gutter": {
|
||||
"light": {
|
||||
"kind": "token",
|
||||
"token": "v2-green-600",
|
||||
"alpha": 5
|
||||
},
|
||||
"dark": {
|
||||
"kind": "token",
|
||||
"token": "v2-green-800",
|
||||
"alpha": 10
|
||||
}
|
||||
},
|
||||
"add-inline": {
|
||||
"light": {
|
||||
"kind": "token",
|
||||
"token": "v2-green-600",
|
||||
"alpha": 20
|
||||
},
|
||||
"dark": {
|
||||
"kind": "token",
|
||||
"token": "v2-green-600",
|
||||
"alpha": 40
|
||||
}
|
||||
},
|
||||
"add-bar": {
|
||||
"light": {
|
||||
"kind": "token",
|
||||
"token": "v2-green-900",
|
||||
"alpha": 100
|
||||
},
|
||||
"dark": {
|
||||
"kind": "token",
|
||||
"token": "v2-green-400",
|
||||
"alpha": 100
|
||||
}
|
||||
},
|
||||
"add-number": {
|
||||
"light": {
|
||||
"kind": "token",
|
||||
"token": "v2-green-900",
|
||||
"alpha": 100
|
||||
},
|
||||
"dark": {
|
||||
"kind": "token",
|
||||
"token": "v2-green-400",
|
||||
"alpha": 100
|
||||
}
|
||||
},
|
||||
"del-row": {
|
||||
"light": {
|
||||
"kind": "token",
|
||||
"token": "v2-red-600",
|
||||
"alpha": 5
|
||||
},
|
||||
"dark": {
|
||||
"kind": "token",
|
||||
"token": "v2-red-600",
|
||||
"alpha": 10
|
||||
}
|
||||
},
|
||||
"del-gutter": {
|
||||
"light": {
|
||||
"kind": "token",
|
||||
"token": "v2-red-600",
|
||||
"alpha": 5
|
||||
},
|
||||
"dark": {
|
||||
"kind": "token",
|
||||
"token": "v2-red-600",
|
||||
"alpha": 10
|
||||
}
|
||||
},
|
||||
"del-inline": {
|
||||
"light": {
|
||||
"kind": "token",
|
||||
"token": "v2-red-600",
|
||||
"alpha": 20
|
||||
},
|
||||
"dark": {
|
||||
"kind": "token",
|
||||
"token": "v2-red-600",
|
||||
"alpha": 40
|
||||
}
|
||||
},
|
||||
"del-bar": {
|
||||
"light": {
|
||||
"kind": "token",
|
||||
"token": "v2-red-800",
|
||||
"alpha": 100
|
||||
},
|
||||
"dark": {
|
||||
"kind": "token",
|
||||
"token": "v2-red-500",
|
||||
"alpha": 100
|
||||
}
|
||||
},
|
||||
"del-number": {
|
||||
"light": {
|
||||
"kind": "token",
|
||||
"token": "v2-red-800",
|
||||
"alpha": 100
|
||||
},
|
||||
"dark": {
|
||||
"kind": "token",
|
||||
"token": "v2-red-500",
|
||||
"alpha": 100
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"light": {
|
||||
"kind": "token",
|
||||
"token": "v2-text-text-faint",
|
||||
"alpha": 100
|
||||
},
|
||||
"dark": {
|
||||
"kind": "token",
|
||||
"token": "v2-text-text-faint",
|
||||
"alpha": 100
|
||||
}
|
||||
},
|
||||
"fold-text": {
|
||||
"light": {
|
||||
"kind": "token",
|
||||
"token": "v2-text-text-faint",
|
||||
"alpha": 100
|
||||
},
|
||||
"dark": {
|
||||
"kind": "token",
|
||||
"token": "v2-text-text-faint",
|
||||
"alpha": 100
|
||||
}
|
||||
},
|
||||
"add-inline-text": {
|
||||
"light": {
|
||||
"kind": "token",
|
||||
"token": "v2-green-1200",
|
||||
"alpha": 100
|
||||
},
|
||||
"dark": {
|
||||
"kind": "token",
|
||||
"token": "v2-text-text-base",
|
||||
"alpha": 100
|
||||
}
|
||||
},
|
||||
"del-inline-text": {
|
||||
"light": {
|
||||
"kind": "token",
|
||||
"token": "v2-red-1200",
|
||||
"alpha": 100
|
||||
},
|
||||
"dark": {
|
||||
"kind": "token",
|
||||
"token": "v2-text-text-base",
|
||||
"alpha": 100
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,11 @@
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1776683584,
|
||||
"narHash": "sha256-NuTLMrr10Tng72hurYG8jYQ4XKK8wnpJmOGcPiis96g=",
|
||||
"lastModified": 1790510107,
|
||||
"narHash": "sha256-EVMNYv7hYDDD9TGVT/hIyTYgpiXA8y3m5xIEIxuGNU0=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "9dd5558b06dbdacbf635a3dd36dce1b1a7ee3a89",
|
||||
"rev": "3181085bfd08663b6b9e60bc7a8395c2aaa741bd",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
"aarch64-linux"
|
||||
"x86_64-linux"
|
||||
"aarch64-darwin"
|
||||
"x86_64-darwin"
|
||||
];
|
||||
forEachSystem = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
|
||||
rev = self.shortRev or self.dirtyShortRev or "dirty";
|
||||
@@ -22,7 +21,7 @@
|
||||
default = pkgs.mkShell {
|
||||
packages = with pkgs; [
|
||||
bun
|
||||
nodejs_20
|
||||
nodejs
|
||||
pkg-config
|
||||
openssl
|
||||
git
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
APP=opencode2
|
||||
APP=opencode
|
||||
SOURCE_APP=opencode
|
||||
|
||||
MUTED='\033[0;2m'
|
||||
RED='\033[0;31m'
|
||||
@@ -22,7 +23,7 @@ Options:
|
||||
Examples:
|
||||
curl -fsSL https://opencode.ai/v2/install | bash
|
||||
curl -fsSL https://opencode.ai/v2/install | bash -s -- --version 0.0.0-beta-17236
|
||||
./install --binary /path/to/opencode2
|
||||
./install --binary /path/to/opencode
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -167,7 +168,7 @@ else
|
||||
|
||||
package_scope="@opencode"
|
||||
if [ -z "$requested_version" ]; then
|
||||
metadata=$(curl -fsSL https://opencode.ai/update/api/beta/cli/npm || true)
|
||||
metadata=$(curl -fsSL https://opencode.ai/update/api/latest/cli/npm || true)
|
||||
specific_version=$(echo "$metadata" | sed -n 's/.*"version":"\([^"]*\)".*/\1/p')
|
||||
package=$(echo "$metadata" | sed -n 's/.*"package":"\([^"]*\)".*/\1/p')
|
||||
|
||||
@@ -201,9 +202,9 @@ else
|
||||
|
||||
filename="cli-$target-$specific_version.tgz"
|
||||
url="https://registry.npmjs.org/$package_name/-/$filename"
|
||||
binary_name="$APP"
|
||||
binary_name="$SOURCE_APP"
|
||||
if [ "$os" = "windows" ]; then
|
||||
binary_name="$APP.exe"
|
||||
binary_name="$SOURCE_APP.exe"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -230,12 +231,7 @@ check_version() {
|
||||
installed_version="${installed_version##* }"
|
||||
installed_version="${installed_version#v}"
|
||||
|
||||
if [[ "$installed_version" != "$specific_version" ]]; then
|
||||
print_message info "${MUTED}Installed version: ${NC}$installed_version."
|
||||
else
|
||||
print_message info "${MUTED}Version ${NC}$specific_version${MUTED} already installed"
|
||||
exit 0
|
||||
fi
|
||||
print_message info "${MUTED}Installed version: ${NC}$installed_version."
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -340,15 +336,46 @@ download_and_install() {
|
||||
fi
|
||||
|
||||
tar -xzf "$tmp_dir/$filename" -C "$tmp_dir"
|
||||
mv "$tmp_dir/package/bin/$binary_name" "$INSTALL_DIR"
|
||||
chmod 755 "${INSTALL_DIR}/$binary_name"
|
||||
local installed_binary="$APP"
|
||||
if [ "$os" = "windows" ]; then
|
||||
installed_binary="$APP.exe"
|
||||
fi
|
||||
mv "$tmp_dir/package/bin/$binary_name" "$INSTALL_DIR/$installed_binary"
|
||||
chmod 755 "$INSTALL_DIR/$installed_binary"
|
||||
rm -rf "$tmp_dir"
|
||||
}
|
||||
|
||||
install_from_binary() {
|
||||
print_message info "\n${MUTED}Installing ${NC}$APP ${MUTED}from: ${NC}$binary_path"
|
||||
cp "$binary_path" "${INSTALL_DIR}/$APP"
|
||||
chmod 755 "${INSTALL_DIR}/$APP"
|
||||
local installed_binary="$APP"
|
||||
case "$(uname -s)" in
|
||||
MINGW*|MSYS*|CYGWIN*) installed_binary="$APP.exe" ;;
|
||||
esac
|
||||
cp "$binary_path" "$INSTALL_DIR/$installed_binary"
|
||||
chmod 755 "$INSTALL_DIR/$installed_binary"
|
||||
}
|
||||
|
||||
install_legacy_shim() {
|
||||
local shim_os="${os:-}"
|
||||
if [[ -z "$shim_os" ]]; then
|
||||
case "$(uname -s)" in
|
||||
MINGW*|MSYS*|CYGWIN*) shim_os="windows" ;;
|
||||
esac
|
||||
fi
|
||||
rm -f "$INSTALL_DIR/opencode2" "$INSTALL_DIR/opencode2.exe" "$INSTALL_DIR/opencode2.cmd"
|
||||
if [[ "$shim_os" == "windows" ]]; then
|
||||
cat > "$INSTALL_DIR/opencode2.cmd" <<'EOF'
|
||||
@echo off
|
||||
"%~dp0opencode.exe" %*
|
||||
exit /b %errorlevel%
|
||||
EOF
|
||||
return
|
||||
fi
|
||||
cat > "$INSTALL_DIR/opencode2" <<'EOF'
|
||||
#!/bin/sh
|
||||
exec "$(dirname "$0")/opencode" "$@"
|
||||
EOF
|
||||
chmod 755 "$INSTALL_DIR/opencode2"
|
||||
}
|
||||
|
||||
if [ -n "$binary_path" ]; then
|
||||
@@ -357,6 +384,7 @@ else
|
||||
check_version
|
||||
download_and_install
|
||||
fi
|
||||
install_legacy_shim
|
||||
|
||||
|
||||
add_to_path() {
|
||||
@@ -453,7 +481,7 @@ echo -e ""
|
||||
echo -e "${MUTED}OpenCode includes free models, to start:${NC}"
|
||||
echo -e ""
|
||||
echo -e "cd <project> ${MUTED}# Open directory${NC}"
|
||||
echo -e "opencode2 ${MUTED}# Run command${NC}"
|
||||
echo -e "opencode ${MUTED}# Run command${NC}"
|
||||
echo -e ""
|
||||
echo -e "${MUTED}For more information visit ${NC}https://opencode.ai/v2/docs"
|
||||
echo -e ""
|
||||
|
||||
@@ -90,7 +90,7 @@ stdenv.mkDerivation (finalAttrs: {
|
||||
export OPENCODE_CLI_DIST="$TMPDIR/desktop-cli"
|
||||
cli_package=$(bun -e 'import { getCurrentCli } from "./scripts/utils.ts"; console.log(getCurrentCli().package.replace("@opencode/", ""))')
|
||||
mkdir -p "$OPENCODE_CLI_DIST/$cli_package/bin"
|
||||
cp ${lib.getExe opencode} "$OPENCODE_CLI_DIST/$cli_package/bin/opencode2"
|
||||
cp ${lib.getExe opencode} "$OPENCODE_CLI_DIST/$cli_package/bin/opencode"
|
||||
|
||||
bun run build
|
||||
npx electron-builder --dir \
|
||||
|
||||
@@ -7,7 +7,6 @@ in
|
||||
aarch64-linux = "20e68d6c4e47f3ebf59de7c6b1f8b8bec6a6ebda6a451132f9b465f3f13ce467";
|
||||
x86_64-linux = "2452b27112d92387471fa2488aafac85d79ea3f2ee1216c0abd5150d6c12362b";
|
||||
aarch64-darwin = "ac7194a3dfd81930ba35355c01620262c1254752859b42dcb8f4b9e4d174a871";
|
||||
x86_64-darwin = "4489aba55477a0082266cb690db1c829503ba3338048599d8fd243953df37dab";
|
||||
# fetchzip hashes the unpacked headers, not the release tarball.
|
||||
headers = "sha256-4eUy3BZVvxTl7KUOsxio7769lL6ag/ecbeK+qLURWMI=";
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-yzCk746pospz8EVakHRcDhYJkhGYGSt9dHOPbzO4OYo=",
|
||||
"aarch64-linux": "sha256-MFJVLos4v2r9jazmmr3ldVCJKLrO+Qp/BpGpyM/1lf8=",
|
||||
"aarch64-darwin": "sha256-k8r/HVSgdRSTlJ1lI7EebqbxeA3AElnaw1sDYOPEdQw=",
|
||||
"x86_64-darwin": "sha256-ACJdJfz12xLBQvWIkbuve86znSoGJ2PLDQbgh0cT6/g="
|
||||
"x86_64-linux": "sha256-yCdtDQsXERjfL9bJg7YXNloMBOPwFkPMFVNAzXlarBM=",
|
||||
"aarch64-linux": "sha256-iQ1bLIszETcFoD4CENpoaDZJZF/KBKZFeTp9r7ogJ9Y=",
|
||||
"aarch64-darwin": "sha256-G7oIrTXEFQ5iEF8KSpA4xJPw9xwqRDpbIR472iIoqC4="
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,5 @@ stdenvNoCC.mkDerivation {
|
||||
"aarch64-linux"
|
||||
"x86_64-linux"
|
||||
"aarch64-darwin"
|
||||
"x86_64-darwin"
|
||||
];
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
makeBinaryWrapper,
|
||||
models-dev,
|
||||
ripgrep,
|
||||
wayland,
|
||||
installShellFiles,
|
||||
versionCheckHook,
|
||||
writableTmpDirAsHomeHook,
|
||||
@@ -62,9 +63,10 @@ stdenvNoCC.mkDerivation (finalAttrs: {
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
install -Dm755 dist/cli-*/bin/opencode2 $out/bin/opencode2
|
||||
install -Dm755 dist/cli-*/bin/opencode $out/bin/opencode
|
||||
|
||||
wrapProgram $out/bin/opencode2 \
|
||||
# OpenTUI dlopens Wayland for clipboard images.
|
||||
wrapProgram $out/bin/opencode \
|
||||
--prefix PATH : ${
|
||||
lib.makeBinPath (
|
||||
[
|
||||
@@ -73,13 +75,21 @@ stdenvNoCC.mkDerivation (finalAttrs: {
|
||||
# bun runs sysctl to detect if running on rosetta2
|
||||
++ lib.optional stdenvNoCC.hostPlatform.isDarwin sysctl
|
||||
)
|
||||
}
|
||||
} ${lib.optionalString stdenvNoCC.hostPlatform.isLinux ''
|
||||
--prefix LD_LIBRARY_PATH : ${lib.makeLibraryPath [ wayland ]}
|
||||
''}
|
||||
|
||||
ln -s opencode $out/bin/opencode2
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
postInstall = lib.optionalString (stdenvNoCC.buildPlatform.canExecute stdenvNoCC.hostPlatform) ''
|
||||
# trick yargs into also generating zsh completions
|
||||
installShellCompletion --cmd opencode \
|
||||
--bash <($out/bin/opencode completion) \
|
||||
--zsh <(SHELL=/bin/zsh $out/bin/opencode completion)
|
||||
|
||||
installShellCompletion --cmd opencode2 \
|
||||
--bash <($out/bin/opencode2 completion) \
|
||||
--zsh <(SHELL=/bin/zsh $out/bin/opencode2 completion)
|
||||
@@ -101,7 +111,7 @@ stdenvNoCC.mkDerivation (finalAttrs: {
|
||||
description = "The open source coding agent";
|
||||
homepage = "https://opencode.ai";
|
||||
license = lib.licenses.mit;
|
||||
mainProgram = "opencode2";
|
||||
mainProgram = "opencode";
|
||||
inherit (node_modules.meta) platforms;
|
||||
};
|
||||
})
|
||||
@@ -2,15 +2,15 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"version": "0.0.0",
|
||||
"version": "2.0.21",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.2",
|
||||
"scripts": {
|
||||
"dev": "bun run --cwd packages/cli src/index.ts",
|
||||
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev \"$@\" --server \"$(opencode2 service status)\"' --",
|
||||
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode service get password)\" exec bun run dev \"$@\" --server \"$(opencode service status)\"' --",
|
||||
"dev:vite": "bun run --cwd packages/cli --conditions=browser dev/vite.ts",
|
||||
"dev:vite:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev:vite \"$@\" --server \"$(opencode2 service status)\"' --",
|
||||
"dev:vite:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode service get password)\" exec bun run dev:vite \"$@\" --server \"$(opencode service status)\"' --",
|
||||
"dev:desktop": "bun --cwd packages/desktop dev",
|
||||
"dev:web": "bun --cwd packages/app dev",
|
||||
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
||||
@@ -18,12 +18,13 @@
|
||||
"dev:www": "bun run --cwd services/www dev",
|
||||
"dev:storybook": "bun --cwd packages/storybook storybook",
|
||||
"bench:devex": "bun run --cwd packages/app test:bench:devex",
|
||||
"lint": "oxlint",
|
||||
"lint": "oxlint && ast-grep scan -c script/ast-grep/gui-extensions/sgconfig.yml",
|
||||
"lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/util/src packages/core/src packages/server/src packages/protocol/src packages/cli/src",
|
||||
"lint:effect-simplifications": "ast-grep scan -c script/ast-grep/effect-simplifications/sgconfig.yml --off=unused-suppression packages",
|
||||
"test:lint-rules": "ast-grep test -c script/ast-grep/sgconfig.yml",
|
||||
"test:effect-simplification-rules": "ast-grep test -c script/ast-grep/effect-simplifications/sgconfig.yml",
|
||||
"typecheck": "bun turbo typecheck --concurrency=3",
|
||||
"check": "bun run lint && bun run typecheck",
|
||||
"typecheck:profile": "bun script/profile-typecheck.ts",
|
||||
"typecheck:profile:packages": "bun script/profile-typecheck-packages.ts",
|
||||
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
|
||||
@@ -51,9 +52,9 @@
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.5.10",
|
||||
"@opentui/keymap": "0.5.10",
|
||||
"@opentui/solid": "0.5.10",
|
||||
"@opentui/core": "0.5.14",
|
||||
"@opentui/keymap": "0.5.14",
|
||||
"@opentui/solid": "0.5.14",
|
||||
"@tanstack/solid-virtual": "3.13.37",
|
||||
"@shikijs/stream": "4.4.3",
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
@@ -67,7 +68,7 @@
|
||||
"@tsconfig/bun": "1.0.9",
|
||||
"@cloudflare/workers-types": "4.20251008.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@pierre/diffs": "1.2.10",
|
||||
"@pierre/diffs": "1.5.1",
|
||||
"opentui-spinner": "0.0.7",
|
||||
"@solid-primitives/event-listener": "2.4.6",
|
||||
"@solid-primitives/media": "2.3.6",
|
||||
@@ -115,10 +116,12 @@
|
||||
"devDependencies": {
|
||||
"@actions/artifact": "5.0.1",
|
||||
"@ast-grep/cli": "0.44.0",
|
||||
"@types/react": "19.2.17",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@opencode/client": "workspace:*",
|
||||
"@oxlint/plugins": "1.60.0",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/react": "19.2.17",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"glob": "13.0.5",
|
||||
"husky": "9.1.7",
|
||||
@@ -160,6 +163,7 @@
|
||||
"@types/node": "catalog:",
|
||||
"bun-types": "1.4.2",
|
||||
"effect": "catalog:",
|
||||
"open": "11.0.4",
|
||||
"solid-js": "catalog:"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
@@ -171,13 +175,13 @@
|
||||
"solid-js@1.9.15": "patches/solid-js@1.9.15.patch",
|
||||
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
|
||||
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
||||
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
||||
"pacote@21.5.1": "patches/pacote@21.5.1.patch",
|
||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||
"@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch",
|
||||
"@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch",
|
||||
"ghostty-web@github:anomalyco/ghostty-web#83c0a07": "patches/ghostty-web@0.3.0.patch",
|
||||
"vite@8.2.2": "patches/vite@8.2.2.patch"
|
||||
"vite@8.2.2": "patches/vite@8.2.2.patch",
|
||||
"@modelcontextprotocol/client@2.0.0": "patches/@modelcontextprotocol%2Fclient@2.0.0.patch"
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,17 @@
|
||||
|
||||
## Conventions
|
||||
|
||||
Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `LanguageModel.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, and `LLM.generateObject`. Use `LLMRequest.update(...)` when deriving canonical request data; do not add a duplicate `LLM.updateRequest(...)` path. Two ways to construct the same thing is one too many.
|
||||
Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `Message.media(...)`, `LanguageModel.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, and `LLM.generateObject`. `LLM.generate`/`LLM.stream` and Promise `ai.llm.generate`/`ai.llm.stream` accept ergonomic input or an `LLMRequest`; both paths use the same canonical request. Core still builds, logs, replays, and updates that durable `LLMRequest` boundary. Use `LLMRequest.update(...)` when deriving canonical request data; do not add a duplicate `LLM.updateRequest(...)` path.
|
||||
|
||||
- Keep provider-defined string enums forward-compatible. Expose known values for autocomplete while accepting future values with `Known | (string & {})`; use `Schema.String` at runtime unless rejecting unknown values is required for correctness.
|
||||
Modality namespaces mirror `LLM` exactly: `Image.request`, `Image.generate`, `Image.stream`, and the same for `Video`, `Speech`, and `Transcription`. Common request fields (`images`, `mask`, `n`, `size`, `aspectRatio`, `seed`, `format`) lower natively or fail with a typed `AIError`; provider-native controls always live under `providerOptions`, never under a modality-specific `options` key.
|
||||
|
||||
Media payloads are always `Media.Asset` (`src/media.ts`). Construct them with `Media.bytes`, `Media.base64`, `Media.url`, `Media.ref`, `Media.fromDataUrl`, or `Media.file`; never introduce a parallel `data: string | Uint8Array` shape. `MediaPart.media`, `ImageRequest.images`/`mask`, `ImageResponse.images`, and the `media` `LLMEvent` all share it. Protocols branch on `asset.source.type` and `asset.kind` and use `ProviderShared.requireInlineMedia` / `inlineRequired` / `mediaUrl` / `mediaReference` and `MediaInput.inlineBytes` / `refID` rather than re-deriving base64 or URL handling.
|
||||
|
||||
`schema/messages.ts → media.ts → route/executor-service.ts` is an accepted runtime dependency from the schema layer on the executor service tag: `Media.Asset.bytes()` must be able to download `url` sources, and the tag lives in that leaf module precisely so the schema barrel never imports the executor implementation (which imports the schema barrel back). Do not move the tag into `route/executor.ts` or import `route/executor.ts` from `src/schema/*` or `src/media.ts`.
|
||||
|
||||
Nothing in `src/*` except `src/promise.ts` may know about Promises. `@opencode/ai/promise` (`AI.make({ layer? })`, default `ai`) is the single Promise/`AsyncIterable` surface for LLM and media; it runs the Effect APIs in one `ManagedRuntime` and rethrows `AIError` unchanged.
|
||||
|
||||
- Prefer forward compatibility for provider-defined options that OpenCode only passes through. For pass-through string enums, expose known values for autocomplete while accepting future values with `Known | (string & {})`, and accept any string at runtime. Closed literals are appropriate when OpenCode branches on a value, transforms its associated structure, or otherwise cannot correctly handle an unknown variant. New options whose shape or behavior requires implementation remain unsupported until they are handled; do not blindly forward unknown structures.
|
||||
- Order reasoning-effort values from lowest to highest: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. Provider-specific subsets follow the same relative order in types, schemas, option lists, and tests.
|
||||
|
||||
## Tests
|
||||
@@ -47,7 +55,7 @@ const request = LLM.request({
|
||||
prompt: "Say hello.",
|
||||
})
|
||||
|
||||
const response = yield * LLMClient.generate(request)
|
||||
const response = yield * LLMClient.generate(request) // inside Effect.gen
|
||||
```
|
||||
|
||||
`LLM.request(...)` builds an `LLMRequest`. `LLMClient.generate(...)` reads the executable route carried by `request.model.route`, builds the provider-native body, asks the route's transport for a real `HttpClientRequest.HttpClientRequest`, sends it through `RequestExecutor.Service`, parses the provider stream into common `LLMEvent`s, and finally returns an `LLMResponse`.
|
||||
@@ -86,6 +94,16 @@ The four-axis decomposition is the reason DeepSeek, TogetherAI, Cerebras, Basete
|
||||
|
||||
When a provider supports multiple physical transports, selection remains execution policy below its semantic route. `OpenResponsesChannel.transport(...)` owns the provider-neutral Responses WebSocket concept: it prepares one final request, executes HTTP by default, strips WebSocket-disallowed fields, and passes a generic channel exchange to a per-call `WebSocketChannelExecutor` when supplied. Provider-specific Responses routes opt in with handshake and connection-age policy. `Route.streamPrepared` owns decoding and acknowledges channel completion only after successful full consumption.
|
||||
|
||||
### Media Routes
|
||||
|
||||
Media does not fit the SSE-frames-to-event-state-machine LLM route. `MediaRoute.inline(...)` / `queued(...)` / `stream(...)` (`src/route/media.ts`) compose a `MediaProtocol` kind with `Endpoint` and `Auth` and own the transport plumbing: `http` option merging, URL/query rendering, auth headers, JSON vs multipart encoding, and handing the response back to the protocol. `MediaProtocol.inline` (`src/route/media-protocol.ts`) is `body.from(request)` plus `response.decode(response, context)`; each protocol declares `const route = MediaProtocol.identity({ id, name, provider })` once and decodes through `route.decodeJson` / `route.text` / `route.decodeStarted` so decode failures retain the raw body and HTTP context, raising `route.unsupported(operation, message)` for requests it cannot lower, and passes `route` as the first argument to `MediaProtocol.inline` / `queued` / `stream`. `Generation` (`src/generation.ts`) is the provider-neutral handle for a queued generation over a `GenerationRoute` (`status`, `result`, `cancel`). Image protocol files follow the same section order as LLM protocols and declare unsupported common fields once through the protocol's `unsupported` list.
|
||||
|
||||
`MediaProtocol.queued` is the submit-then-poll kind every video route uses: `start` (body + decode into `{ token, snapshot }`), `status`, `result`, and optional `cancel` (with `activeOnly` when the provider's cancel endpoint deletes finished work, as Runway's does: the route refreshes status first and skips terminal generations), each addressed by a route-owned `token` whose `Schema.Codec` makes it serializable. `MediaRoute.inline` and `MediaRoute.queued` compose the two kinds with `Endpoint` and `Auth`; the queued route decodes the token once at the boundary (`start` output or `resume` input) and closes over it in a token-free `GenerationRoute` (`status`/`result`/`cancel` are plain Effects), so `Generation` never sees the token's shape and only carries the encoded JSON for persistence. Polls reuse the route's auth and deployment headers plus the request's `http` overlay after `start`, and resolve relative paths against the route base URL (provider-issued absolute URLs such as fal's `status_url` pass through). `result` is always its own GET even when the provider returns output inside the status document, so `Generation.await` behaves the same after `start` and after `resume`. `PollContext.auth` carries only what `Auth` added or changed so protocols can hand download credentials to output assets as transient `Media.Asset.headers` (Veo) — never part of `source` or JSON. Status strings map through a per-protocol `STATUS` table via `MediaProtocol.status`; terminal generations without output fail through `output.ended` / `output.contentPolicy` with the provider document on `reason.body`; a `failed` generation maps the provider's error code through a per-protocol `FAILURE` table via `MediaProtocol.failure` so rejected inputs are not reported as retryable `ProviderInternal`. `GenerationAwaitOptions` (`AwaitOptions` in `src/generation.ts`, `{ poll?: Poll }`) is the one options type for `await`, `events`, `Video.generate`, and `Video.stream`.
|
||||
|
||||
`MediaProtocol.stream` is the incremental kind every speech route uses, with the same discipline as LLM protocols. `MediaRoute.stream` submits the caller's request as `MediaProtocol.Addressed<Request>` (`{ ...request, mode }`, `mode: "generate" | "stream"`), so one provider stays one protocol: `body.from`, the endpoint path, and `frames` read `request.mode` to pick the body, path, and framing. `frames(bytes, context)` returns frames — `Framing.sse`, `Framing.lines`, `Framing.document` (a single-document response shaped like a streamed record), or the raw `bytes` for chunked audio. `initial()` is fresh per-response parser state; `step` folds each frame into it and emits modality events; `finish(state, context)` runs once after the last frame with the request, body, and observed `http` (header-only usage lives there) and emits exactly one terminal event or fails with `route.incomplete()`. Keep parser state to real accumulators and derive anything the request or body determines in `finish`. `generate` runs the same stream and folds it with the modality's `collect`. Request-derived URL parameters go on the body's `query` (array values repeat the parameter), applied before route and caller `http.query`. Decode frames with `route.decodeFrame` and raise stream-time failures with `route.frameError` (the frame stays on `reason.body`); protocols never thread HTTP context, because the route fills `reason.http` on stream errors that lack it. Speech protocols share `protocols/utils/speech-stream.ts` for deltas, timestamps, voice ids, PCM and container descriptions, and the terminal asset.
|
||||
|
||||
Every modality route is the inline | stream | queued union (transcription uses all three: OpenAI and Gemini stream, Deepgram and ElevenLabs are inline, AssemblyAI is queued), every client is `MediaClient.make(Service, { modality, responseEvents })` (`src/media-client.ts`), which dispatches on the route's `kind`, and every model composes through `composeRoute`. fal queue protocols come from `protocols/utils/fal-queue.ts`, bodies are `json`, `multipart`, or `binary` (a raw upload), and a queued protocol that must upload media before submitting implements `start.prepare` (`MediaProtocol.Prepare`; AssemblyAI `/v2/upload`).
|
||||
|
||||
### URL Construction
|
||||
|
||||
`Endpoint` owns `{ baseURL, path, query }`. Each protocol route includes a canonical endpoint when the provider has one (e.g. `https://api.openai.com/v1`); provider helpers override endpoint fields by configuring the route before selecting a model. Generic OpenAI-compatible routes have no canonical URL and require configuration before execution.
|
||||
@@ -94,11 +112,12 @@ For providers where the URL is derived from typed inputs (Azure resource name, B
|
||||
|
||||
### Provider Facades
|
||||
|
||||
Provider-facing APIs are configured facades over route values. Endpoint/auth/resource/API-version setup happens before model selection, and model selectors accept only a model or deployment id:
|
||||
Provider-facing APIs are configured facades over route values. Endpoint/auth/resource/API-version setup happens before model selection, and model selectors accept only a model or deployment id. Media models use per-modality selectors on the same facade (`openai.image(id)`, `.speech(id)`, `.transcription(id)`, `google.video(id)`) that mirror `openai.responses(id)`; the one-word overlap with the request namespace is accepted over a second construction path:
|
||||
|
||||
```ts
|
||||
const openai = OpenAI.configure({ apiKey, baseURL })
|
||||
const model = openai.responses("gpt-4o-mini")
|
||||
const image = openai.image("gpt-image-2")
|
||||
|
||||
const azure = Azure.configure({ resourceName, apiKey, apiVersion: "v1" })
|
||||
const deployment = azure.responses("my-deployment")
|
||||
@@ -122,13 +141,14 @@ Keep provider facades small and explicit:
|
||||
|
||||
### Provider Package Entrypoints
|
||||
|
||||
Catalog-selected native providers use package-like export paths from `@opencode/ai`. They are internal entrypoints in one npm package, not separately published provider packages. Every entrypoint implements `ProviderPackage.Definition` and exposes `model(modelID, settings)`, where settings are serializable provider configuration plus common `headers`, `body`, and `limits` overlays.
|
||||
Catalog-selected native providers use package-like export paths from `@opencode/ai`. They are internal entrypoints in one npm package, not separately published provider packages. Every entrypoint implements `ProviderPackage.Definition` and exposes `model(modelID, settings)`, where settings are one flat serializable object: the connection keys the entrypoint declares (`apiKey`, `baseURL`, `region`, …), the common `headers` and `body` overlays, and the protocol's request options (`reasoningEffort`, `thinking`, …) side by side. Each entrypoint destructures its own connection keys and passes the rest to the route as `providerOptions`; there is no nested `providerOptions` at the entrypoint.
|
||||
|
||||
```ts
|
||||
import { model } from "@opencode/ai/providers/openai/responses"
|
||||
|
||||
const selected = model("gpt-5", {
|
||||
apiKey,
|
||||
reasoningEffort: "high",
|
||||
})
|
||||
```
|
||||
|
||||
@@ -165,6 +185,10 @@ Native chronological system messages are route/model-specific. Open Responses lo
|
||||
|
||||
The wrapped-user fallback preserves ordering while visibly lowering authority. Never silently pass a raw chronological `role: "system"` through a route that might reject it. Do not insert raw retrieved documents, tool output, or web content into privileged chronological system updates; keep untrusted content in ordinary user/tool channels.
|
||||
|
||||
### Effort Updates
|
||||
|
||||
`Message.effort({ effort, previous })` is a chronological "reasoning effort changed here" marker (`undefined` means the model default). Changing a top-level effort invalidates the whole provider prompt cache, so protocols with a native per-message update (`Protocol.supportsEffortUpdates`) keep the top-level effort at the first marker's `previous` and lower each marker in place: Anthropic Messages emits an empty `role: "system"` message with `output_config.effort` plus the `mid-conversation-output-config-2026-07-01` beta, and OpenAI Responses emits `configuration_update` items. `applyEffortUpdates` runs in `prepareRequest` and strips the markers for every other route, so a protocol without support keeps today's plain top-level behaviour. When the last marker disagrees with the effort the request asks for (reverted or forked history), `resolveEffortUpdates` strips the markers and falls back to a plain top-level change.
|
||||
|
||||
### Tools
|
||||
|
||||
Tool loops are represented in common messages and events:
|
||||
@@ -251,6 +275,7 @@ Use this order for every protocol module:
|
||||
### Rules
|
||||
|
||||
- Keep protocol files focused on the protocol. Move provider-specific projection, signing, media normalization, or other bulky transformations into `src/protocols/utils/*`.
|
||||
- Send `tool.inputSchema` as given. `prepareRequest` applies the tool schema rules (`ToolSchemaProjection.tools`) once per request, including tools in namespaces. A protocol whose API needs a model family's rules for every model declares `sanitizer` instead of transforming schemas itself.
|
||||
- Use `Effect.fn("Provider.fromRequest")` for request body construction entrypoints. Use `Effect.fn(...)` for event handlers that yield effects; keep purely synchronous handlers as plain functions returning a `StepResult` that the dispatcher lifts via `Effect.succeed(...)`.
|
||||
- Parser state owns terminal information. The state machine records finish reason, usage, and pending tool calls; emit one terminal `finish` event (or `provider-error`) for each completed response. If a provider splits reason and usage across events, merge them in parser state before flushing.
|
||||
- Emit exactly one terminal `finish` event for a completed response, normally after a matching `step-finish`. Use `stream.terminal` to stop reading when the provider has a completion sentinel; use `stream.onHalt` when the final event must be flushed after the framed stream ends.
|
||||
|
||||
@@ -1,33 +1,209 @@
|
||||
# @opencode/ai
|
||||
|
||||
Schema-first language model and image-generation APIs built with Effect.
|
||||
Schema-first APIs for text, images, video, speech, and transcription, built with Effect.
|
||||
|
||||
```ts
|
||||
import { Effect, Layer } from "effect"
|
||||
import { LLM, LLMClient } from "@opencode/ai"
|
||||
import { RequestExecutor } from "@opencode/ai/route"
|
||||
import { Effect } from "effect"
|
||||
import { AIClient, LLM } from "@opencode/ai"
|
||||
import { OpenAI } from "@opencode/ai/providers"
|
||||
|
||||
const model = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY }).responses("gpt-4o-mini")
|
||||
|
||||
const request = LLM.request({
|
||||
model,
|
||||
system: "You are concise.",
|
||||
prompt: "Say hello in one short sentence.",
|
||||
generation: { maxTokens: 40 },
|
||||
})
|
||||
const openai = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY })
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request)
|
||||
const response = yield* LLM.generate({
|
||||
model: openai.responses("gpt-4o-mini"), // `.chat(...)` selects the Chat Completions API instead
|
||||
system: "You are concise.",
|
||||
prompt: "Say hello in one short sentence.",
|
||||
generation: { maxTokens: 40 },
|
||||
})
|
||||
console.log(response.text)
|
||||
})
|
||||
|
||||
const llmLayer = LLMClient.layer.pipe(Layer.provide(RequestExecutor.fetchLayer))
|
||||
|
||||
await Effect.runPromise(program.pipe(Effect.provide(llmLayer)))
|
||||
// Every modality client plus the HTTP request executor; `AIClient.layerWith(executor)` swaps the executor.
|
||||
await Effect.runPromise(program.pipe(Effect.provide(AIClient.layer)))
|
||||
```
|
||||
|
||||
Run `LLMClient.stream(request)` instead of `generate` when you want incremental `LLMEvent`s. The event stream is provider-neutral — same shape across OpenAI Chat, OpenAI Responses, Anthropic Messages, Gemini, Bedrock Converse, and any OpenAI-compatible deployment.
|
||||
Run `LLM.stream(...)` instead of `generate` when you want incremental `LLMEvent`s. Both accept input or a prebuilt
|
||||
`LLM.request(...)`. The event stream is provider-neutral — same shape across OpenAI Chat, OpenAI Responses,
|
||||
Anthropic Messages, Gemini, Bedrock Converse, and any OpenAI-compatible deployment.
|
||||
|
||||
The same configured facade names image, video, speech, and transcription models. `Image.generate` resolves the
|
||||
provider's image route from the model and returns `Media.Asset`s with lazily decoded bytes:
|
||||
|
||||
```ts
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Image, Media } from "@opencode/ai"
|
||||
|
||||
const image = Effect.gen(function* () {
|
||||
const response = yield* Image.generate({
|
||||
model: openai.image("gpt-image-2"),
|
||||
prompt: "A robot tending a rooftop garden",
|
||||
size: "1024x1024",
|
||||
providerOptions: { quality: "high" }, // typed per image model
|
||||
})
|
||||
yield* Media.write(response.image, "./garden.png")
|
||||
})
|
||||
|
||||
// `Media.file` / `Media.write` use the Effect `FileSystem` service; provide your platform's layer.
|
||||
await Effect.runPromise(image.pipe(Effect.provide(AIClient.layer), Effect.provide(NodeFileSystem.layer)))
|
||||
```
|
||||
|
||||
Advanced: each client also has its own `layer`, which requires `RequestExecutor.Service`. Compose client layers with
|
||||
`Layer.provideMerge`, not `Layer.provide`: `asset.bytes()`, `Media.write`, and Gemini's `media` output parts need the
|
||||
executor too, and hiding it fails type-checking with `RequestExecutorService` left in the requirements.
|
||||
|
||||
To share a policy such as logging across every client, wrap the executor once with `RequestExecutor.middleware`:
|
||||
|
||||
```ts
|
||||
import { RequestExecutor } from "@opencode/ai/route"
|
||||
|
||||
const logged = RequestExecutor.middleware((request, next) =>
|
||||
Effect.log(`${request.method} ${request.url}`).pipe(Effect.andThen(next(request))),
|
||||
)
|
||||
|
||||
const everything = AIClient.layerWith(logged) // or AI.make({ layer: logged })
|
||||
```
|
||||
|
||||
Prefer promises? `@opencode/ai/promise` exposes the same LLM and media APIs over one managed runtime, plus asset
|
||||
helpers; `ai.file` and `ai.write` load `node:fs/promises` on first use, so no Effect `FileSystem` is needed:
|
||||
|
||||
```ts
|
||||
import { AI } from "@opencode/ai/promise"
|
||||
|
||||
const ai = AI.make()
|
||||
const input = { model: openai.responses("gpt-4o-mini"), prompt: "Say hello." }
|
||||
const text = await ai.llm.generate(input)
|
||||
const generated = await ai.image.generate({ model: openai.image("gpt-image-2"), prompt: "A lighthouse" })
|
||||
await ai.write(generated.image, "./lighthouse.png") // also ai.file(path), ai.bytes(asset), ai.base64(asset), ai.materialize(asset)
|
||||
for await (const event of ai.llm.stream(ai.llm.request(input))) {
|
||||
// LLMEvent
|
||||
}
|
||||
await ai.dispose()
|
||||
```
|
||||
|
||||
## Experimental evaluation
|
||||
|
||||
Evaluation models compare shared state with typed choice, score, and boolean questions. The API is
|
||||
isolated under an experimental entrypoint and provider namespace while the contract evolves:
|
||||
|
||||
```ts
|
||||
import { Effect } from "effect"
|
||||
import { Evaluation, EvaluationClient } from "@opencode/ai/experimental"
|
||||
import { TypeSafeAI } from "@opencode/ai/providers"
|
||||
|
||||
const model = TypeSafeAI.configure().experimental.evaluation("jev-latest")
|
||||
|
||||
const program = Evaluation.run({
|
||||
model,
|
||||
state: "I was charged twice. Please refund the duplicate payment.",
|
||||
questions: {
|
||||
department: {
|
||||
type: "choice",
|
||||
instructions: "Which team should handle this?",
|
||||
criteria: { billing: "Payments and refunds", technical: "Bugs and outages" },
|
||||
},
|
||||
urgency: {
|
||||
type: "score",
|
||||
instructions: "How urgent is this?",
|
||||
criteria: ["Can wait", "Needs prompt attention", "Blocking revenue"],
|
||||
},
|
||||
refund: { type: "boolean", instructions: "Is the customer asking for a refund?" },
|
||||
},
|
||||
})
|
||||
|
||||
const response = await Effect.runPromise(program.pipe(Effect.provide(EvaluationClient.fetchLayer)))
|
||||
|
||||
console.log(response.answers.department.choice)
|
||||
console.log(response.answers.refund.probability)
|
||||
```
|
||||
|
||||
`TypeSafeAI` reads `TYPESAFE_API_KEY`. `OpenCodeZen` exposes the same selector and reads
|
||||
`OPENCODE_API_KEY`. OpenRouter and Vercel AI Gateway use the same provider shape:
|
||||
|
||||
```ts
|
||||
import { OpenRouter, VercelAIGateway } from "@opencode/ai/providers"
|
||||
|
||||
OpenRouter.configure().experimental.evaluation("typesafe/jev-1.13")
|
||||
VercelAIGateway.configure().experimental.evaluation("typesafe-ai/jev")
|
||||
```
|
||||
|
||||
OpenRouter reads `OPENROUTER_API_KEY`. Vercel reads `AI_GATEWAY_API_KEY`, then `VERCEL_OIDC_TOKEN`.
|
||||
The common API uses `boolean`; System One routes lower it to native `noul`.
|
||||
Choice and score answers include `confidence` when the provider returns it, such as
|
||||
`response.answers.department.confidence`. Score legends remain available in provider metadata, and
|
||||
the provider's rounded probabilities are returned unchanged.
|
||||
|
||||
## Alibaba Cloud Model Studio
|
||||
|
||||
`Alibaba` provides standard Model Studio inference. Configure a region explicitly, then select
|
||||
Chat Completions (`.model` or `.chat`), Anthropic-compatible Messages (`.messages`), or OpenAI-compatible
|
||||
Responses (`.responses`). These routes use HTTP/SSE.
|
||||
|
||||
```ts
|
||||
import { LLM } from "@opencode/ai"
|
||||
import { Alibaba } from "@opencode/ai/providers"
|
||||
|
||||
const alibaba = Alibaba.configure({
|
||||
region: "ap-southeast-1", // Singapore
|
||||
apiKey: process.env.DASHSCOPE_API_KEY,
|
||||
// workspaceID: "llm-your-workspace", // use a workspace-dedicated endpoint
|
||||
})
|
||||
|
||||
const request = LLM.request({
|
||||
model: alibaba.model("qwen3.8-max"),
|
||||
prompt: "Explain this design.",
|
||||
providerOptions: { reasoningEffort: "medium" },
|
||||
})
|
||||
```
|
||||
|
||||
### Regions and credentials
|
||||
|
||||
| Region | `region` | Shared host when `workspaceID` is omitted |
|
||||
| ------------------- | ---------------- | ----------------------------------------- |
|
||||
| Singapore | `ap-southeast-1` | `dashscope-intl.aliyuncs.com` |
|
||||
| China (Beijing) | `cn-beijing` | `dashscope.aliyuncs.com` |
|
||||
| China (Hong Kong) | `cn-hongkong` | `cn-hongkong.dashscope.aliyuncs.com` |
|
||||
| US (Virginia) | `us-east-1` | `dashscope-us.aliyuncs.com` |
|
||||
| Germany (Frankfurt) | `eu-central-1` | Supply `workspaceID` or `baseURL` |
|
||||
| Japan (Tokyo) | `ap-northeast-1` | Supply `workspaceID` or `baseURL` |
|
||||
|
||||
With `workspaceID`, the host is `{workspaceID}.{region}.maas.aliyuncs.com`. A complete `baseURL`
|
||||
overrides regional setup, including the API prefix: `/compatible-mode/v1` for Chat/Responses,
|
||||
or `/apps/anthropic/v1` for Messages. The selector appends its operation path.
|
||||
|
||||
Keys and model availability are region-specific. Auth resolves from explicit `auth` or `apiKey`,
|
||||
then `DASHSCOPE_API_KEY`, then `ALIBABA_API_KEY`.
|
||||
|
||||
The access region and inference scope differ: Virginia's `-us` model IDs request US-only inference;
|
||||
some regions select scope through their workspace. Model IDs pass through unchanged.
|
||||
Alibaba's [regional guide](https://www.alibabacloud.com/help/en/model-studio/regions) and
|
||||
[base URL table](https://www.alibabacloud.com/help/en/model-studio/base-url) disagree about Virginia's
|
||||
shared host; the entry above follows the base URL table. Dedicated hosts can be copied from the console.
|
||||
|
||||
### Native options
|
||||
|
||||
- **Chat:** `reasoningEffort` → `reasoning_effort`, `enableThinking` → `enable_thinking`,
|
||||
`thinkingBudget` → `thinking_budget`, and `preserveThinking` → `preserve_thinking`.
|
||||
Replay complete `response.message` values to retain `reasoning_content` separately from answer text.
|
||||
Qwen 3.8 defaults to preserving thinking; older models have different defaults.
|
||||
Additional options include `toolStream`, `parallelToolCalls`, `repetitionPenalty`, `responseFormat`,
|
||||
`enableSearch`, and native `searchOptions`. `generation.topK` lowers to `top_k`.
|
||||
`clearThinking` is a hosted GLM control, and `thinking.type` is available for hosted MiniMax models.
|
||||
- **Messages:** `effort` → `output_config.effort`. `thinking.type` accepts enabled/disabled with an
|
||||
optional `budgetTokens` (or native `budget_tokens`). `outputConfig.format` accepts a JSON schema.
|
||||
Model Studio's empty thinking signatures are accepted; supplied signatures are replayed unchanged.
|
||||
- **Responses:** `reasoningEffort` → `reasoning.effort`, plus `enableThinking`, `store`,
|
||||
`previousResponseId`, and `conversation`. Omitted `store` retains the API's default (`true`);
|
||||
set it to `false` for client-managed history. `previousResponseId` requires a stored response.
|
||||
Hosted tools are `Alibaba.webSearch()`, `Alibaba.webExtractor()`, and `Alibaba.codeInterpreter()`.
|
||||
Web extraction is used together with web search. Hosted calls/results carry `providerExecuted: true`.
|
||||
|
||||
Omitted options preserve provider defaults. Effort values pass through unchanged and accept future
|
||||
strings. Qwen 3.8 Chat rejects requests combining a thinking budget with effort.
|
||||
|
||||
Package entrypoints are `@opencode/ai/providers/alibaba`, `alibaba/chat`, `alibaba/messages`,
|
||||
and `alibaba/responses`. Live recordings cover all three APIs in Singapore; regional URL construction
|
||||
is unit-tested for all six regions.
|
||||
|
||||
## Z.AI
|
||||
|
||||
@@ -162,10 +338,9 @@ and `moonshot/responses`; each exports `model(modelID, settings)`.
|
||||
MiniMax defaults to its Messages API and reads `MINIMAX_API_KEY` when `apiKey` is omitted:
|
||||
|
||||
```ts
|
||||
import { Effect, Layer } from "effect"
|
||||
import { LLM, LLMClient } from "@opencode/ai"
|
||||
import { Effect } from "effect"
|
||||
import { AIClient, LLM } from "@opencode/ai"
|
||||
import { MiniMax } from "@opencode/ai/providers"
|
||||
import { RequestExecutor } from "@opencode/ai/route"
|
||||
|
||||
const minimax = MiniMax.configure({ apiKey: process.env.MINIMAX_API_KEY })
|
||||
const request = LLM.request({
|
||||
@@ -175,8 +350,7 @@ const request = LLM.request({
|
||||
generation: { maxTokens: 1536 },
|
||||
})
|
||||
|
||||
const layer = LLMClient.layer.pipe(Layer.provide(RequestExecutor.fetchLayer))
|
||||
const response = await Effect.runPromise(LLMClient.generate(request).pipe(Effect.provide(layer)))
|
||||
const response = await Effect.runPromise(LLM.generate(request).pipe(Effect.provide(AIClient.layer)))
|
||||
console.log(response.text)
|
||||
```
|
||||
|
||||
@@ -242,23 +416,25 @@ citations or separate result blocks. Retain `response.message` for either API's
|
||||
Use `Image.generate` for one-off generation or editing:
|
||||
|
||||
```ts
|
||||
import { Image, ImageInput } from "@opencode/ai"
|
||||
import { Image, Media } from "@opencode/ai"
|
||||
|
||||
const generation = Image.generate({
|
||||
model: meta.image("muse-image-1.0"),
|
||||
prompt: "A flat black square on a white background.",
|
||||
options: { n: 1, reasoningStrength: "low" },
|
||||
n: 1,
|
||||
providerOptions: { reasoningStrength: "low" },
|
||||
})
|
||||
|
||||
const edit = Image.generate({
|
||||
model: meta.image("muse-image-1.0"),
|
||||
prompt: "Make the square purple.",
|
||||
images: [ImageInput.bytes(imageBytes, "image/webp")],
|
||||
options: { outputFormat: "png", reasoningStrength: "low" },
|
||||
images: [Media.bytes(imageBytes, "image/webp")],
|
||||
format: "png",
|
||||
providerOptions: { reasoningStrength: "low" },
|
||||
})
|
||||
```
|
||||
|
||||
The default image format is WEBP; `outputFormat` also accepts PNG/JPEG and `responseFormat: "url"`
|
||||
The default image format is WEBP; `format` also accepts PNG/JPEG and `responseFormat: "url"`
|
||||
returns a signed URL. `size` is an aspect-ratio hint. For conversational images, select
|
||||
`meta.responses("muse-image-1.0")` with `tools: [Meta.imageGeneration({ reasoningStrength: "low" })]`.
|
||||
Generated images are provider-executed tool results with file content. Retain `response.message` to
|
||||
@@ -269,101 +445,128 @@ Meta Responses is explicitly HTTP/SSE-only and does not use WebSockets, even whe
|
||||
|
||||
## Image generation
|
||||
|
||||
Use `Image.generate` with an image model for direct asset generation:
|
||||
Use `Image.generate` with an image model for direct asset generation. `Image.request` mirrors `LLM.request`: the
|
||||
model comes from the facade's `.image(...)` selector (mirroring `.responses(...)`), common fields
|
||||
(`images`, `mask`, `n`, `size`, `aspectRatio`, `seed`, `format`) lower natively or fail typed, and
|
||||
`providerOptions` is inferred from the selected model:
|
||||
|
||||
```ts
|
||||
import { Image, ImageInput } from "@opencode/ai"
|
||||
import { Image, Media } from "@opencode/ai"
|
||||
import { OpenAI } from "@opencode/ai/providers"
|
||||
|
||||
const openai = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY })
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const response = yield* Image.generate({
|
||||
model: OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY }).image("gpt-image-2"),
|
||||
model: openai.image("gpt-image-2"),
|
||||
prompt: "A robot tending a rooftop garden",
|
||||
options: {
|
||||
n: 2,
|
||||
size: "1024x1024",
|
||||
n: 2,
|
||||
size: "1024x1024",
|
||||
format: "webp",
|
||||
providerOptions: {
|
||||
quality: "high", // inferred from the OpenAI image model
|
||||
outputFormat: "webp",
|
||||
future_option: true, // unknown native options pass through unchanged
|
||||
},
|
||||
})
|
||||
|
||||
return response.images // GeneratedImage[] with owned bytes or a provider URL
|
||||
return response.images // Media.Asset[] with owned bytes or a provider URL
|
||||
})
|
||||
```
|
||||
|
||||
Common fields are portable in shape, not in support. Unsupported fields fail with a typed `AIError` before any network
|
||||
call rather than being dropped, so check this table before swapping only the `model`:
|
||||
|
||||
| Provider | `n` | `size` | `aspectRatio` | `seed` | `format` | `images` | `mask` |
|
||||
| --------------------- | --- | --------- | ------------- | ------ | -------- | -------------------------------- | ------------------- |
|
||||
| OpenAI | ✓¹ | ✓ | ✗ | ✗ | ✓ | ✓ | ✓ |
|
||||
| Google (Gemini) | 1 | ✗ | ✓ | ✓ | ✗ | ✓ (no public URLs) | ✗ |
|
||||
| xAI | ✓ | ✗ | ✓ | ✗ | ✗ | ✓ | ✗ |
|
||||
| Z.ai | ✗ | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ |
|
||||
| Meta | ✓ | ✓ (hint) | ✗ | ✗ | ✓ | ✓ | ✗ |
|
||||
| Black Forest Labs | 1 | per model | per model | ✓ | ✓ | per model (1–8) | `flux-pro-1.0-fill` |
|
||||
| fal | ✓ | per model | per model | ✓ | ✓ | 1 (several on `/edit`, `/multi`) | ✓ |
|
||||
| Replicate | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ (use `providerOptions`) | ✗ |
|
||||
| Stability `image` | 1 | ✗ | ✓ | ✓ | ✓ | 1 (not on `core`) | ✗ |
|
||||
| Stability `upscale()` | ✗ | ✗ | ✗ | ✓ | ✓ | exactly 1 (required) | ✗ |
|
||||
|
||||
✓ lowers natively; ✗ fails whenever the field is set (including `n: 1`); `1` means `n > 1` fails. ¹ `Image.stream` on OpenAI generates one image. fal
|
||||
rejects `size` and `aspectRatio` together; which one a fal or BFL model takes depends on the model.
|
||||
|
||||
`Media.Asset` is the one asset type shared by image requests, image responses, LLM messages, and tool results.
|
||||
`asset.source` is the serializable `Media.Source` (`bytes`, `base64`, `url`, or `ref`); `asset.bytes()`,
|
||||
`asset.base64()`, and `asset.dataUrl()` decode or download lazily and cache; `asset.materialize()` pulls a `url`
|
||||
asset into owned bytes before the provider URL expires. Construct assets with `Media.bytes`, `Media.base64`,
|
||||
`Media.url`, `Media.ref(provider, id)`, `Media.fromDataUrl`, or `Media.file(path)`.
|
||||
|
||||
Pass ordered image inputs to the same method for editing, composition, or image-conditioned generation:
|
||||
|
||||
```ts
|
||||
const response =
|
||||
yield *
|
||||
Image.generate({
|
||||
const composed = Effect.gen(function* () {
|
||||
const response = yield* Image.generate({
|
||||
model,
|
||||
prompt: "Combine these product photos into one studio scene",
|
||||
images: [
|
||||
ImageInput.bytes(firstBytes, "image/png"),
|
||||
ImageInput.url("https://example.com/second.webp"),
|
||||
ImageInput.file("file_123"),
|
||||
Media.bytes(firstBytes, "image/png"),
|
||||
Media.url("https://example.com/second.webp"),
|
||||
Media.ref("openai", "file_123"),
|
||||
],
|
||||
options,
|
||||
providerOptions,
|
||||
http,
|
||||
})
|
||||
return response.images
|
||||
})
|
||||
```
|
||||
|
||||
`ImageInput.fileUri(uri, mediaType)` represents provider file URIs such as Gemini Files. Raw strings are not
|
||||
accepted as image inputs, avoiding ambiguity between base64, URLs, and provider IDs. Empty or omitted `images`
|
||||
uses text-to-image generation; a non-empty array selects the provider's edit behavior without enforcing provider
|
||||
image-count limits locally. `images` is the only common image-editing field. OpenAI uses multipart for byte/data-URL
|
||||
edits and its JSON reference body for URL or file-ID edits. Its provider-specific `options.mask` accepts an
|
||||
`ImageInput` for inpainting:
|
||||
`Media.ref(provider, id)` represents provider file handles such as OpenAI file IDs or Gemini Files URIs; routes
|
||||
only forward refs that belong to their own provider (OpenAI, xAI, and Gemini images accept them). No shipped route
|
||||
returns a ref yet, and `asset.bytes()` / `materialize()` on a ref fail by design. Raw strings are not accepted as
|
||||
image inputs, avoiding ambiguity between base64, URLs, and provider IDs. Empty or omitted `images` uses text-to-image generation; a
|
||||
non-empty array selects the provider's edit behavior (see the table above for routes that limit the count). OpenAI
|
||||
uses multipart for byte/data-URL edits and its JSON reference body for URL or file-ID edits. The common `mask`
|
||||
field selects inpainting; routes that cannot honor it fail with `UnsupportedOperation`:
|
||||
|
||||
```ts
|
||||
yield *
|
||||
Image.generate({
|
||||
model: OpenAI.configure({ apiKey }).image("gpt-image-2"),
|
||||
prompt,
|
||||
images: [ImageInput.bytes(sourceBytes, "image/png")],
|
||||
options: { mask: ImageInput.bytes(maskBytes, "image/png") },
|
||||
})
|
||||
const inpainted = Image.generate({
|
||||
model: openai.image("gpt-image-2"),
|
||||
prompt,
|
||||
images: [Media.bytes(sourceBytes, "image/png")],
|
||||
mask: Media.bytes(maskBytes, "image/png"),
|
||||
})
|
||||
```
|
||||
|
||||
The OpenAI adapter extracts this helper value into the edit request's native `mask` field rather than passing the
|
||||
tagged `ImageInput` object through as an ordinary option. On multipart requests, `http.body` can override option
|
||||
fields but not structural `model`, `prompt`, `image[]`, or `mask` fields, and the transport owns the multipart
|
||||
`Content-Type` boundary. For JSON requests, `http.body` remains the final raw-native overlay. Gemini does not fetch
|
||||
public HTTP URLs, and hosted Z.ai image generation does not accept image inputs. These cases fail with
|
||||
`InvalidRequest` before network I/O.
|
||||
On multipart requests, `http.body` can override option fields but not structural `model`, `prompt`, `image[]`,
|
||||
or `mask` fields, and the transport owns the multipart `Content-Type` boundary. For JSON requests, `http.body`
|
||||
remains the final raw-native overlay. Gemini does not fetch public HTTP URLs, and hosted Z.ai image generation does
|
||||
not accept image inputs. These cases fail with a typed `AIError` before network I/O.
|
||||
|
||||
Provider-native image options belong to each request. Raw `http.body` fields have final precedence over them:
|
||||
|
||||
```ts
|
||||
const model = OpenAI.configure({ apiKey }).image("gpt-image-2")
|
||||
|
||||
yield *
|
||||
Image.generate({
|
||||
model,
|
||||
prompt,
|
||||
options: { quality: "medium" },
|
||||
http,
|
||||
})
|
||||
const medium = Image.generate({
|
||||
model: openai.image("gpt-image-2"),
|
||||
prompt,
|
||||
providerOptions: { quality: "medium" },
|
||||
http,
|
||||
})
|
||||
```
|
||||
|
||||
xAI image models use the same request API with xAI-native controls:
|
||||
|
||||
```ts
|
||||
yield *
|
||||
Image.generate({
|
||||
model: XAI.configure({ apiKey }).image("any-model-id"),
|
||||
prompt,
|
||||
options: {
|
||||
n: 2,
|
||||
aspectRatio: "16:9",
|
||||
resolution: "1k",
|
||||
responseFormat: "b64_json",
|
||||
future_option: true,
|
||||
},
|
||||
http,
|
||||
})
|
||||
import { XAI } from "@opencode/ai/providers"
|
||||
|
||||
const xai = Image.generate({
|
||||
model: XAI.configure({ apiKey }).image("any-model-id"),
|
||||
prompt,
|
||||
n: 2,
|
||||
aspectRatio: "16:9",
|
||||
providerOptions: {
|
||||
resolution: "1k",
|
||||
responseFormat: "b64_json",
|
||||
future_option: true,
|
||||
},
|
||||
http,
|
||||
})
|
||||
```
|
||||
|
||||
Google's current Gemini image models use the same direct API:
|
||||
@@ -375,10 +578,10 @@ const googleProgram = Effect.gen(function* () {
|
||||
const response = yield* Image.generate({
|
||||
model: Google.configure({ apiKey }).image("any-model-id"),
|
||||
prompt: "A robot tending a rooftop garden",
|
||||
options: {
|
||||
aspectRatio: "16:9",
|
||||
aspectRatio: "16:9",
|
||||
seed: 42,
|
||||
providerOptions: {
|
||||
imageSize: "2K",
|
||||
seed: 42,
|
||||
thinkingLevel: "HIGH",
|
||||
includeThoughts: true,
|
||||
futureOption: true,
|
||||
@@ -398,22 +601,87 @@ their mapped aliases, and `http.body` is the final deep overlay. The selected mo
|
||||
Z.ai image models infer open Z.ai-native options from the selected model:
|
||||
|
||||
```ts
|
||||
yield *
|
||||
Image.generate({
|
||||
model: ZAI.configure({ apiKey }).image("any-model-id"),
|
||||
prompt,
|
||||
options: {
|
||||
quality: "hd",
|
||||
userID: "user-123",
|
||||
future_option: true,
|
||||
},
|
||||
http,
|
||||
})
|
||||
import { ZAI } from "@opencode/ai/providers"
|
||||
|
||||
const zai = Image.generate({
|
||||
model: ZAI.configure({ apiKey }).image("any-model-id"),
|
||||
prompt,
|
||||
providerOptions: {
|
||||
quality: "hd",
|
||||
userID: "user-123",
|
||||
future_option: true,
|
||||
},
|
||||
http,
|
||||
})
|
||||
```
|
||||
|
||||
Z.ai does not include trustworthy MIME metadata for output URLs, so generated images use
|
||||
`application/octet-stream`. Output URLs expire after 30 days; download and persist them promptly if they must
|
||||
remain available.
|
||||
`application/octet-stream` until materialized. Output URLs expire after 30 days; call `asset.materialize()` and
|
||||
persist the bytes promptly if they must remain available.
|
||||
|
||||
### Partial images
|
||||
|
||||
OpenAI's GPT image models stream previews. `Image.stream` sends `stream: true` with `partialImages` (0–3, default 2)
|
||||
and emits `image-partial` events before each final `image`; `Image.generate` keeps the plain JSON request:
|
||||
|
||||
```ts
|
||||
import { Stream } from "effect"
|
||||
import { ImageEvent } from "@opencode/ai"
|
||||
|
||||
const previews = Image.stream({
|
||||
model: openai.image("gpt-image-2"),
|
||||
prompt: "A lighthouse at dusk",
|
||||
providerOptions: { partialImages: 2 },
|
||||
}).pipe(Stream.runForEach((event) => (ImageEvent.is.imagePartial(event) ? showPreview(event.image) : Effect.void)))
|
||||
```
|
||||
|
||||
The provider may send fewer previews than requested when the final image is ready first.
|
||||
|
||||
### Queued image providers
|
||||
|
||||
Black Forest Labs, fal, Replicate, and Stability's creative upscaler are submit-then-poll routes. `Image.generate`
|
||||
and `Image.stream` poll for you (pass `{ poll }` to tune the interval and timeout); `Image.start` returns a
|
||||
`Generation` whose `token` is serializable JSON for `Image.resume` in another process:
|
||||
|
||||
```ts
|
||||
import { BlackForestLabs, Stability } from "@opencode/ai/providers"
|
||||
|
||||
const bfl = BlackForestLabs.configure({ apiKey: process.env.BFL_API_KEY })
|
||||
|
||||
const submit = Effect.gen(function* () {
|
||||
const generation = yield* Image.start({ model: bfl.image("flux-2-pro"), prompt, size: "1024x768" })
|
||||
persist({ provider: "black-forest-labs", modelID: "flux-2-pro", token: generation.token })
|
||||
})
|
||||
|
||||
const finish = Effect.gen(function* () {
|
||||
const saved = load()
|
||||
const resumed = yield* Image.resume(bfl.image(saved.modelID), saved.token)
|
||||
return yield* resumed.await({ poll: { interval: "2 seconds" } })
|
||||
})
|
||||
```
|
||||
|
||||
The token carries no route identity, so persist the provider and model ID alongside it: `resume` needs the model.
|
||||
|
||||
- **Black Forest Labs** — results are downloaded before returning, because `result.sample` expires in 10 minutes.
|
||||
- **Replicate** — inputs are model-defined, so only `prompt` lowers: sizing, count, seed, format, and files go in
|
||||
`providerOptions` under the model's names, with files as `Media.Asset` (data URLs up to 256 KB, larger by URL).
|
||||
Outputs are removed an hour after the prediction completes. `Prefer: wait=60` in `headers` or `http.headers` holds
|
||||
the submission open so a fast prediction costs one result read.
|
||||
- **Stability** — `stability.image(id)` generates inline; `stability.upscale()` is the creative upscaler, queued:
|
||||
|
||||
```ts
|
||||
const stability = Stability.configure({ apiKey: process.env.STABILITY_API_KEY })
|
||||
const upscaled = Effect.gen(function* () {
|
||||
const small = yield* Media.file("./small.png")
|
||||
return yield* Image.generate(
|
||||
{ model: stability.upscale(), prompt: "A lighthouse", images: [small] },
|
||||
{ poll: { interval: "5 seconds" } },
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
Imagen is not available: Google shut it down on the Gemini API, and Vertex discontinued the Imagen 4 models on
|
||||
2026-06-30. `Google.image(...)` uses Gemini-native image models.
|
||||
|
||||
Conversational image generation remains part of the LLM interaction. OpenAI Responses exposes it through its hosted image tool:
|
||||
|
||||
@@ -431,17 +699,268 @@ const program = Effect.gen(function* () {
|
||||
})
|
||||
```
|
||||
|
||||
The hosted result is represented as a provider-executed tool call and tool result. Its image is a `file` content item with a data URI, so retaining `response.message` preserves the generated image for continuation.
|
||||
The hosted result is represented as a provider-executed tool call and a tool result whose content carries the generated image as a file. Gemini image-capable models instead emit a first-class `media` `LLMEvent` for inline image output (`response.message` then carries a `media` part). Retaining `response.message` preserves the generated image for continuation on both routes.
|
||||
|
||||
## Video generation
|
||||
|
||||
Video mirrors `Image` with one difference: every provider is asynchronous, so the route is a submit-then-poll
|
||||
`Generation`. Models come from `.video(...)` selectors on the `Google` (Veo), `XAI`, `Fal`, and `Runway` facades.
|
||||
Common fields (`frames`, `references`, `video`, `durationSeconds`, `aspectRatio`, `resolution`, `audio`, `n`, `seed`,
|
||||
`negativePrompt`) lower natively or fail with a typed `AIError` before any network call; provider-native controls live
|
||||
under `providerOptions`, inferred from the selected model.
|
||||
|
||||
```ts
|
||||
import { Video } from "@opencode/ai"
|
||||
import { Google, Runway } from "@opencode/ai/providers"
|
||||
|
||||
const google = Google.configure({ apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY })
|
||||
|
||||
// Simple: submit and wait.
|
||||
const program = Effect.gen(function* () {
|
||||
const response = yield* Video.generate(
|
||||
{
|
||||
model: google.video("veo-3.1-generate-preview"),
|
||||
prompt: "Panning wide shot of a calico kitten sleeping in the sunshine",
|
||||
aspectRatio: "16:9",
|
||||
resolution: "1080p",
|
||||
durationSeconds: 8,
|
||||
providerOptions: { personGeneration: "allow_adult" },
|
||||
},
|
||||
{ poll: { interval: "10 seconds", timeout: "10 minutes" } },
|
||||
)
|
||||
// Veo serves files for two days behind the API key. The asset knows the deadline (`expiresAt`) and carries the
|
||||
// download credentials only on the live instance (`asset.headers`), never in `source` or JSON: materialize
|
||||
// before persisting, or the persisted URL cannot be fetched again.
|
||||
return yield* response.video.materialize()
|
||||
})
|
||||
|
||||
// Explicit control: keep the handle, persist the token, resume elsewhere.
|
||||
const controlled = Effect.gen(function* () {
|
||||
const generation = yield* Video.start({ model: google.video("veo-3.1-generate-preview"), prompt })
|
||||
generation.id // provider operation / task / request id
|
||||
generation.status // "queued" | "running" | "completed" | "failed" | "cancelled" | "expired"
|
||||
generation.token // route-owned JSON: `{ operation }`, `{ requestID }`, `{ taskID }`, or fal's follow-up URLs
|
||||
// The token carries no route identity: persist the provider and model ID alongside it, since `resume` needs the model.
|
||||
const saved = JSON.stringify(generation.token)
|
||||
|
||||
const resumed = yield* Video.resume(google.video("veo-3.1-generate-preview"), JSON.parse(saved))
|
||||
return yield* resumed.await({ poll: { interval: "10 seconds" } })
|
||||
})
|
||||
|
||||
// Progress as a stream: generation-queued | generation-progress | video | finish.
|
||||
const events = Video.stream({ model: Runway.configure({ apiKey }).video("gen4.5"), prompt }, { poll })
|
||||
```
|
||||
|
||||
Status polls, result fetches, cancels, and asset downloads all run through the same request executor with the route's
|
||||
auth. `Generation.await` and `Generation.events` fail with a
|
||||
`Timeout` reason when `poll.timeout` (default 10 minutes) elapses. Status polls and result fetches retry transient
|
||||
failures (rate limits, provider 5xx, network errors) with backoff that honors `retry-after`, always within
|
||||
`poll.timeout`; submits and cancels never retry. Interrupting a wait (or aborting its `signal`) does not cancel the
|
||||
provider job, which keeps running and billing: call `cancel()` to stop it. Failed,
|
||||
cancelled, and expired generations fail typed with the provider's terminal document on `reason.body`; moderation
|
||||
outcomes (Veo `raiMediaFilteredReasons`, xAI `respect_moderation`, Runway `SAFETY.*` codes) surface as `notices` when
|
||||
a video is still returned and as a `ContentPolicy` reason when nothing is.
|
||||
|
||||
Provider notes:
|
||||
|
||||
- **Google Veo** takes inline bytes only (materialize `url` assets first); `frames.last` requires `frames.first`;
|
||||
audio is always on, so `audio: false` fails typed; one video per request. Output URLs need the API key to
|
||||
download, which the returned asset holds transiently (see above).
|
||||
- **xAI** sends a `video` input to `/videos/edits`, or `/videos/extensions` with `providerOptions.mode: "extend"`.
|
||||
`seed` and `negativePrompt` are not supported.
|
||||
- **fal** endpoints are model-specific: `durationSeconds`, `references`, and `frames.last` fail typed and belong in
|
||||
`providerOptions` under the model's own names (`duration: "8s"`, `end_image_url`, …). Auth is
|
||||
`Authorization: Key <FAL_KEY>`.
|
||||
- **Runway** expects pixel ratios in `aspectRatio` for most models (`"1280:720"`), pins `X-Runway-Version`, reports
|
||||
`usage: { type: "credits" }`, and its output URLs expire after 24–48 hours.
|
||||
|
||||
The promise client exposes the same surface: `ai.video.start(...)` resolves to a handle with `await`, `events`,
|
||||
`result`, `refresh`, `cancel`, and `token`; `ai.video.generate`, `ai.video.resume(model, token)`, and
|
||||
`ai.video.stream` mirror the Effect API. The handle's `status` and `progress` are a snapshot from when it was
|
||||
created; `refresh()` resolves to a new handle. Every promise method and stream accepts `{ signal }`: like `fetch`,
|
||||
aborting rejects the Promise or throws from the `for await` loop with `signal.reason` (an `AbortError` `DOMException`
|
||||
unless `abort(reason)` passed one), while `break` stops a stream without throwing.
|
||||
|
||||
```ts
|
||||
import { ai } from "@opencode/ai/promise"
|
||||
|
||||
const generation = await ai.video.start({ model, prompt })
|
||||
for await (const event of generation.events({ poll: { interval: 10_000 } })) console.log(event.type)
|
||||
const video = await generation.result({ signal })
|
||||
await ai.write(video.video, "./kite.mp4")
|
||||
```
|
||||
|
||||
## Speech generation
|
||||
|
||||
Speech (text-to-speech) is one request whose response is parsed incrementally, so every route supports both
|
||||
`Speech.generate` (the whole file) and `Speech.stream` (audio chunks as they arrive). Models come from `.speech(...)`
|
||||
selectors on the `OpenAI`, `Google` (Gemini TTS), `ElevenLabs`, `Cartesia`, and `Deepgram` facades. Common fields
|
||||
(`voice`, `format`, `speed`, `language`, `instructions`, `timestamps`) lower natively or fail with a typed `AIError`
|
||||
before any network call; provider-native controls live under `providerOptions`, inferred from the selected model.
|
||||
|
||||
```ts
|
||||
import { Media, Speech, SpeechClient, SpeechEvent } from "@opencode/ai"
|
||||
import { ElevenLabs, OpenAI } from "@opencode/ai/providers"
|
||||
|
||||
const openai = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY })
|
||||
|
||||
// The whole file, written to disk.
|
||||
const program = Effect.gen(function* () {
|
||||
const response = yield* Speech.generate({
|
||||
model: openai.speech("gpt-4o-mini-tts"),
|
||||
text: "Hello from OpenCode.",
|
||||
voice: "coral",
|
||||
format: "mp3",
|
||||
instructions: "Warm and unhurried.",
|
||||
})
|
||||
response.audio // Media.Asset with bytes; headerless PCM carries info.encoding / sampleRate / channels
|
||||
response.usage // undefined: OpenAI reports tokens only on SSE streams (Gemini: tokens; ElevenLabs: credits; Deepgram: characters)
|
||||
yield* Media.write(response.audio, "hello.mp3")
|
||||
})
|
||||
|
||||
// Chunks as they arrive: audio-delta* (interleaved with timestamps) then one finish carrying the assembled asset.
|
||||
const events = Speech.stream({
|
||||
model: ElevenLabs.configure({ apiKey }).speech("eleven_flash_v2_5"),
|
||||
text: "Hello from OpenCode.",
|
||||
voice: "JBFqnCBsd6RMkjVDRZzb",
|
||||
format: "pcm",
|
||||
timestamps: true,
|
||||
}).pipe(
|
||||
Stream.tap((event) => {
|
||||
if (SpeechEvent.is.audioDelta(event)) return play(event.chunk)
|
||||
if (SpeechEvent.is.timestamps(event)) return highlight(event.items) // { text, startSeconds, endSeconds }[]
|
||||
return Effect.void
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
`voice` is the provider's own identifier — a name on OpenAI and Gemini (`"coral"`, `"Kore"`), a voice id on
|
||||
ElevenLabs and Cartesia. `{ id }` selects an OpenAI custom voice (`{ id: "voice_1234" }`) and means the same as the
|
||||
plain string elsewhere. There is no cross-provider voice catalog. `format` is the container-level word (`mp3`, `wav`,
|
||||
`pcm`, `opus`, `aac`, `flac`); sample rates and bitrates live under `providerOptions`, and a value the route cannot
|
||||
produce fails as `UnsupportedOperation`. Streams buffer every chunk so `finish` can carry the whole clip.
|
||||
|
||||
Provider notes:
|
||||
|
||||
- **OpenAI** streams over SSE (`stream_format: "sse"`), which is also the only place it reports token usage; `tts-1`
|
||||
and `tts-1-hd` do not support SSE and stream the raw audio body instead. `pcm` is 24 kHz 16-bit mono. `language`
|
||||
and `timestamps` are not supported.
|
||||
- **Gemini TTS** returns the provider's default output: WAV for Gemini 3.8 TTS `generate`, raw 16-bit PCM
|
||||
(`audio/L16;codec=pcm;rate=24000`) otherwise. `pcm` is the only explicit `format` it accepts, and it fails typed on
|
||||
Gemini 3.8 `generate`; the route never wraps PCM as WAV. Style is directed in the text, so `instructions` and
|
||||
`speed` fail typed. Only `gemini-3.1-flash-tts-preview` and later support streaming. Two-speaker audio goes through
|
||||
`providerOptions.speechConfig.multiSpeakerVoiceConfig`.
|
||||
- **ElevenLabs** requires `voice` (the path voice id) and authenticates with `xi-api-key`. `format` maps to the
|
||||
`output_format` query parameter (`mp3_44100_128`, `pcm_24000`, `wav_24000`, `opus_48000_64`);
|
||||
`providerOptions.outputFormat` sets the exact string. WAV is only available from `generate`. `timestamps: true`
|
||||
selects the `with-timestamps` endpoints and yields character-level alignment. `instructions` is not supported.
|
||||
- **Cartesia** requires `voice` and pins `Cartesia-Version`. `generate` defaults to MP3 from `/tts/bytes`; streams
|
||||
and `timestamps: true` (word-level) use `/tts/sse`, which only serves raw PCM. `providerOptions.sampleRate`,
|
||||
`bitRate`, and `encoding` complete `output_format`. No usage is reported.
|
||||
- **Deepgram** Aura's voice is the model id (`aura-2-thalia-en`), so `voice` and `language` fail typed. `format`
|
||||
and `providerOptions` lower to query parameters (`encoding`, `container`, `sample_rate`, `bit_rate`); `pcm` is
|
||||
`linear16` without a container. Auth is `Authorization: Token <DEEPGRAM_API_KEY>`.
|
||||
|
||||
The promise client mirrors the Effect API; `ai.speech.stream` is an `AsyncIterable`.
|
||||
|
||||
```ts
|
||||
import { ai } from "@opencode/ai/promise"
|
||||
|
||||
const response = await ai.speech.generate({ model, text: "Hello from OpenCode.", voice: "coral" })
|
||||
await ai.write(response.audio, "hello.mp3")
|
||||
|
||||
for await (const event of ai.speech.stream({ model, text: "Hello from OpenCode.", voice: "coral" })) {
|
||||
if (event.type === "audio-delta") player.write(event.chunk)
|
||||
}
|
||||
```
|
||||
|
||||
## Transcription
|
||||
|
||||
Transcription (speech-to-text) is the one modality whose providers use every route kind: OpenAI and Gemini stream,
|
||||
Deepgram and ElevenLabs answer inline, and AssemblyAI is queued. `Transcription.generate` and `Transcription.stream`
|
||||
work on all of them; `Transcription.start` / `resume` return a `Generation` on queued routes and fail with
|
||||
`UnsupportedOperation` elsewhere. Models come from `.transcription(...)` selectors on the `OpenAI`, `Google`,
|
||||
`Deepgram`, `ElevenLabs`, and `AssemblyAI` facades. Common fields (`language`, `prompt`,
|
||||
`timestamps: "none" | "segment" | "word"`, `diarize`, `speakers`) lower natively or fail with a typed `AIError` before
|
||||
any network call; a route may return more than asked.
|
||||
|
||||
```ts
|
||||
import { Console, Effect, Stream } from "effect"
|
||||
import { Media, Transcription, TranscriptionEvent } from "@opencode/ai"
|
||||
import { AssemblyAI, Deepgram, OpenAI } from "@opencode/ai/providers"
|
||||
|
||||
const openai = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY })
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const audio = yield* Media.file("./call.mp3")
|
||||
|
||||
// Speaker-labelled segments; labels are provider-native strings ("A", "0", "spk:0", "speaker_0").
|
||||
const response = yield* Transcription.generate({
|
||||
model: Deepgram.configure({ apiKey }).transcription("nova-3"),
|
||||
audio,
|
||||
diarize: true,
|
||||
timestamps: "word",
|
||||
})
|
||||
response.text // "Hello from OpenCode."
|
||||
response.segments // [{ text, startSeconds, endSeconds, speaker: "0" }]
|
||||
response.words // [{ text, startSeconds, endSeconds, speaker, confidence }]
|
||||
response.language // the provider's own value, lowercased ("en", "eng", "english", "en_us")
|
||||
|
||||
// Text deltas as the model transcribes, then one finish carrying the whole transcript.
|
||||
yield* Transcription.stream({ model: openai.transcription("gpt-4o-mini-transcribe"), audio }).pipe(
|
||||
Stream.tap((event) => (TranscriptionEvent.is.textDelta(event) ? Console.log(event.delta) : Effect.void)),
|
||||
Stream.runDrain,
|
||||
)
|
||||
|
||||
// Queued: persist the token with the provider and model ID (the token alone cannot pick the model), resume, and await.
|
||||
const model = AssemblyAI.configure({ apiKey }).transcription("universal-3-5-pro")
|
||||
const generation = yield* Transcription.start({ model, audio })
|
||||
const resumed = yield* Transcription.resume(model, JSON.parse(JSON.stringify(generation.token)))
|
||||
const transcript = yield* resumed.await({ poll: { interval: "3 seconds" } })
|
||||
})
|
||||
```
|
||||
|
||||
Inline routes emit only `finish` from `stream` (no faked deltas); queued routes emit `generation-queued` /
|
||||
`generation-progress` before it.
|
||||
|
||||
Provider notes:
|
||||
|
||||
- **OpenAI** takes inline audio only; `diarize` needs `gpt-4o-transcribe-diarize`, timestamps need `whisper-1`, and `whisper-1` does not stream.
|
||||
- **Gemini** needs a transcribe model (`gemini-3.5-transcribe`); `prompt` and `speakers` fail typed.
|
||||
- **Deepgram** detects the language unless `language` is set; vocabulary goes in `providerOptions.keyterm`.
|
||||
- **ElevenLabs** (`scribe_v2`) uploads inline audio as the multipart `file` and sends a URL as `source_url`. Words
|
||||
always carry timestamps, and segments are speaker turns, so `diarize`, `timestamps: "segment"`, or `speakers` turns
|
||||
on diarization. `speakers` is an upper bound (`num_speakers`); `prompt` fails typed (vocabulary goes in
|
||||
`providerOptions.keyterms`), as do webhook delivery and per-channel output (`use_multi_channel` without
|
||||
`multichannel_output_style: "combined"`).
|
||||
- **AssemblyAI** uploads inline audio before submitting and treats `speakers` as the exact speaker count.
|
||||
|
||||
The promise client mirrors the Effect API:
|
||||
|
||||
```ts
|
||||
const audio = await ai.file("./call.mp3")
|
||||
const text = (await ai.transcription.generate({ model, audio })).text
|
||||
for await (const event of ai.transcription.stream({ model, audio })) if (event.type === "text-delta") write(event.delta)
|
||||
const generation = await ai.transcription.start({ model: assemblyai, audio })
|
||||
const transcript = await generation.await({ poll: { interval: 3_000 } })
|
||||
```
|
||||
|
||||
## Public API
|
||||
|
||||
- **`LLM.request({...})`** — build a provider-neutral `LLMRequest`. Accepts ergonomic inputs (`system: string`, `prompt: string`) that normalize into the canonical Schema classes.
|
||||
- **`LLM.generate` / `LLM.stream`** — re-exported from `LLMClient` for one-import use.
|
||||
- **`LLM.generate` / `LLM.stream`** — run direct input or an `LLMRequest` through `LLMClient` for one-import use.
|
||||
- **`Message.user(...)` / `Message.assistant(...)` / `Message.tool(...)`** — message constructors from the canonical schema model.
|
||||
- **`LanguageModel.make(...)` / `ToolCallPart.make(...)` / `ToolResultPart.make(...)` / `ToolDefinition.make(...)`** — model and tool-related constructors from the canonical schema model.
|
||||
- **`LLMEvent.is.*`** — typed guards (`is.textDelta`, `is.toolCall`, `is.finish`, …) for filtering streams.
|
||||
- **`Image.generate({...})`** — generate images through a provider-neutral image request and response model.
|
||||
- **`Image.request` / `generate` / `stream` / `start` / `resume`** — images over inline, streaming (partial previews), and queued routes through a provider-neutral request and response model.
|
||||
- **`ImageClient`** — Effect service and layer for image execution, parallel to `LLMClient`.
|
||||
- **`Media`** — the shared asset type (`Media.Asset`, `Media.Source`) and constructors used by messages, tool results, and media requests.
|
||||
- **`Generation`** — provider-neutral handle for an in-flight media generation (`await`, `refresh`, `cancel`, `events`) used by queued media routes.
|
||||
- **`Video.request` / `generate` / `stream` / `start` / `resume`** — queued video generation through a provider-neutral request; `VideoClient` is its Effect service and layer.
|
||||
- **`Speech.request` / `Speech.generate` / `Speech.stream`** — text-to-speech through a provider-neutral request; `SpeechClient` is its Effect service and layer.
|
||||
- **`Transcription.request` / `generate` / `stream` / `start` / `resume`** — speech-to-text over inline, streaming, and queued routes; `TranscriptionClient` is its Effect service and layer.
|
||||
- **`AIClient.layer` / `AIClient.layerWith(executor)`** — every modality client plus the request executor in one layer.
|
||||
- **`@opencode/ai/promise`** — `AI.make({ layer? })` and a default `ai` client exposing `llm`, `image`, `video`, `speech`, and `transcription` as Promise / `AsyncIterable` APIs, plus `file`, `write`, `bytes`, `base64`, and `materialize` for assets.
|
||||
|
||||
## Testing
|
||||
|
||||
@@ -504,11 +1023,13 @@ This is different from prompt caching, server-side history storage, or truncatio
|
||||
Prefer this operation, where supported, when the application owns compaction policy and durable context updates.
|
||||
|
||||
```ts
|
||||
const result = yield * LLMClient.compact(request)
|
||||
const next = LLMRequest.update(request, {
|
||||
messages: result.replacement,
|
||||
const compacted = Effect.gen(function* () {
|
||||
const result = yield* LLMClient.compact(request)
|
||||
const next = LLMRequest.update(request, {
|
||||
messages: result.replacement,
|
||||
})
|
||||
return yield* LLMClient.generate(next)
|
||||
})
|
||||
const response = yield * LLMClient.generate(next)
|
||||
```
|
||||
|
||||
`replacement` replaces the complete input window. Do not append it to the original transcript or extract only the encrypted item: the provider may retain additional messages in its output. Retained user and assistant messages remain ordinary messages with typed text, media, or reasoning parts, in their original order. Provider-specific message IDs, status, and phase use `providerMetadata`, not a raw output array hidden in an assistant message. Unsupported returned item types fail explicitly.
|
||||
@@ -524,16 +1045,16 @@ The input must still fit the model's context window. Explicit compaction is not
|
||||
OpenAI Responses also exposes a separate, explicitly selected mechanism:
|
||||
|
||||
```ts
|
||||
const result =
|
||||
yield *
|
||||
LLMClient.compact(request, {
|
||||
const checkpoint = Effect.gen(function* () {
|
||||
const result = yield* LLMClient.compact(request, {
|
||||
mechanism: "trigger",
|
||||
webSocket, // Optional: without it, the request uses HTTP/SSE.
|
||||
})
|
||||
|
||||
result.checkpoint // Successful encrypted CompactionPart.
|
||||
result.responseID
|
||||
result.usage
|
||||
result.checkpoint // Successful encrypted CompactionPart.
|
||||
result.responseID
|
||||
result.usage
|
||||
})
|
||||
```
|
||||
|
||||
This appends a native `compaction_trigger` control item to the full input and sends a normal Responses request, with tools and instructions retained, `stream: true`, `store: false`, and parallel tool calls enabled. It removes normal-answer text/output-format controls, forced tool choices, output-token/tool-call limits, and automatic `context_management`. Body overlays cannot replace `input` or supply `previous_response_id`/`conversation`; the complete canonical history is required for safe stateless replay. Request metadata, auth, headers, query parameters, service tier, and supported prompt-cache settings are preserved.
|
||||
@@ -547,9 +1068,11 @@ The supplied WebSocket executor can reuse a compatible append baseline for the c
|
||||
Trigger support is separate from endpoint support. Only the OpenAI Responses route advertises it; Azure, xAI, Chat, and compatible Responses routes do not inherit it. Untyped calls still fail before sending: missing route capabilities return `UnsupportedOperation`, while unknown mechanism names and invalid inputs return `InvalidRequest`. Dynamic callers must narrow for the selected mechanism:
|
||||
|
||||
```ts
|
||||
if (LLMClient.canCompact(request, { mechanism: "trigger" })) {
|
||||
const result = yield * LLMClient.compact(request, { mechanism: "trigger" })
|
||||
}
|
||||
const narrowed = Effect.gen(function* () {
|
||||
if (LLMClient.canCompact(request, { mechanism: "trigger" })) {
|
||||
const result = yield* LLMClient.compact(request, { mechanism: "trigger" })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
This capability describes protocol implementation, **not universal availability on OpenAI API deployments**. The host application owns subscription/deployment eligibility, OAuth, endpoint selection, and deployment-specific headers. Local protocol/socket tests do not establish live provider support.
|
||||
@@ -558,9 +1081,10 @@ This capability describes protocol implementation, **not universal availability
|
||||
|
||||
`providerOptions.contextManagement` lets the provider decide when to compact during an ordinary `generate` or `stream` call. This is an advanced option for callers that own persistence and recovery: persist the complete assistant message, including its checkpoint, before continuing. Enabling the option does not provide durable checkpoint storage, interruption recovery, or model-switch policy. Keep the prior context until a successful checkpoint has been persisted.
|
||||
|
||||
Inside an `Effect.gen`, enable OpenAI compaction with typed provider options:
|
||||
Enable OpenAI compaction with typed provider options:
|
||||
|
||||
```ts
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMClient, LLMRequest, Message } from "@opencode/ai"
|
||||
import { OpenAI } from "@opencode/ai/providers"
|
||||
|
||||
@@ -571,9 +1095,11 @@ const request = LLM.request({
|
||||
contextManagement: [{ type: "compaction", compactThreshold: 200_000 }],
|
||||
},
|
||||
})
|
||||
const response = yield * LLMClient.generate(request)
|
||||
const next = LLMRequest.update(request, {
|
||||
messages: [...request.messages, response.message, Message.user("Continue")],
|
||||
const continued = Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request)
|
||||
return LLMRequest.update(request, {
|
||||
messages: [...request.messages, response.message, Message.user("Continue")],
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
@@ -801,7 +1327,7 @@ Compose a route with `Route.make({ protocol, endpoint, auth, framing, ... })`. T
|
||||
|
||||
## Effect
|
||||
|
||||
This package is built on Effect. Public methods return `Effect` or `Stream`; provide `LLMClient.layer` for LLM dispatch and `ImageClient.layer` for image dispatch, then import the provider/protocol modules for the routes you use. The example at `example/tutorial.ts` is a runnable walkthrough.
|
||||
This package is built on Effect. Public methods return `Effect` or `Stream`; provide `AIClient.layer` (or `AIClient.layerWith(executor)`) for every modality, then import the provider/protocol modules for the routes you use. The example at `example/tutorial.ts` is a runnable walkthrough.
|
||||
|
||||
## See also
|
||||
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
# Media generation in `@opencode/ai` — public API direction
|
||||
|
||||
Status: phases 1–4 implemented (through Image queued routes and partial images); phase 5 proposal.
|
||||
|
||||
## Goal
|
||||
|
||||
`@opencode/ai` becomes the one package you reach for to generate anything: text, images, video, speech, transcripts, and later music and realtime. The LLM surface already exists and is shaped by three constraints: Effect-first, used by OpenCode Core, usable externally. Media has a different priority order: **external DX first**, Effect and Promise as peers, Core as one consumer among many.
|
||||
|
||||
The design below is derived from a survey of the raw provider APIs (OpenAI, Gemini/Veo/Imagen, xAI, Stability, BFL, fal, Replicate, Runway, Luma, Kling, MiniMax, ElevenLabs, Deepgram, Cartesia, AssemblyAI, Lyria) and of existing multi-provider SDKs.
|
||||
|
||||
## What the survey forces
|
||||
|
||||
1. **Three execution shapes, everywhere.** Inline sync (OpenAI images, all TTS, Gemini), async job with polling or webhook (every video provider, BFL, fal, Replicate, AssemblyAI), and bidirectional streams (ElevenLabs/Cartesia/Deepgram WS, realtime). Video has no sync provider at all.
|
||||
2. **Output is never just bytes.** base64, signed URLs with TTLs from 10 minutes (BFL, so its route downloads before returning via `PollContext.materialize`) to 2 days (Veo), URLs that need auth plus redirect (Veo), separate download endpoints (Sora `/content?variant=`), raw bodies (Stability, TTS). Multi-output is the norm.
|
||||
3. **Inputs have roles.** First/last frame, mask, style/subject reference, source video for edit/extend, reference audio, prior generation id, provider-side file handles (`file_id`, `gs://`, `runway://`, `mm_file://`).
|
||||
4. **Partial streaming is modality-specific.** Images: a few whole partial frames. Audio: ordered chunks plus timestamp events. Jobs: status/progress/logs. Video: none.
|
||||
5. **Usage is a union**: tokens, seconds, characters (often only in headers), credits, compute time.
|
||||
6. **Moderation can be partial success** (Veo strips audio but returns video). Deprecations are constant (Sora API shuts down 2026-09-24; Imagen is shut down on the Gemini API and past its 2026-06-30 discontinuation date on Vertex).
|
||||
|
||||
## Where existing SDKs are weak and we should not be
|
||||
|
||||
- No streaming TTS.
|
||||
- Video handles are experimental start/status pairs; the polling loop lives inside the generate call.
|
||||
- Unsupported inputs become silent warnings arrays, so a request can succeed while dropping your mask.
|
||||
- `n` is fanned out into hidden parallel calls, which obscures cost and idempotency.
|
||||
- Each modality has its own bespoke result type; the file abstraction is a lazy base64/bytes pair with no URL, expiry, or provider ref.
|
||||
- Effect's own `unstable/ai` has no media generation. Nothing in the Effect ecosystem owns this.
|
||||
|
||||
## Design principles
|
||||
|
||||
- **Same shape as LLM.** `X.request(...)` → Schema class; `X.generate(request)` / `X.stream(request)`; `XClient.Service` + `layer`; typed `AIError`. If you know `LLM`, you know `Video`.
|
||||
- **Execution shape is route policy, not API shape.** `Image.generate` returns an image whether the provider is inline or queued. Job control is available uniformly when you want it.
|
||||
- **Errors, not warnings.** Unsupported common fields fail at the protocol boundary with a typed `AIError`, as the LLM routes do today. Provider-side partial results (filtered audio, moderated sample) surface as `notices` on the response, never as silent drops.
|
||||
- **One asset type in, one asset type out**, shared with LLM messages and tool results.
|
||||
- **Typed per-model options**, no hidden fan-out, no implicit retries that spend money.
|
||||
- **Promise API is one mechanism for the whole package**, not a media-only wrapper.
|
||||
- **One construction path per model.** Media models come from per-modality selectors on the configured facade (`openai.image("gpt-image-2")`), the same shape as `openai.responses("gpt-5")`.
|
||||
|
||||
## Public API
|
||||
|
||||
### Model selection
|
||||
|
||||
A model value is built as `OpenAI.configure({ apiKey }).responses("gpt-5")` or `.image("gpt-image-2")`: `configure` fixes credentials, endpoint, and defaults; the selector fixes which of the provider's APIs to hit and binds the typed `providerOptions` generic. Media follows the same shape with one selector per modality — `.image(id)`, `.video(id)`, `.speech(id)`, `.transcription(id)` on the facades that offer each — mirroring `openai.responses(id)`. `Image.request` accepts `ImageModel` only, exactly as `LLM.request` accepts `LanguageModel`.
|
||||
|
||||
```ts
|
||||
import { OpenAI, Google } from "@opencode/ai/providers"
|
||||
|
||||
const openai = OpenAI.configure({ apiKey }) // OpenAI(...) alone uses env auth (OPENAI_API_KEY)
|
||||
|
||||
LLM.request({ model: openai.responses("gpt-5"), prompt })
|
||||
Image.request({ model: openai.image("gpt-image-2"), prompt })
|
||||
Video.request({ model: google.video("veo-3.1-generate-preview"), prompt })
|
||||
Speech.request({ model: openai.speech("gpt-4o-mini-tts"), text })
|
||||
Transcription.request({ model: openai.transcription("gpt-4o-transcribe"), audio })
|
||||
```
|
||||
|
||||
The request namespace and the selector share one word (`Image.request` + `.image(...)`). That redundancy is accepted: a callable facade returning a lazily resolved ref would be a second way to construct the same model, and the type machinery to infer `providerOptions` through it is not worth one word. Where a provider has two routes for one modality, the selectors stay explicit (`openai.chat`, `stability.image` inline vs `stability.upscale()` queued), and one default per modality per provider is part of the facade definition (OpenAI image → Images API, Google image → Gemini-native; Imagen is shut down, so there is no `google.imagen`). The facade selector (`openai.image(id)`) is the public path for media models. Modality-specific package entrypoints (`model(modelID, settings)` beside today's LLM paths such as `@opencode/ai/providers/openai/responses`) are deferred until Core has a modality-aware model resolver; Core's resolver accepts only `LanguageModel` today.
|
||||
|
||||
### `Media` — the asset type
|
||||
|
||||
Replaces `MediaPart.data: string | Uint8Array`, `ImageInput`, `GeneratedImage`, and aligns `Tool.FileContent`.
|
||||
|
||||
```ts
|
||||
import { Media } from "@opencode/ai"
|
||||
|
||||
Media.Source =
|
||||
| { type: "bytes"; data: Uint8Array; mediaType: string }
|
||||
| { type: "base64"; data: string; mediaType: string }
|
||||
| { type: "url"; url: string; mediaType?: string; expiresAt?: number }
|
||||
| { type: "ref"; provider: ProviderID; id: string; mediaType?: string } // file_id, gs://, runway://, prior generation
|
||||
|
||||
class Media.Asset {
|
||||
readonly source: Media.Source
|
||||
readonly mediaType: string // always resolved (sniffed when the provider omits it)
|
||||
readonly kind: "image" | "video" | "audio" | "document" | "other"
|
||||
readonly info?: { width?; height?; durationSeconds?; sampleRate?; channels?; encoding?; format? }
|
||||
readonly expiresAt?: number
|
||||
readonly providerMetadata?: ProviderMetadata
|
||||
readonly headers?: Record<string, string> // transient download credentials (Veo); never in source/JSON
|
||||
|
||||
bytes(): Effect<Uint8Array, AIError, RequestExecutor.Service> // downloads/decodes lazily, cached
|
||||
base64(): Effect<string, AIError, RequestExecutor.Service>
|
||||
dataUrl(): Effect<string, AIError, RequestExecutor.Service>
|
||||
materialize(): Effect<Media.Asset, AIError, RequestExecutor.Service> // url/ref → bytes, before the URL dies
|
||||
}
|
||||
|
||||
Media.bytes(data, mediaType?) Media.base64(data, mediaType?)
|
||||
Media.url(url, options?) Media.ref(provider, id)
|
||||
Media.file(path) // Effect<Asset, AIError, FileSystem>: reads + sniffs
|
||||
Media.write(asset, path) // Effect<void, AIError, FileSystem | RequestExecutor.Service>
|
||||
```
|
||||
|
||||
`Media.file` and `Media.write` stay Effect-only: bring your platform's `FileSystem` layer. The Promise client owns the
|
||||
runtime path: `ai.file(path)` and `ai.write(asset, path)` read and write through `node:fs/promises` (loaded on first
|
||||
use) with the same media-type sniffing and `InvalidRequest` failures, and `ai.bytes`, `ai.base64`, and
|
||||
`ai.materialize` run the asset methods in its runtime.
|
||||
|
||||
A `ref` source is accepted as input only by routes whose provider issues file handles. No shipped route produces one
|
||||
yet, so `bytes()` and `materialize()` on a ref fail by design until a producer exists.
|
||||
|
||||
Raw-PCM outputs (Gemini TTS, Cartesia raw, Deepgram WS) carry `info.encoding/sampleRate/channels` because there is no container header.
|
||||
|
||||
### Modality namespaces
|
||||
|
||||
Each namespace mirrors `LLM` exactly.
|
||||
|
||||
```ts
|
||||
import { Image, Video, Speech, Transcription } from "@opencode/ai"
|
||||
import { OpenAI, Google, ElevenLabs, Fal } from "@opencode/ai/providers"
|
||||
```
|
||||
|
||||
#### Image
|
||||
|
||||
```ts
|
||||
Effect.gen(function* () {
|
||||
const request = Image.request({
|
||||
model: openai.image("gpt-image-2"),
|
||||
prompt: "A robot tending a rooftop garden",
|
||||
images: [yield* Media.file("./ref.png")], // references / edit sources
|
||||
mask: yield* Media.file("./mask.png"),
|
||||
n: 2,
|
||||
size: "1536x1024", // OpenAI sizes by pixels; Gemini/xAI take aspectRatio instead
|
||||
format: "webp",
|
||||
providerOptions: { quality: "high", background: "transparent" }, // typed per model
|
||||
})
|
||||
|
||||
const response = yield* Image.generate(request) // ImageResponse
|
||||
response.image // Media.Asset (first)
|
||||
response.images // Media.Asset[]
|
||||
response.usage // Usage union (see below)
|
||||
response.notices // moderation / partial-result notices
|
||||
|
||||
Image.stream(request) // Stream<ImageEvent>
|
||||
// ImageEvent: generation-queued | generation-progress | image-partial { index, image } | image { index, image } | finish { usage }
|
||||
})
|
||||
```
|
||||
|
||||
`size` and `aspectRatio` are not interchangeable; each route rejects fields it cannot lower — see the portability table
|
||||
in the README's Image generation section.
|
||||
|
||||
Editing is not a separate function; `images`/`mask` on the request select the edit path in the route (OpenAI `/images/edits`, Gemini multimodal parts, xAI `/images/edits`). Routes that cannot honor `mask` fail with `Unsupported`.
|
||||
|
||||
`ImageRoute` is the inline | stream | queued union, dispatched on `route.kind`, like every modality route. `Image.stream` on a streaming route emits `image-partial` previews before each `image`; on a queued route it emits `generation-queued` / `generation-progress` observations, then the result's `image` and `finish` events.
|
||||
|
||||
#### Video
|
||||
|
||||
Shipped in phase 2 (`src/video.ts`, `src/video-client.ts`, protocols `google-video`, `xai-video`, `fal-video`, `runway-video`).
|
||||
|
||||
```ts
|
||||
Effect.gen(function* () {
|
||||
const request = Video.request({
|
||||
model: google.video("veo-3.1-generate-preview"),
|
||||
prompt: "Panning wide shot of a calico kitten sleeping in the sunshine",
|
||||
frames: { first: yield* Media.file("./start.png"), last: yield* Media.file("./end.png") },
|
||||
references: [yield* Media.file("./style.png")],
|
||||
video: Media.bytes(previous, "video/mp4"), // edit / extend source
|
||||
durationSeconds: 8,
|
||||
aspectRatio: "16:9",
|
||||
resolution: "1080p",
|
||||
audio: true,
|
||||
n: 1,
|
||||
seed: 7,
|
||||
negativePrompt: "text, watermark", // common, not provider-native
|
||||
providerOptions: { personGeneration: "allow_adult" },
|
||||
})
|
||||
|
||||
// Simple: wait for it.
|
||||
const response = yield* Video.generate(request, { poll: { interval: "10 seconds", timeout: "10 minutes" } })
|
||||
response.video // Media.Asset: url (expiresAt on Veo and Runway; transient `headers` for Veo downloads)
|
||||
response.usage // credits on Runway; the other three report none (xAI's usage.cost_in_usd_ticks is not decoded)
|
||||
response.notices // Veo raiMediaFilteredReasons → filtered, xAI respect_moderation → moderated
|
||||
yield* response.video.materialize() // pull bytes before the URL expires
|
||||
|
||||
// Explicit generation control.
|
||||
const generation = yield* Video.start(request) // Generation<VideoResponse>
|
||||
generation.id; generation.status; generation.progress; generation.position; generation.token
|
||||
yield* generation.await({ poll }) // VideoResponse
|
||||
yield* generation.cancel() // fal PUT cancel_url, Runway DELETE /tasks/{id}; Veo and xAI succeed without a request
|
||||
|
||||
// Resume from another process. The token is validated against the route's codec and refreshed once. It carries no
|
||||
// route identity, so persist the provider and model ID alongside it: `resume` needs the model.
|
||||
const resumed = yield* Video.resume(model, JSON.parse(saved))
|
||||
|
||||
// Progress as a stream.
|
||||
Video.stream(request, { poll }) // Stream<VideoEvent>: generation-queued { id, position } | generation-progress { id, progress } | video { index, video } | finish { usage, notices }
|
||||
})
|
||||
```
|
||||
|
||||
Tokens are route-owned JSON: Veo `{ operation }`, xAI `{ requestID }`, Runway `{ taskID }`, fal
|
||||
`{ requestID, statusURL, responseURL, cancelURL }` (fal's follow-up URLs are authoritative and absolute). Common-field
|
||||
lowering per provider: Veo takes inline media only, rejects `audio: false` and `n > 1`, and requires `frames.first`
|
||||
when `frames.last` is set; xAI rejects `n`, `seed`, and `negativePrompt` and routes a `video` input to edits or
|
||||
(`providerOptions.mode: "extend"`) extensions; fal rejects `n`, plus `durationSeconds`, `references`, and `frames.last`
|
||||
because the field names and enums differ per model; Runway passes `aspectRatio` through as its pixel `ratio` and
|
||||
rejects `n`.
|
||||
|
||||
Deferred: `Video.complete(model, token, webhook)` (finish from a webhook payload without polling) and provider poll
|
||||
hints (none of the four providers emit one). Later providers: Luma, Kling, MiniMax, Replicate.
|
||||
|
||||
#### Speech (TTS)
|
||||
|
||||
Shipped in phase 3 (`src/speech.ts`, `src/speech-client.ts`, protocols `openai-speech`, `google-speech`,
|
||||
`elevenlabs-speech`, `cartesia-speech`, `deepgram-speech`; new `ElevenLabs`, `Cartesia`, and `Deepgram` facades).
|
||||
|
||||
```ts
|
||||
const request = Speech.request({
|
||||
model: elevenlabs.speech("eleven_flash_v2_5"),
|
||||
text: "Hello from OpenCode.",
|
||||
voice: "JBFqnCBsd6RMkjVDRZzb", // provider-native identifier, or { id }
|
||||
format: "mp3", // mp3 | wav | pcm | opus | aac | flac | (string & {})
|
||||
speed: 1.0,
|
||||
language: "en",
|
||||
instructions: "Warm, unhurried.", // only OpenAI; elsewhere fails typed
|
||||
timestamps: true, // request alignment; routes without it fail typed
|
||||
providerOptions: { voice_settings: { stability: 0.5 } },
|
||||
})
|
||||
|
||||
const response = yield* Speech.generate(request) // SpeechResponse: audio: Media.Asset, timestamps?, usage?, providerMetadata?
|
||||
yield* Speech.stream(request) // Stream<SpeechEvent>: generation-queued | generation-progress | audio-delta { chunk } | timestamps { items } | finish { audio, usage? }
|
||||
```
|
||||
|
||||
Execution is `MediaProtocol.stream` for every provider: one request whose body is framed and folded by a `step`
|
||||
state machine, with `generate` running the same stream and collecting it. The route submits the request with its
|
||||
`mode` (`"generate" | "stream"`), which lets one provider stay one protocol — OpenAI adds `stream_format: "sse"` (except `tts-1`/`tts-1-hd`, which stream raw bytes), ElevenLabs appends
|
||||
`/stream`, Cartesia switches `/tts/bytes` to `/tts/sse`, Gemini switches `generateContent` to
|
||||
`streamGenerateContent`. The terminal `finish` event carries the assembled asset (every provider's stream is
|
||||
concatenable chunks), so stream consumers also get the whole file and `generate` is just "take `finish`, gather
|
||||
`timestamps`". The cost is memory: a stream holds every chunk until `finish`, so even a consumer that only plays deltas
|
||||
keeps the whole clip in memory. That is bounded by the providers' input text limits (a few minutes of audio); a
|
||||
long-form or session API would need an opt-out.
|
||||
|
||||
**Voice.** `voice?: string | { id: string }`. A string is passed through as the provider's native identifier — a
|
||||
name on OpenAI and Gemini, a voice id on ElevenLabs (path segment) and Cartesia. `{ id }` selects an OpenAI custom
|
||||
voice and is treated as the plain string on routes that do not distinguish custom from built-in. Deepgram's voice is
|
||||
the model id (`aura-2-thalia-en`), so `voice` is `unsupported` there. There is no cross-provider voice catalog or
|
||||
name→id resolution. Multi-speaker (Gemini `speechConfig.multiSpeakerVoiceConfig`) and per-voice settings
|
||||
(ElevenLabs `voice_settings`) go through `providerOptions`.
|
||||
|
||||
**Format and PCM.** `format` is container-level; provider sample rates and bitrates live under `providerOptions`
|
||||
(ElevenLabs `outputFormat`, Cartesia `sampleRate`/`bitRate`/`encoding`, Deepgram `encoding`/`container`/`sampleRate`/
|
||||
`bitRate`). Each protocol maps `format` to its native value (ElevenLabs `mp3_44100_128`/`pcm_24000`/`wav_24000`/
|
||||
`opus_48000_64`, Cartesia `{ container, encoding, sample_rate }`, Deepgram `encoding`+`container`) and declares the
|
||||
asset's media type rather than sniffing, because headerless PCM can look like an MPEG frame sync. Headerless PCM
|
||||
always carries `info.encoding`, `info.sampleRate`, and `info.channels`; its media type is the provider's declaration
|
||||
(Gemini `audio/L16;codec=pcm;rate=24000`, Deepgram's `content-type`) or `audio/pcm`. Gemini's asset follows the
|
||||
provider's declared type: WAV for Gemini 3.8 TTS `generate`, headerless PCM otherwise. The route never wraps PCM as WAV,
|
||||
so `pcm` is the only explicit `format` it accepts, and not on Gemini 3.8 `generate`. Every `format` value a route cannot
|
||||
produce (unknown to it, a container on Cartesia SSE, WAV on an ElevenLabs stream, anything but `pcm` on Gemini, `pcm` on
|
||||
Gemini 3.8 `generate`) fails the same way as an unsupported field: `UnsupportedOperation` with
|
||||
`operation: "media.format"`.
|
||||
|
||||
**Timestamps.** `timestamps: true` on the request asks for alignment. ElevenLabs selects the `with-timestamps`
|
||||
endpoints (character-level, NDJSON when streaming); Cartesia sets `add_timestamps` on `/tts/sse` (word-level; a
|
||||
`generate` with timestamps collects the SSE stream). OpenAI, Gemini, and Deepgram reject it.
|
||||
|
||||
Common-field lowering per provider:
|
||||
|
||||
| Provider | `voice` | `speed` | `language` | `instructions` | `timestamps` | Usage |
|
||||
|---|---|---|---|---|---|---|
|
||||
| OpenAI | `voice` (name or `{ id }`) | `speed` | unsupported | `instructions` | unsupported | `tokens` from SSE `speech.audio.done` only |
|
||||
| Gemini | `prebuiltVoiceConfig.voiceName` | unsupported | `speechConfig.languageCode` | unsupported (direct in text) | unsupported | `tokens` from `usageMetadata` |
|
||||
| ElevenLabs | path voice id (required) | `voice_settings.speed` | `language_code` | unsupported | `with-timestamps` | `credits` from `character-cost` header |
|
||||
| Cartesia | `voice` (required) | `generation_config.speed` | `language` | unsupported | `add_timestamps` | none |
|
||||
| Deepgram | unsupported (voice is the model) | `speed` query | unsupported | unsupported | unsupported | `characters` from `dg-char-count` header |
|
||||
|
||||
Deferred: `Speech.session(...)` — input-streaming TTS where text arrives incrementally over a WebSocket (ElevenLabs
|
||||
`stream-input`, Cartesia WebSocket contexts, Deepgram WebSocket speak) — is a separate scoped resource, not part of
|
||||
`generate`/`stream`, and ships with the realtime work in phase 5.
|
||||
|
||||
#### Transcription (STT)
|
||||
|
||||
Shipped as the second half of phase 3 (`src/transcription.ts`, `src/transcription-client.ts`, protocols
|
||||
`openai-transcription`, `google-transcription`, `deepgram-transcription`, `elevenlabs-transcription`,
|
||||
`assemblyai-transcription`; new `AssemblyAI` facade).
|
||||
|
||||
```ts
|
||||
const request = Transcription.request({
|
||||
model: openai.transcription("gpt-4o-transcribe-diarize"),
|
||||
audio: yield* Media.file("./call.wav"),
|
||||
language: "en", // provider-native passthrough
|
||||
timestamps: "segment", // none | segment | word
|
||||
diarize: true,
|
||||
speakers: 2, // speaker count (AssemblyAI exact, ElevenLabs maximum)
|
||||
providerOptions: { known_speaker_names: ["agent"] },
|
||||
})
|
||||
|
||||
const response = yield* Transcription.generate(request)
|
||||
response.text; response.segments; response.words; response.language; response.durationSeconds; response.usage
|
||||
yield* Transcription.stream(request) // Stream<TranscriptionEvent>: generation-queued | generation-progress | text-delta | segment | finish
|
||||
const generation = yield* Transcription.start(request) // queued routes only
|
||||
yield* Transcription.resume(model, token)
|
||||
```
|
||||
|
||||
Transcription is the first modality whose providers span all three protocol kinds, and it needed no fourth kind.
|
||||
Every `MediaRoute` now carries its `kind`; `TranscriptionRoute` is the union of the inline, stream, and queued routes;
|
||||
`TranscriptionModel.fromRoute` is overloaded per protocol kind (arity picks the overload: `<Options>`,
|
||||
`<Options, Frame, State>`, `<Options, Token>`) and composes through the shared `composeRoute` (`src/media-model.ts`),
|
||||
which picks `MediaRoute.inline` / `stream` / `queued`; and `TranscriptionClient`, like every modality client, is
|
||||
`MediaClient.make` (`src/media-client.ts`), which dispatches on `route.kind`. `generate` on a queued route is `start`
|
||||
then `await`; `stream` on an inline route is the response as a single `finish`, and on a queued route it is the status
|
||||
observations followed by `finish`. `start` / `resume` on a non-queued route fail with `UnsupportedOperation` (`transcription.start`). The
|
||||
`finish` event carries the whole transcript (text, segments, words, language, duration, usage), so the stream route's
|
||||
`collect` is just "take `finish`".
|
||||
|
||||
The route layer gained a `binary` body with array-valued `query` (Deepgram) and `Queued.start.prepare` (AssemblyAI's
|
||||
upload); `packages/ai/AGENTS.md` (Media Routes) describes both.
|
||||
|
||||
Settled rules:
|
||||
|
||||
- **Timestamps.** A granularity the selected route or model cannot produce fails as `UnsupportedOperation`
|
||||
(`media.timestamps`), following Speech; a route that returns more than asked (Deepgram, ElevenLabs, and AssemblyAI
|
||||
always return words) is not stripped. Segments always carry start and end times: Gemini times each transcription part from its
|
||||
word offsets, so segment timestamps and diarization also request word offsets there.
|
||||
- **Diarization.** `diarize` means segments (and words, where the provider labels them) carry `speaker`. Labels are
|
||||
provider-native strings — OpenAI `A` or a known speaker name, Deepgram `0`, Gemini `spk:0`, AssemblyAI `A`,
|
||||
ElevenLabs `speaker_0` — with no cross-provider speaker model. `speakers` is the number of speakers to label:
|
||||
AssemblyAI (`speakers_expected`) treats it as an exact constraint rather than a hint, and ElevenLabs
|
||||
(`num_speakers`) as the maximum. Both turn on diarization for it; the other routes reject it.
|
||||
- **Segments from words.** ElevenLabs returns only a token list (`word`, `spacing`, `audio_event`), so its segments
|
||||
are speaker turns: consecutive words and spacing with one `speaker_id`, text joined from the provider's own spacing
|
||||
tokens. `words` drops spacing and audio events. Segments therefore need diarization, which `timestamps: "segment"`
|
||||
turns on, as AssemblyAI's utterances need speaker labels.
|
||||
- **Language** is passed through (`language`, OpenAI `gpt-transcribe` `languages[]`, Gemini `languageCodes`,
|
||||
AssemblyAI and ElevenLabs `language_code`). `response.language` is the provider's own value, lowercased but not
|
||||
normalized: an ISO code on most routes (AssemblyAI's detection returns `en`, ElevenLabs ISO 639-3 `eng`), `english`
|
||||
from whisper-1. Deepgram and AssemblyAI assume English unless asked to detect, so a missing `language` enables their
|
||||
detection.
|
||||
- **Gemini** requires a transcribe model; other model ids fail with `UnsupportedOperation` before the call, because
|
||||
general models ignore `audioTranscriptionConfig` and answer conversationally. Streamed chunks carry whole speaker
|
||||
turns (one part per turn), which join with a space.
|
||||
- **Streaming inline providers** emit only `finish`; deltas are never faked.
|
||||
- **Units.** AssemblyAI milliseconds and Gemini protobuf durations (`"0.400s"`) are normalized to seconds at the
|
||||
protocol boundary.
|
||||
|
||||
| Provider | Kind | Audio input | `timestamps` | `diarize` | Unsupported | Usage |
|
||||
|---|---|---|---|---|---|---|
|
||||
| OpenAI | stream (`stream: true` in `stream` mode; `whisper-1` ignores `stream`, so it emits only `finish`) | multipart `file` (inline only) | `whisper-1` (`verbose_json`); diarize model: `segment` | `gpt-4o-transcribe-diarize` (`diarized_json`) | `speakers`; `prompt` on the diarize model | `tokens` or `seconds` |
|
||||
| Gemini | stream (`generateContent` / `streamGenerateContent`) | `inlineData` or Gemini Files `fileData` | `audioTranscriptionConfig.wordTimestamp` | `audioTranscriptionConfig.diarization` | `prompt`, `speakers` | `tokens` |
|
||||
| Deepgram | inline | raw body, or JSON `{ url }` | words always; `segment` → `utterances` | `diarize_model=latest` + `utterances` | `prompt`, `speakers` | `seconds` (`metadata.duration`) |
|
||||
| ElevenLabs | inline | multipart `file`, or `source_url` | words always; `segment` → `diarize` (speaker turns) | `diarize` | `prompt`; `webhook`, per-channel `use_multi_channel` | `seconds` (`audio_duration_secs`) |
|
||||
| AssemblyAI | queued (upload → submit → poll) | `/v2/upload` then `audio_url`, or a URL | words always; `segment` → `speaker_labels` | `speaker_labels` | — | `seconds` (`audio_duration`) |
|
||||
|
||||
Deferred: `Transcription.session(...)` — realtime STT over WebSocket (Deepgram live, AssemblyAI streaming, ElevenLabs
|
||||
realtime, OpenAI realtime transcription) — is the same future scoped `session` shape as input-streaming TTS and ships
|
||||
with the realtime work in phase 5.
|
||||
|
||||
### `Generation` — shared async execution
|
||||
|
||||
```ts
|
||||
class Generation<Response> {
|
||||
readonly id: string
|
||||
readonly route: GenerationRoute<Response> // token-free: { status, result, cancel?: Effect } closed over the decoded token
|
||||
readonly token: unknown // route-owned serializable JSON
|
||||
readonly status: "queued" | "running" | "completed" | "failed" | "cancelled" | "expired"
|
||||
readonly progress?: number // 0..1, normalized
|
||||
readonly position?: number
|
||||
refresh(): Effect<Generation<Response>, AIError>
|
||||
result(): Effect<Response, AIError>
|
||||
await(options?: GenerationAwaitOptions): Effect<Response, AIError>
|
||||
cancel(): Effect<void, AIError>
|
||||
events(options?: GenerationAwaitOptions): Stream<GenerationEvent, AIError> // fails with Timeout past poll.timeout, checked per observation
|
||||
}
|
||||
|
||||
GenerationAwaitOptions = { poll?: Poll }
|
||||
Poll = { interval?: Duration; timeout?: Duration }
|
||||
```
|
||||
|
||||
`Generation` is not video-specific. Image routes on BFL, fal, Replicate, and Stability `upscale()` are queued; `Image.start` exists for them. A route declares itself `inline` or `queued`; `generate` on a queued route is `start` then `await`.
|
||||
|
||||
Status polls and result reads retry transient failures (rate limits, provider 5xx, and transport errors, classified by the same `isRetryable` the Session runner uses) inside `MediaRoute.queued`. Only the HTTP exchange retries, never the decoded document: a terminal `failed` generation also surfaces as `ProviderInternal` and must not be re-read. Gaps grow exponentially from 1s with jitter, up to 30s each, honoring a provider `retry-after` up to that cap, for at most 8 retries. `await`, `events`, and `Video.stream` cut retries off at `poll.timeout` and fail with `Timeout`, so retries never extend the caller's deadline; a direct `result()` or `resume` read is bounded by the retry cap alone. `start` and `cancel` never retry: a repeated submit can start and bill a second job. The policy is internal; there is no option for it.
|
||||
|
||||
Interrupting `await`, `events`, or `Video.stream` (or aborting the promise API's `signal`) stops waiting only. The provider job keeps running and billing; call `cancel()` explicitly to stop it.
|
||||
|
||||
### Usage
|
||||
|
||||
```ts
|
||||
Usage =
|
||||
| { type: "tokens"; input; output; total; details? }
|
||||
| { type: "seconds"; seconds }
|
||||
| { type: "characters"; characters }
|
||||
| { type: "credits"; credits }
|
||||
| { type: "compute"; seconds }
|
||||
```
|
||||
|
||||
Header-only usage (ElevenLabs `character-cost`, Deepgram `dg-char-count`) is lifted into `usage` by the route.
|
||||
|
||||
### Promise API — `@opencode/ai/promise`
|
||||
|
||||
Mirrors the `packages/plugin/src/effect` and `packages/plugin/src/promise` split that already exists in this repo. One mechanism for LLM and media.
|
||||
|
||||
```ts
|
||||
import { AI } from "@opencode/ai/promise"
|
||||
|
||||
const ai = AI.make() // ManagedRuntime over RequestExecutor.fetchLayer + all clients
|
||||
// AI.make({ layer }) to inject a custom executor / recorder / middleware
|
||||
|
||||
const image = await ai.image.generate({ model, prompt })
|
||||
await ai.bytes(image.image) // also ai.base64, ai.materialize, ai.write(asset, path)
|
||||
const reference = await ai.file("./ref.png")
|
||||
|
||||
for await (const event of ai.speech.stream({ model, text, voice })) { … }
|
||||
|
||||
const generation = await ai.video.start({ model, prompt }) // snapshot handle; refresh() returns a new one
|
||||
for await (const event of generation.events({ poll: { interval: 10_000 } })) { … }
|
||||
const video = await generation.await({ poll: { interval: 10_000 }, signal })
|
||||
const resumed = await ai.video.resume(model, JSON.parse(saved)) // persist provider + model ID with the token
|
||||
|
||||
const request = ai.llm.request({ model, prompt })
|
||||
const text = await ai.llm.generate(request)
|
||||
for await (const event of ai.llm.stream(request)) { … }
|
||||
|
||||
await ai.dispose()
|
||||
```
|
||||
|
||||
Streams become `AsyncIterable` via `Stream.toAsyncIterable`. `AIError` is thrown as-is. Aborting an `AbortSignal` interrupts the work and, like `fetch`, rejects the Promise or throws from the stream with `signal.reason` instead of ending the stream as if complete. Nothing in `src/*` except this entrypoint knows about promises.
|
||||
|
||||
### Providers
|
||||
|
||||
Existing facades gain per-modality selectors; the modality routes each facade provides (*italics* are not
|
||||
implemented):
|
||||
|
||||
| Facade | llm | image | video | speech | transcription | other |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `OpenAI` | responses (default), chat | Images API (stream) | *Sora skipped (decision 8)* | ✓ | ✓ | |
|
||||
| `Google` | Gemini | Gemini-native | Veo | Gemini TTS | `gemini-3.5-transcribe` | |
|
||||
| `XAI` | ✓ | ✓ | ✓ | | | |
|
||||
| `ElevenLabs` | | | | ✓ | Scribe | *soundEffect, music (phase 5)* |
|
||||
| `Cartesia` | | | | ✓ | | |
|
||||
| `Deepgram` | | | | Aura | ✓ | |
|
||||
| `Fal` | | ✓ (queued) | ✓ | | | |
|
||||
| `AssemblyAI` | | | | | ✓ (queued) | |
|
||||
| `BlackForestLabs` | | ✓ (queued) | | | | |
|
||||
| `Replicate` | | ✓ (queued) | | | | |
|
||||
| `Stability` | | `image` (inline), `upscale()` (queued) | | | | |
|
||||
| `Runway` | | | ✓ | | | |
|
||||
| `Luma`, `Kling`, `MiniMax` | | *deferred* | *deferred* | | | |
|
||||
|
||||
New facades follow the existing one-file-per-provider rule. The facade selector is the public path for media models; modality-specific package entrypoints (for example `@opencode/ai/providers/openai/images`) are deferred until Core has a modality-aware model resolver.
|
||||
|
||||
`ImageModel<Options>` gives typed `providerOptions` per model; `VideoModel`, `SpeechModel`, and `TranscriptionModel` follow the same generic. As with `LanguageModel`, the route type does not carry `Options`, so `ImageModel<OpenAIImageOptions>` is an `ImageModel` and client methods take plain `ImageRequestFor`. They share an internal `MediaModel` base class (ids, route, `http` overlays) that is not part of the public exports; `Generation` and the promise client work with the concrete modality models.
|
||||
|
||||
### Routes and protocols
|
||||
|
||||
Media does not fit the LLM four-axis route (SSE frames → event state machine) except for streaming TTS/STT. Reuse `Endpoint`, `Auth`, `Framing`, `RequestExecutor`, and add media protocol kinds:
|
||||
|
||||
- `MediaProtocol.inline` — `body.from(request)` (JSON, multipart, or query), `response.decode(response)` (JSON, or binary body → `Media.Asset`).
|
||||
- `MediaProtocol.queued` — `start` (body + decode to `{ token, snapshot }`), `status`, `result`, optional `cancel`, and a `token` codec. `result` is always a separate GET (against the status document for Veo/xAI/Runway, fal's `response_url` otherwise) so `await` after `start` and after `resume` share one path. `PollContext.auth` hands the auth headers the route sent to the protocol for output URLs that need them (Veo downloads); they become transient `Media.Asset.headers`, never part of `source`. There is no separate `download` step: `Media.Asset.bytes()` downloads through the executor with those headers. `MediaRoute.inline(...)` / `MediaRoute.queued(...)` compose each kind with endpoint and auth; the queued route decodes the token once and hands `Generation` a token-free `{ status, result, cancel? }`.
|
||||
- `MediaProtocol.stream` — `body.from(request)` over the request plus its `mode`, `frames` (a function that picks the framing for the call: `Framing.sse`, `lines`, `document`, or the raw bytes), fresh per-response `initial()` state, `step` emitting modality events, and `finish(state, context)` — with the observed response for header-only usage — emitting exactly one terminal event or failing as an incomplete stream. The route fills `reason.http` on stream errors. `MediaRoute.stream(...)` exposes `stream` and `generate` (the same stream folded by the modality's `collect`).
|
||||
|
||||
`MediaRoute.inline` / `MediaRoute.queued` / `MediaRoute.stream` compose one protocol kind with endpoint/auth and tag the route with its `kind`; `ImageModel`/`VideoModel`/`SpeechModel`/`TranscriptionModel` share the `MediaModel` base (`src/media-model.ts`).
|
||||
|
||||
### LLM integration
|
||||
|
||||
- `MediaPart` becomes `{ type: "media"; media: Media.Asset; … }` so protocols branch on `kind` and can pass `url`/`ref` sources through natively (OpenAI `image_url`, Gemini `fileData`).
|
||||
- New `LLMEvent`s: `media { media: Media.Asset }` so Gemini inline image output is first-class instead of dropped. OpenAI Responses `image_generation_call` keeps its single carrier — the provider-executed `tool-result` with `file` content — because Core consumes hosted tool-result content today and has no `media` event handling yet; it switches to the `media` carrier when Core adopts the event, so the image is never emitted twice.
|
||||
- `Message.assistant([...])` accepts media parts; Gemini multi-turn image editing replays them.
|
||||
- `Tool.FileContent` aligns with `Media.Source`.
|
||||
|
||||
## Decisions
|
||||
|
||||
All settled:
|
||||
|
||||
1. **Per-modality selectors** (`openai.image(id)`, `.video`, `.speech`, `.transcription`) name media models, mirroring `openai.responses(id)`. The one-word overlap with the request namespace is accepted over a callable-facade `ModelRef` as a second construction path.
|
||||
2. **`providerOptions` everywhere** (rename current `Image.options`) for consistency with LLM.
|
||||
3. **No hidden `n` fan-out.** `n` lowers natively; routes that cannot do `n > 1` fail typed. Callers use `Effect.all` / `Promise.all` explicitly.
|
||||
4. **Errors over warnings** for unsupported common fields; `notices` for provider-side partial results only.
|
||||
5. **`Media.Asset` is a class** (lazy bytes, cached) with `Media.Source` as the serializable Schema for wire/persistence. `Asset.from(source)` / `asset.source` round-trip losslessly. Same pattern as `LanguageModel` today.
|
||||
6. **Promise entrypoint**: `@opencode/ai/promise` exporting `AI.make(options?: { layer? })` plus a module-level default `ai` for scripts, covering LLM too.
|
||||
7. **Modality set for v1**: `Image`, `Video`, `Speech`, `Transcription`. `Music`/`SoundEffect` and `session` (bidirectional WS, realtime) are designed-for but deferred.
|
||||
8. **Sora is skipped** (API shuts down 2026-09-24). Video launches with Veo, xAI, fal, Runway.
|
||||
|
||||
## Build order
|
||||
|
||||
Foundation + Image ship together as the reference implementation, serially. Video, Speech, and Transcription then proceed in parallel on separate branches. Image jobs and partial streaming come last, after Video has hardened `Generation`.
|
||||
|
||||
## Phasing
|
||||
|
||||
1. **Foundation** — per-modality selectors, `Media`, `Generation`, `Poll`, `Usage` union, `MediaProtocol` kinds, `@opencode/ai/promise` with `llm` + `image`. Port the five existing image protocols onto it. Unify `MediaPart` and add the `media` LLM event (fixes Gemini image output being dropped).
|
||||
2. **Video** — ✅ Veo, xAI, fal, Runway shipped (`MediaProtocol.queued`, `Video.start/generate/resume/stream`, promise `ai.video`). Deferred: `Video.complete` (webhooks), Luma, Kling, MiniMax, Replicate.
|
||||
3. **Speech + Transcription** — ✅ Speech: OpenAI, Gemini TTS, ElevenLabs, Cartesia, Deepgram shipped (`MediaProtocol.stream`, `Speech.generate/stream`, promise `ai.speech`). ✅ Transcription: OpenAI, Gemini, Deepgram, ElevenLabs Scribe, AssemblyAI shipped across all three route kinds (`Transcription.generate/stream/start/resume`, promise `ai.transcription`). Deferred: `Speech.session` and `Transcription.session` (WebSocket streaming).
|
||||
4. **Image queued routes and partials** — ✅ BFL, fal, Replicate, and Stability creative upscale queued; Stability generate inline; OpenAI `partial_images` streaming (`image-partial` restored). Imagen dropped: shut down on the Gemini API and discontinued on Vertex (2026-06-30). Deferred: Stability's synchronous edit and fast/conservative upscale endpoints.
|
||||
5. **Later** — ElevenLabs music/SFX, Lyria, `Speech.session` / `Transcription.session`, realtime.
|
||||
|
||||
Core adoption (session attachments beyond png/jpeg/gif/webp/pdf, image-generation tool, TUI rendering) comes after phase 1 and is a Core concern.
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Config, Effect, Formatter, Layer, Schema, Stream } from "effect"
|
||||
import { LLM, LLMClient, LLMRequest, Message, ProviderID, Tool, ToolRuntime } from "@opencode/ai"
|
||||
import { Route, Auth, Endpoint, Framing, Protocol, RequestExecutor } from "@opencode/ai/route"
|
||||
import { Config, Effect, Formatter, Schema, Stream } from "effect"
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { AIClient, Image, LLM, LLMRequest, Media, Message, ProviderID, Tool, ToolRuntime } from "@opencode/ai"
|
||||
import { Route, Auth, Endpoint, Framing, Protocol } from "@opencode/ai/route"
|
||||
import { OpenAI } from "@opencode/ai/providers"
|
||||
|
||||
/**
|
||||
@@ -16,15 +17,18 @@ import { OpenAI } from "@opencode/ai/providers"
|
||||
|
||||
const apiKey = Config.redacted("OPENAI_API_KEY")
|
||||
|
||||
// 1. Pick a model. The provider helper records provider identity, protocol
|
||||
// choice, capabilities, deployment options, authentication, and defaults.
|
||||
const model = OpenAI.configure({
|
||||
// 1. Configure a provider. The configured facade records provider identity,
|
||||
// deployment options, authentication, and defaults. Per-modality selectors pick
|
||||
// the API: `.responses(...)` / `.chat(...)` for LLM calls and `.image(...)` for
|
||||
// image generation.
|
||||
const openai = OpenAI.configure({
|
||||
apiKey,
|
||||
generation: { maxTokens: 160 },
|
||||
providerOptions: {
|
||||
store: false,
|
||||
},
|
||||
}).model("gpt-4o-mini")
|
||||
})
|
||||
const model = openai.responses("gpt-4o-mini")
|
||||
|
||||
// 2. Build a provider-neutral request. This is useful when reusing one request
|
||||
// across generate and stream examples.
|
||||
@@ -209,18 +213,37 @@ const FakeEcho = {
|
||||
}),
|
||||
}
|
||||
|
||||
// Provide the LLM runtime and the HTTP request executor once. Keep one path
|
||||
// enabled at a time so the tutorial can demonstrate generate, stream, or
|
||||
// 8. Image generation uses the same facade and the same request/generate shape.
|
||||
// `response.image` is a `Media.Asset`: bytes decode lazily and are cached, and
|
||||
// `Media.write` persists them through the Effect `FileSystem`.
|
||||
const generateImage = Effect.gen(function* () {
|
||||
const response = yield* Image.generate({
|
||||
model: openai.image("gpt-image-1-mini"),
|
||||
prompt: "A flat black circle centered on a plain white background.",
|
||||
size: "1024x1024",
|
||||
format: "jpeg",
|
||||
providerOptions: { quality: "low" },
|
||||
})
|
||||
|
||||
console.log("\n== image ==")
|
||||
console.log("media type:", response.image.mediaType)
|
||||
console.log("bytes:", (yield* response.image.bytes()).byteLength)
|
||||
console.log("usage", Formatter.formatJson(response.usage, { space: 2 }))
|
||||
yield* Media.write(response.image, "tutorial-image.jpg").pipe(Effect.provide(NodeFileSystem.layer))
|
||||
})
|
||||
|
||||
// Provide every modality client and the HTTP request executor once with
|
||||
// `AIClient.layer` (`AIClient.layerWith(executor)` swaps the executor). Keep one
|
||||
// path enabled at a time so the tutorial can demonstrate generate, stream, or
|
||||
// tool-loop behavior without spending tokens on every example.
|
||||
const requestExecutorLayer = RequestExecutor.fetchLayer
|
||||
const llmClientLayer = LLMClient.layer.pipe(Layer.provide(requestExecutorLayer))
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
// yield* generateOnce
|
||||
// yield* streamText
|
||||
// yield* generateStructuredObject
|
||||
// yield* generateDynamicObject.pipe(Effect.andThen((response) => Effect.sync(() => console.log(response.object))))
|
||||
// yield* generateImage
|
||||
yield* streamWithTools
|
||||
}).pipe(Effect.provide(Layer.mergeAll(requestExecutorLayer, llmClientLayer)))
|
||||
}).pipe(Effect.provide(AIClient.layer))
|
||||
|
||||
Effect.runPromise(program)
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.17.20",
|
||||
"version": "2.0.21",
|
||||
"name": "@opencode/ai",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -15,6 +15,7 @@
|
||||
],
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./promise": "./src/promise.ts",
|
||||
"./testing": "./src/testing.ts",
|
||||
"./*": "./src/*.ts"
|
||||
},
|
||||
|
||||
@@ -104,6 +104,111 @@ const PROVIDERS: ReadonlyArray<Provider> = [
|
||||
vars: [{ name: "XAI_API_KEY" }],
|
||||
validate: (env) => validateBearer("https://api.x.ai/v1/models", Redacted.make(env.XAI_API_KEY)),
|
||||
},
|
||||
{
|
||||
id: "fal",
|
||||
label: "fal",
|
||||
tier: "canary",
|
||||
note: "fal queue image and video recorded tests",
|
||||
vars: [{ name: "FAL_KEY" }],
|
||||
// fal has no free authenticated list endpoint; a 404 for an unknown request id proves the key was accepted.
|
||||
validate: (env) =>
|
||||
Effect.gen(function* () {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const response = yield* http.execute(
|
||||
HttpClientRequest.get(
|
||||
"https://queue.fal.run/fal-ai/veo3.1/requests/00000000-0000-0000-0000-000000000000/status",
|
||||
).pipe(HttpClientRequest.setHeaders({ authorization: `Key ${Redacted.value(Redacted.make(env.FAL_KEY))}` })),
|
||||
)
|
||||
if (response.status === 404) return undefined
|
||||
return yield* responseError(response)
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "black-forest-labs",
|
||||
label: "Black Forest Labs",
|
||||
tier: "canary",
|
||||
note: "BFL FLUX queued image recorded tests",
|
||||
vars: [{ name: "BFL_API_KEY" }],
|
||||
validate: (env) =>
|
||||
HttpClientRequest.get("https://api.bfl.ai/v1/credits").pipe(
|
||||
HttpClientRequest.setHeader("x-key", Redacted.value(Redacted.make(env.BFL_API_KEY))),
|
||||
executeRequest,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "replicate",
|
||||
label: "Replicate",
|
||||
tier: "canary",
|
||||
note: "Replicate prediction image recorded tests",
|
||||
vars: [{ name: "REPLICATE_API_TOKEN" }],
|
||||
validate: (env) => validateBearer("https://api.replicate.com/v1/account", Redacted.make(env.REPLICATE_API_TOKEN)),
|
||||
},
|
||||
{
|
||||
id: "stability",
|
||||
label: "Stability AI",
|
||||
tier: "canary",
|
||||
note: "Stability inline generate and queued upscale recorded tests",
|
||||
vars: [{ name: "STABILITY_API_KEY" }],
|
||||
validate: (env) => validateBearer("https://api.stability.ai/v1/user/balance", Redacted.make(env.STABILITY_API_KEY)),
|
||||
},
|
||||
{
|
||||
id: "runway",
|
||||
label: "Runway",
|
||||
tier: "canary",
|
||||
note: "Runway task video recorded tests",
|
||||
vars: [{ name: "RUNWAYML_API_SECRET" }],
|
||||
validate: (env) =>
|
||||
validateBearer("https://api.dev.runwayml.com/v1/organization", Redacted.make(env.RUNWAYML_API_SECRET), {
|
||||
"X-Runway-Version": "2024-11-06",
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "elevenlabs",
|
||||
label: "ElevenLabs",
|
||||
tier: "canary",
|
||||
note: "ElevenLabs text-to-speech recorded tests",
|
||||
vars: [{ name: "ELEVENLABS_API_KEY" }],
|
||||
validate: (env) =>
|
||||
HttpClientRequest.get("https://api.elevenlabs.io/v1/models").pipe(
|
||||
HttpClientRequest.setHeader("xi-api-key", Redacted.value(Redacted.make(env.ELEVENLABS_API_KEY))),
|
||||
executeRequest,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "cartesia",
|
||||
label: "Cartesia",
|
||||
tier: "canary",
|
||||
note: "Cartesia text-to-speech recorded tests",
|
||||
vars: [{ name: "CARTESIA_API_KEY" }],
|
||||
validate: (env) =>
|
||||
validateBearer("https://api.cartesia.ai/voices?limit=1", Redacted.make(env.CARTESIA_API_KEY), {
|
||||
"Cartesia-Version": "2026-08-14",
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "deepgram",
|
||||
label: "Deepgram",
|
||||
tier: "canary",
|
||||
note: "Deepgram Aura text-to-speech and Nova transcription recorded tests",
|
||||
vars: [{ name: "DEEPGRAM_API_KEY" }],
|
||||
validate: (env) =>
|
||||
HttpClientRequest.get("https://api.deepgram.com/v1/projects").pipe(
|
||||
HttpClientRequest.setHeader("authorization", `Token ${Redacted.value(Redacted.make(env.DEEPGRAM_API_KEY))}`),
|
||||
executeRequest,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "assemblyai",
|
||||
label: "AssemblyAI",
|
||||
tier: "canary",
|
||||
note: "AssemblyAI queued transcription recorded tests",
|
||||
vars: [{ name: "ASSEMBLYAI_API_KEY" }],
|
||||
validate: (env) =>
|
||||
HttpClientRequest.get("https://api.assemblyai.com/v2/transcript?limit=1").pipe(
|
||||
HttpClientRequest.setHeader("authorization", Redacted.value(Redacted.make(env.ASSEMBLYAI_API_KEY))),
|
||||
executeRequest,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "cloudflare-ai-gateway",
|
||||
label: "Cloudflare AI Gateway",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Layer } from "effect"
|
||||
import { ImageClient } from "./image-client.js"
|
||||
import { LLMClient } from "./route/client.js"
|
||||
import { RequestExecutor } from "./route/executor.js"
|
||||
import { SpeechClient } from "./speech-client.js"
|
||||
import { TranscriptionClient } from "./transcription-client.js"
|
||||
import { VideoClient } from "./video-client.js"
|
||||
|
||||
/** Every modality client over `executor`, which stays in the output so `asset.bytes()` and `Media.write` resolve. */
|
||||
export const layerWith = <E, R>(executor: Layer.Layer<RequestExecutor.Service, E, R>) =>
|
||||
Layer.mergeAll(
|
||||
LLMClient.layer,
|
||||
ImageClient.layer,
|
||||
VideoClient.layer,
|
||||
SpeechClient.layer,
|
||||
TranscriptionClient.layer,
|
||||
).pipe(Layer.provideMerge(executor))
|
||||
|
||||
/** Every modality client plus the executor over `RequestExecutor.fetchLayer`: the one layer most programs need. */
|
||||
export const layer = layerWith(RequestExecutor.fetchLayer)
|
||||
|
||||
export type Services = Layer.Success<typeof layer>
|
||||
|
||||
export * as AIClient from "./ai-client.js"
|
||||
@@ -12,6 +12,7 @@
|
||||
// count against the four-breakpoint budget; auto only fills remaining slots.
|
||||
import { CacheHint, type CachePolicy, type CachePolicyObject } from "./schema/options.js"
|
||||
import { LLMRequest, Message, ToolDefinition, type ContentPart, type ToolEntry } from "./schema/messages.js"
|
||||
import { effortUpdate } from "./effort-updates.js"
|
||||
|
||||
const AUTO: CachePolicyObject = {
|
||||
tools: true,
|
||||
@@ -37,12 +38,31 @@ const resolve = (policy: CachePolicy | undefined): CachePolicyObject => {
|
||||
// prefix caching, Gemini's implicit + out-of-band CachedContent). Skip the
|
||||
// whole policy pass for these — emitting hints would be harmless but pointless.
|
||||
const RESPECTS_INLINE_HINTS = new Set([
|
||||
"alibaba-messages",
|
||||
"anthropic-messages",
|
||||
"anthropic-compatible-messages",
|
||||
"cloudflare-ai-gateway-messages",
|
||||
"google-vertex-messages",
|
||||
"meta-messages",
|
||||
"minimax-messages",
|
||||
"moonshot-messages",
|
||||
"zai-coding-messages",
|
||||
"bedrock-converse",
|
||||
"openrouter",
|
||||
"digitalocean",
|
||||
])
|
||||
|
||||
// OpenRouter upstreams other than Anthropic and Alibaba Qwen cache without breakpoints. Gemini uses only the last
|
||||
// breakpoint, so a conversation-tail breakpoint writes a new cache every step and costs more than none. Qwen ignores
|
||||
// breakpoints on tool definitions and caches tools with the system prompt.
|
||||
const openRouterPolicy = (modelID: string): CachePolicyObject => {
|
||||
// `~anthropic/claude-sonnet-latest` style IDs are OpenRouter aliases for the latest model in a family.
|
||||
const id = modelID.replace(/^~/, "")
|
||||
if (id.startsWith("anthropic/")) return AUTO
|
||||
if (id.startsWith("qwen/")) return { system: true, messages: { tail: 1 } }
|
||||
return NONE
|
||||
}
|
||||
|
||||
const makeHint = (ttlSeconds: number | undefined): CacheHint =>
|
||||
ttlSeconds !== undefined ? new CacheHint({ type: "ephemeral", ttlSeconds }) : new CacheHint({ type: "ephemeral" })
|
||||
|
||||
@@ -121,9 +141,15 @@ const markMessages = (
|
||||
return markMessageAt(messages, lastIndexOfRole(messages, "user"), hint, budget)
|
||||
if (strategy === "latest-assistant")
|
||||
return markMessageAt(messages, lastIndexOfRole(messages, "assistant"), hint, budget)
|
||||
const start = Math.max(0, messages.length - strategy.tail)
|
||||
let start = messages.length
|
||||
let remaining = strategy.tail
|
||||
while (remaining > 0 && start > 0) {
|
||||
start -= 1
|
||||
if (effortUpdate(messages[start]!) === undefined) remaining -= 1
|
||||
}
|
||||
let next = messages
|
||||
for (let i = start; i < messages.length; i++) next = markMessageAt(next, i, hint, budget)
|
||||
for (let i = start; i < messages.length; i++)
|
||||
if (effortUpdate(messages[i]!) === undefined) next = markMessageAt(next, i, hint, budget)
|
||||
return next
|
||||
}
|
||||
|
||||
@@ -142,9 +168,10 @@ const countHints = (request: LLMRequest) =>
|
||||
|
||||
export const applyCachePolicy = (request: LLMRequest): LLMRequest => {
|
||||
if (!RESPECTS_INLINE_HINTS.has(request.model.route.id)) return request
|
||||
if (request.model.route.id === "openrouter" && (request.cache === undefined || request.cache === "auto"))
|
||||
return request
|
||||
const policy = resolve(request.cache)
|
||||
const policy =
|
||||
request.model.route.id === "openrouter" && (request.cache === undefined || request.cache === "auto")
|
||||
? openRouterPolicy(request.model.id)
|
||||
: resolve(request.cache)
|
||||
if (!policy.tools && !policy.system && !policy.messages) return request
|
||||
|
||||
const hint = makeHint(policy.ttlSeconds)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// Changing the top-level reasoning effort invalidates the provider prompt cache. Protocols with a native
|
||||
// per-message update keep it frozen and lower `Message.effort(...)` markers instead; other routes strip them.
|
||||
import { LLMRequest, type EffortPart, type Message } from "./schema/messages.js"
|
||||
|
||||
export const effortUpdate = (message: Message): EffortPart | undefined => {
|
||||
if (message.role !== "system" || message.content.length !== 1) return undefined
|
||||
const part = message.content[0]
|
||||
return part.type === "effort" ? part : undefined
|
||||
}
|
||||
|
||||
export const stripEffortUpdates = (request: LLMRequest) => {
|
||||
const messages = request.messages.filter((message) => effortUpdate(message) === undefined)
|
||||
return messages.length === request.messages.length ? request : LLMRequest.update(request, { messages })
|
||||
}
|
||||
|
||||
export const applyEffortUpdates = (request: LLMRequest): LLMRequest =>
|
||||
request.model.route.supportsEffortUpdates?.(request) ? request : stripEffortUpdates(request)
|
||||
|
||||
// Reverted or forked history can leave the last marker disagreeing with the requested effort.
|
||||
export const resolveEffortUpdates = (request: LLMRequest, current: string | undefined) => {
|
||||
const updates = request.messages.flatMap((message) => effortUpdate(message) ?? [])
|
||||
if (updates.length === 0) return { request, effort: current }
|
||||
if (updates.at(-1)?.effort !== current) return { request: stripEffortUpdates(request), effort: current }
|
||||
return { request, effort: updates[0]?.previous }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export { EvaluationClient } from "./experimental/evaluation-client.js"
|
||||
export {
|
||||
BooleanAnswer,
|
||||
BooleanQuestion,
|
||||
ChoiceAnswer,
|
||||
ChoiceQuestion,
|
||||
Evaluation,
|
||||
EvaluationAnswer,
|
||||
EvaluationInput,
|
||||
EvaluationModel,
|
||||
EvaluationModelSchema,
|
||||
EvaluationQuestion,
|
||||
EvaluationRequest,
|
||||
EvaluationResponse,
|
||||
EvaluationRounding,
|
||||
ScoreAnswer,
|
||||
ScoreQuestion,
|
||||
} from "./experimental/evaluation.js"
|
||||
export type {
|
||||
AnswerFor,
|
||||
AnswersFor,
|
||||
EvaluationModelOptions,
|
||||
EvaluationOptions,
|
||||
EvaluationQuestions,
|
||||
EvaluationRequestFor,
|
||||
EvaluationRequestInput,
|
||||
EvaluationResponseFor,
|
||||
EvaluationRoute,
|
||||
} from "./experimental/evaluation.js"
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { RequestExecutor } from "../route/executor.js"
|
||||
import { AIError, InvalidProviderOutputError, mergeHttpOptions } from "../schema/index.js"
|
||||
import { sanitizeSurrogates } from "../utils/sanitize.js"
|
||||
import {
|
||||
type EvaluationOptions,
|
||||
type EvaluationQuestions,
|
||||
type EvaluationRequestFor,
|
||||
type EvaluationResponseFor,
|
||||
} from "./evaluation.js"
|
||||
|
||||
export type Execute = RequestExecutor.Interface["execute"]
|
||||
|
||||
export interface Interface {
|
||||
readonly evaluate: <Options extends EvaluationOptions, const Questions extends EvaluationQuestions>(
|
||||
request: EvaluationRequestFor<Options, Questions>,
|
||||
) => Effect.Effect<EvaluationResponseFor<Questions>, AIError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/AI/Experimental/EvaluationClient") {}
|
||||
|
||||
export const evaluate = <Options extends EvaluationOptions, const Questions extends EvaluationQuestions>(
|
||||
request: EvaluationRequestFor<Options, Questions>,
|
||||
): Effect.Effect<EvaluationResponseFor<Questions>, AIError, Service> =>
|
||||
Effect.flatMap(Service, (client) => client.evaluate(request))
|
||||
|
||||
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
return Service.of({
|
||||
evaluate: (request) =>
|
||||
request.model.route
|
||||
.evaluate(
|
||||
{
|
||||
...sanitizeSurrogates({
|
||||
...request,
|
||||
model: undefined,
|
||||
http: mergeHttpOptions(request.model.http, request.http),
|
||||
}),
|
||||
model: request.model,
|
||||
},
|
||||
executor.execute,
|
||||
)
|
||||
.pipe(
|
||||
Effect.flatMap((response) => {
|
||||
const questions = Object.entries(request.questions)
|
||||
if (
|
||||
questions.length === Object.keys(response.answers).length &&
|
||||
questions.every(([id, question]) => {
|
||||
const answer = response.answers[id]
|
||||
if (question.type === "boolean") return answer?.type === "boolean"
|
||||
if (question.type === "choice") {
|
||||
if (answer?.type !== "choice" || !Object.hasOwn(question.criteria, answer.choice)) return false
|
||||
if (answer.probabilities === undefined) return true
|
||||
const keys = Object.keys(question.criteria)
|
||||
const probabilities = answer.probabilities
|
||||
return (
|
||||
Object.keys(probabilities).length === keys.length &&
|
||||
keys.every((key) => Object.hasOwn(probabilities, key))
|
||||
)
|
||||
}
|
||||
if (answer?.type !== "score" || answer.score < 0 || answer.score > question.criteria.length - 1)
|
||||
return false
|
||||
if (answer.probabilities === undefined) return true
|
||||
const keys = question.criteria.map((_, index) => String(index))
|
||||
const probabilities = answer.probabilities
|
||||
return (
|
||||
Object.keys(probabilities).length === keys.length &&
|
||||
keys.every((key) => Object.hasOwn(probabilities, key))
|
||||
)
|
||||
})
|
||||
)
|
||||
return Effect.succeed(response as EvaluationResponseFor<typeof request.questions>)
|
||||
return Effect.fail(
|
||||
new AIError({
|
||||
reason: new InvalidProviderOutputError({
|
||||
route: request.model.route.id,
|
||||
message: "Evaluation answers do not match the requested questions",
|
||||
cause: response.answers,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
})
|
||||
}),
|
||||
)
|
||||
export const fetchLayer = layer.pipe(Layer.provideMerge(RequestExecutor.fetchLayer))
|
||||
|
||||
export const EvaluationClient = {
|
||||
Service,
|
||||
layer,
|
||||
fetchLayer,
|
||||
evaluate,
|
||||
} as const
|
||||
@@ -0,0 +1,248 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import {
|
||||
AIError,
|
||||
HttpOptions,
|
||||
InvalidRequestError,
|
||||
ModelID,
|
||||
ProviderID,
|
||||
ProviderMetadata,
|
||||
Usage,
|
||||
} from "../schema/index.js"
|
||||
import { EvaluationClient, Service, type Execute } from "./evaluation-client.js"
|
||||
|
||||
export const EvaluationInput = Schema.Union([Schema.String, Schema.JsonObject, Schema.Array(Schema.Json)])
|
||||
export type EvaluationInput = Schema.Schema.Type<typeof EvaluationInput>
|
||||
|
||||
const EvaluationCriterion = Schema.NullOr(EvaluationInput)
|
||||
const ChoiceCriteria = Schema.Record(Schema.String, EvaluationCriterion).pipe(
|
||||
Schema.refine((x): x is typeof x => Object.keys(x).length > 0, {
|
||||
message: "Choice criteria must be a nonempty option map",
|
||||
}),
|
||||
)
|
||||
|
||||
export const ChoiceQuestion = Schema.Struct({
|
||||
type: Schema.Literal("choice"),
|
||||
instructions: EvaluationInput,
|
||||
criteria: ChoiceCriteria,
|
||||
})
|
||||
export type ChoiceQuestion = Schema.Schema.Type<typeof ChoiceQuestion>
|
||||
|
||||
export const ScoreQuestion = Schema.Struct({
|
||||
type: Schema.Literal("score"),
|
||||
instructions: EvaluationInput,
|
||||
criteria: Schema.Array(EvaluationCriterion).check(Schema.isMinLength(2)),
|
||||
})
|
||||
export type ScoreQuestion = Schema.Schema.Type<typeof ScoreQuestion>
|
||||
|
||||
export const BooleanQuestion = Schema.Struct({
|
||||
type: Schema.Literal("boolean"),
|
||||
instructions: EvaluationInput,
|
||||
criteria: Schema.optional(
|
||||
Schema.Struct({
|
||||
true: Schema.optional(EvaluationCriterion),
|
||||
false: Schema.optional(EvaluationCriterion),
|
||||
}),
|
||||
),
|
||||
})
|
||||
export type BooleanQuestion = Schema.Schema.Type<typeof BooleanQuestion>
|
||||
|
||||
export const EvaluationQuestion = Schema.Union([ChoiceQuestion, ScoreQuestion, BooleanQuestion]).pipe(
|
||||
Schema.toTaggedUnion("type"),
|
||||
)
|
||||
export type EvaluationQuestion = Schema.Schema.Type<typeof EvaluationQuestion>
|
||||
export type EvaluationQuestions = Readonly<Record<string, EvaluationQuestion>>
|
||||
const EvaluationQuestions = Schema.Record(Schema.String, EvaluationQuestion).pipe(
|
||||
Schema.refine((x): x is typeof x => Object.keys(x).length > 0, {
|
||||
message: "Evaluation questions must be a nonempty map",
|
||||
}),
|
||||
)
|
||||
|
||||
const Probability = Schema.Number.check(Schema.isBetween({ minimum: 0, maximum: 1 }))
|
||||
|
||||
export const ChoiceAnswer = Schema.Struct({
|
||||
type: Schema.Literal("choice"),
|
||||
choice: Schema.String,
|
||||
probabilities: Schema.optional(Schema.Record(Schema.String, Probability)),
|
||||
confidence: Schema.optional(Probability),
|
||||
})
|
||||
export type ChoiceAnswer = Schema.Schema.Type<typeof ChoiceAnswer>
|
||||
|
||||
export const ScoreAnswer = Schema.Struct({
|
||||
type: Schema.Literal("score"),
|
||||
score: Schema.Number,
|
||||
probabilities: Schema.optional(Schema.Record(Schema.String, Probability)),
|
||||
confidence: Schema.optional(Probability),
|
||||
})
|
||||
export type ScoreAnswer = Schema.Schema.Type<typeof ScoreAnswer>
|
||||
|
||||
export const BooleanAnswer = Schema.Struct({
|
||||
type: Schema.Literal("boolean"),
|
||||
probability: Probability,
|
||||
})
|
||||
export type BooleanAnswer = Schema.Schema.Type<typeof BooleanAnswer>
|
||||
|
||||
export const EvaluationAnswer = Schema.Union([ChoiceAnswer, ScoreAnswer, BooleanAnswer]).pipe(
|
||||
Schema.toTaggedUnion("type"),
|
||||
)
|
||||
export type EvaluationAnswer = Schema.Schema.Type<typeof EvaluationAnswer>
|
||||
|
||||
export type AnswerFor<Question extends EvaluationQuestion> = Question extends {
|
||||
readonly type: "choice"
|
||||
readonly criteria: infer Criteria
|
||||
}
|
||||
? {
|
||||
readonly type: "choice"
|
||||
readonly choice: Extract<keyof Criteria, string>
|
||||
readonly probabilities?: Readonly<Record<Extract<keyof Criteria, string>, number>>
|
||||
readonly confidence?: number
|
||||
}
|
||||
: Question extends { readonly type: "score" }
|
||||
? ScoreAnswer
|
||||
: BooleanAnswer
|
||||
|
||||
export type AnswersFor<Questions extends EvaluationQuestions> = {
|
||||
readonly [ID in keyof Questions]: AnswerFor<Questions[ID]>
|
||||
}
|
||||
|
||||
export type EvaluationOptions = Record<string, unknown>
|
||||
|
||||
export interface EvaluationRoute<Options extends EvaluationOptions = EvaluationOptions> {
|
||||
readonly id: string
|
||||
readonly evaluate: (
|
||||
request: EvaluationRequestFor<Options>,
|
||||
execute: Execute,
|
||||
) => Effect.Effect<EvaluationResponse, AIError>
|
||||
}
|
||||
|
||||
export class EvaluationModel<Options extends EvaluationOptions = EvaluationOptions> {
|
||||
declare protected readonly _Options: (options: Options) => Options
|
||||
readonly id: ModelID
|
||||
readonly provider: ProviderID
|
||||
readonly route: EvaluationRoute<Options>
|
||||
readonly http?: HttpOptions
|
||||
|
||||
constructor(input: EvaluationModel.Input<Options>) {
|
||||
this.id = input.id
|
||||
this.provider = input.provider
|
||||
this.route = input.route
|
||||
this.http = input.http
|
||||
}
|
||||
|
||||
static make<Options extends EvaluationOptions = EvaluationOptions>(input: EvaluationModel.MakeInput<Options>) {
|
||||
return new EvaluationModel<Options>({
|
||||
id: ModelID.make(input.id),
|
||||
provider: ProviderID.make(input.provider),
|
||||
route: input.route,
|
||||
http: input.http,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export namespace EvaluationModel {
|
||||
export interface Input<Options extends EvaluationOptions = EvaluationOptions> {
|
||||
readonly id: ModelID
|
||||
readonly provider: ProviderID
|
||||
readonly route: EvaluationRoute<Options>
|
||||
readonly http?: HttpOptions
|
||||
}
|
||||
|
||||
export interface MakeInput<Options extends EvaluationOptions = EvaluationOptions>
|
||||
extends Omit<Input<Options>, "id" | "provider"> {
|
||||
readonly id: string | ModelID
|
||||
readonly provider: string | ProviderID
|
||||
}
|
||||
}
|
||||
|
||||
export const EvaluationModelSchema = Schema.declare(
|
||||
(value): value is EvaluationModel => value instanceof EvaluationModel,
|
||||
{
|
||||
expected: "Evaluation.Model",
|
||||
},
|
||||
)
|
||||
|
||||
export class EvaluationRequest extends Schema.Class<EvaluationRequest>("Evaluation.Request")({
|
||||
model: EvaluationModelSchema,
|
||||
state: EvaluationInput,
|
||||
questions: EvaluationQuestions,
|
||||
options: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
http: Schema.optional(HttpOptions),
|
||||
}) {
|
||||
declare protected readonly _EvaluationRequest: void
|
||||
}
|
||||
|
||||
export type EvaluationModelOptions<Model> = Model extends EvaluationModel<infer Options> ? Options : never
|
||||
|
||||
export type EvaluationRequestFor<
|
||||
Options extends EvaluationOptions = EvaluationOptions,
|
||||
Questions extends EvaluationQuestions = EvaluationQuestions,
|
||||
> = Omit<EvaluationRequest, "model" | "questions" | "options"> & {
|
||||
readonly model: EvaluationModel<Options>
|
||||
readonly questions: Questions
|
||||
readonly options?: Options
|
||||
}
|
||||
|
||||
export type EvaluationRequestInput<
|
||||
Model extends object = EvaluationModel,
|
||||
Questions extends EvaluationQuestions = EvaluationQuestions,
|
||||
> = Omit<ConstructorParameters<typeof EvaluationRequest>[0], "model" | "questions" | "options" | "http"> & {
|
||||
readonly model: Model
|
||||
readonly questions: Questions
|
||||
readonly options?: NoInfer<EvaluationModelOptions<Model>>
|
||||
readonly http?: HttpOptions.Input
|
||||
} & (Model extends EvaluationModel<EvaluationModelOptions<Model>> ? unknown : never)
|
||||
|
||||
export class EvaluationRounding extends Schema.Class<EvaluationRounding>("Evaluation.Rounding")({
|
||||
probabilityDecimals: Schema.optional(Schema.Int),
|
||||
scoreDecimals: Schema.optional(Schema.Int),
|
||||
}) {}
|
||||
|
||||
export class EvaluationResponse extends Schema.Class<EvaluationResponse>("Evaluation.Response")({
|
||||
model: ModelID,
|
||||
answers: Schema.Record(Schema.String, EvaluationAnswer),
|
||||
usage: Schema.optional(Usage),
|
||||
rounding: Schema.optional(EvaluationRounding),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}) {}
|
||||
|
||||
export type EvaluationResponseFor<Questions extends EvaluationQuestions> = Omit<EvaluationResponse, "answers"> & {
|
||||
readonly answers: AnswersFor<Questions>
|
||||
}
|
||||
|
||||
export function request<const Model extends object, const Questions extends EvaluationQuestions>(
|
||||
input: EvaluationRequestInput<Model, Questions>,
|
||||
): EvaluationRequestFor<EvaluationModelOptions<Model>, Questions>
|
||||
export function request(input: EvaluationRequest): EvaluationRequest
|
||||
export function request(input: EvaluationRequest | EvaluationRequestInput) {
|
||||
if (input instanceof EvaluationRequest) return input
|
||||
return new EvaluationRequest({
|
||||
...input,
|
||||
model: input.model as unknown as EvaluationModel,
|
||||
http: HttpOptions.make(input.http),
|
||||
})
|
||||
}
|
||||
|
||||
export function run<const Model extends object, const Questions extends EvaluationQuestions>(
|
||||
input: EvaluationRequestInput<Model, Questions>,
|
||||
): Effect.Effect<EvaluationResponseFor<Questions>, AIError, Service>
|
||||
export function run(input: EvaluationRequest): Effect.Effect<EvaluationResponse, AIError, Service>
|
||||
export function run(input: EvaluationRequest | EvaluationRequestInput) {
|
||||
return Effect.try({
|
||||
try: () => (input instanceof EvaluationRequest ? input : request(input)),
|
||||
catch: (cause) =>
|
||||
new AIError({
|
||||
reason: new InvalidRequestError({
|
||||
message: cause instanceof Error ? cause.message : String(cause),
|
||||
cause,
|
||||
}),
|
||||
}),
|
||||
}).pipe(
|
||||
Effect.flatMap((request) =>
|
||||
EvaluationClient.evaluate(request as EvaluationRequestFor<EvaluationOptions, EvaluationQuestions>),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export const Evaluation = {
|
||||
request,
|
||||
run,
|
||||
} as const
|
||||
@@ -0,0 +1,199 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import {
|
||||
ChoiceQuestion,
|
||||
EvaluationInput,
|
||||
EvaluationModel,
|
||||
EvaluationResponse,
|
||||
EvaluationRounding,
|
||||
ScoreQuestion,
|
||||
type EvaluationAnswer,
|
||||
type EvaluationOptions,
|
||||
} from "./evaluation.js"
|
||||
import { Auth, type Definition as AuthDefinition } from "../route/auth.js"
|
||||
import {
|
||||
AIError,
|
||||
HttpContext,
|
||||
HttpOptions,
|
||||
InvalidProviderOutputError,
|
||||
InvalidRequestError,
|
||||
ModelID,
|
||||
Usage,
|
||||
mergeJsonRecords,
|
||||
} from "../schema/index.js"
|
||||
|
||||
const Noul = Schema.Struct({
|
||||
type: Schema.Literal("noul"),
|
||||
instructions: EvaluationInput,
|
||||
criteria: Schema.optional(
|
||||
Schema.Struct({
|
||||
true: Schema.optional(Schema.NullOr(EvaluationInput)),
|
||||
false: Schema.optional(Schema.NullOr(EvaluationInput)),
|
||||
}),
|
||||
),
|
||||
})
|
||||
const Question = Schema.Union([
|
||||
ChoiceQuestion.pipe(
|
||||
Schema.refine((x): x is typeof x => Object.keys(x.criteria).length <= 255, {
|
||||
message: "System One Choice questions support at most 255 options",
|
||||
}),
|
||||
),
|
||||
ScoreQuestion.pipe(
|
||||
Schema.refine((x): x is typeof x => x.criteria.length <= 10, {
|
||||
message: "System One Score questions support at most 10 levels",
|
||||
}),
|
||||
),
|
||||
Noul,
|
||||
])
|
||||
const Request = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
model: Schema.String,
|
||||
state: EvaluationInput,
|
||||
questions: Schema.Record(Schema.String, Question),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Any)],
|
||||
)
|
||||
|
||||
const Probability = Schema.Number.check(Schema.isBetween({ minimum: 0, maximum: 1 }))
|
||||
const NoulAnswer = Schema.Struct({ type: Schema.Literal("noul"), noul: Probability })
|
||||
const Choice = Schema.Struct({
|
||||
type: Schema.Literal("choice"),
|
||||
choice: Schema.String,
|
||||
probabilities: Schema.Record(Schema.String, Probability),
|
||||
confidence: Schema.optional(Probability),
|
||||
})
|
||||
const Score = Schema.Struct({
|
||||
type: Schema.Literal("score"),
|
||||
score: Schema.Number,
|
||||
probabilities: Schema.Record(Schema.String, Probability),
|
||||
legend: Schema.optional(Schema.Record(Schema.String, Schema.Json)),
|
||||
confidence: Schema.optional(Probability),
|
||||
})
|
||||
const Answer = Schema.Union([NoulAnswer, Choice, Score]).pipe(Schema.toTaggedUnion("type"))
|
||||
const NativeUsage = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
input_tokens: Schema.optional(Schema.Number),
|
||||
output_tokens: Schema.optional(Schema.Number),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
const Response = Schema.Struct({
|
||||
model: Schema.String,
|
||||
answers: Schema.Record(Schema.String, Answer),
|
||||
usage: Schema.optional(NativeUsage),
|
||||
id: Schema.optional(Schema.String),
|
||||
provider: Schema.optional(Schema.String),
|
||||
provider_metadata: Schema.optional(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Unknown))),
|
||||
})
|
||||
|
||||
export interface ModelInput {
|
||||
readonly id: string | ModelID
|
||||
readonly provider: string
|
||||
readonly providerMetadataKey: string
|
||||
readonly auth: AuthDefinition
|
||||
readonly baseURL: string
|
||||
readonly headers?: Record<string, string>
|
||||
readonly http?: HttpOptions
|
||||
}
|
||||
|
||||
export const model = <Options extends EvaluationOptions = EvaluationOptions>(cfg: ModelInput) =>
|
||||
EvaluationModel.make<Options>({
|
||||
id: cfg.id,
|
||||
provider: cfg.provider,
|
||||
http: cfg.http,
|
||||
route: {
|
||||
id: "system-one",
|
||||
evaluate: (req, send) =>
|
||||
Effect.gen(function* () {
|
||||
const url = new URL(`${cfg.baseURL.replace(/\/$/, "")}/systemone`)
|
||||
Object.entries(req.http?.query ?? {}).forEach(([key, value]) => url.searchParams.set(key, value))
|
||||
const body = yield* Schema.encodeUnknownEffect(Schema.fromJsonString(Request))({
|
||||
...mergeJsonRecords(req.options, req.http?.body),
|
||||
model: req.model.id,
|
||||
state: req.state,
|
||||
questions: Object.fromEntries(
|
||||
Object.entries(req.questions).map(([id, x]) => [id, x.type === "boolean" ? { ...x, type: "noul" } : x]),
|
||||
),
|
||||
}).pipe(
|
||||
Effect.mapError(
|
||||
(cause) => new AIError({ reason: new InvalidRequestError({ message: cause.message, cause }) }),
|
||||
),
|
||||
)
|
||||
const headers = yield* Auth.toEffect(cfg.auth)({
|
||||
request: req,
|
||||
method: "POST",
|
||||
url: url.toString(),
|
||||
body,
|
||||
headers: Headers.fromInput({ ...cfg.headers, ...req.http?.headers }),
|
||||
})
|
||||
const res = yield* send(
|
||||
HttpClientRequest.post(url).pipe(
|
||||
HttpClientRequest.setHeaders(headers),
|
||||
HttpClientRequest.bodyText(body, "application/json"),
|
||||
),
|
||||
)
|
||||
const http = new HttpContext({ url: res.request.url, status: res.status, headers: res.headers })
|
||||
const fail = (message: string, cause: unknown, body?: string) =>
|
||||
new AIError({ reason: new InvalidProviderOutputError({ route: "system-one", message, body, http, cause }) })
|
||||
const text = yield* res.text.pipe(
|
||||
Effect.mapError((cause) => fail("Failed to read the System One response", cause)),
|
||||
)
|
||||
const data = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Response))(text).pipe(
|
||||
Effect.mapError((cause) => fail("System One returned an invalid response", cause, text)),
|
||||
)
|
||||
|
||||
const legend: Record<string, Record<string, Schema.Json>> = {}
|
||||
const answers = Object.fromEntries(
|
||||
Object.entries(data.answers).map(([id, answer]): [string, EvaluationAnswer] => {
|
||||
if (answer.type === "noul") return [id, { type: "boolean", probability: answer.noul }]
|
||||
if (answer.type === "choice") {
|
||||
return [
|
||||
id,
|
||||
{
|
||||
type: "choice",
|
||||
choice: answer.choice,
|
||||
probabilities: answer.probabilities,
|
||||
...(answer.confidence === undefined ? {} : { confidence: answer.confidence }),
|
||||
},
|
||||
]
|
||||
}
|
||||
if (answer.legend !== undefined) legend[id] = answer.legend
|
||||
return [
|
||||
id,
|
||||
{
|
||||
type: "score",
|
||||
score: answer.score,
|
||||
probabilities: answer.probabilities,
|
||||
...(answer.confidence === undefined ? {} : { confidence: answer.confidence }),
|
||||
},
|
||||
]
|
||||
}),
|
||||
)
|
||||
const meta = {
|
||||
...(data.id === undefined ? {} : { responseId: data.id }),
|
||||
...(data.provider === undefined ? {} : { provider: data.provider }),
|
||||
...data.provider_metadata?.[cfg.providerMetadataKey],
|
||||
...(Object.keys(legend).length === 0 ? {} : { legend }),
|
||||
}
|
||||
return new EvaluationResponse({
|
||||
model: ModelID.make(data.model),
|
||||
answers,
|
||||
usage: data.usage
|
||||
? new Usage({
|
||||
inputTokens: data.usage.input_tokens,
|
||||
outputTokens: data.usage.output_tokens,
|
||||
totalTokens:
|
||||
data.usage.input_tokens === undefined && data.usage.output_tokens === undefined
|
||||
? undefined
|
||||
: (data.usage.input_tokens ?? 0) + (data.usage.output_tokens ?? 0),
|
||||
providerMetadata: { [cfg.providerMetadataKey]: data.usage },
|
||||
})
|
||||
: undefined,
|
||||
rounding: new EvaluationRounding({ probabilityDecimals: 2, scoreDecimals: 2 }),
|
||||
providerMetadata: Object.keys(meta).length === 0 ? undefined : { [cfg.providerMetadataKey]: meta },
|
||||
})
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
export const SystemOne = { model } as const
|
||||
@@ -0,0 +1,207 @@
|
||||
import { Clock, Duration, Effect, Schedule, Schema, Stream } from "effect"
|
||||
import { AIError, TimeoutError } from "./schema/errors.js"
|
||||
|
||||
export const Status = Schema.Literals(["queued", "running", "completed", "failed", "cancelled", "expired"])
|
||||
export type Status = Schema.Schema.Type<typeof Status>
|
||||
|
||||
/** Provider-neutral view of one generation observation. */
|
||||
export interface Snapshot {
|
||||
readonly id: string
|
||||
readonly status: Status
|
||||
/** Normalized 0..1 when the provider reports progress. */
|
||||
readonly progress?: number
|
||||
readonly position?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Route-owned generation operations for one generation. The media route decodes its serializable token once (from the
|
||||
* submission response or a `resume` input) and closes over it, so `Generation` never sees the token's shape.
|
||||
*/
|
||||
export interface Route<Response> {
|
||||
readonly status: Effect.Effect<Snapshot, AIError>
|
||||
readonly result: Effect.Effect<Response, AIError>
|
||||
readonly cancel?: Effect.Effect<void, AIError>
|
||||
}
|
||||
|
||||
export interface Poll {
|
||||
readonly interval?: Duration.Input
|
||||
readonly timeout?: Duration.Input
|
||||
}
|
||||
|
||||
export interface AwaitOptions {
|
||||
readonly poll?: Poll
|
||||
}
|
||||
|
||||
export const DEFAULT_POLL_INTERVAL = Duration.seconds(5)
|
||||
export const DEFAULT_POLL_TIMEOUT = Duration.minutes(10)
|
||||
|
||||
export const QueuedEvent = Schema.Struct({
|
||||
type: Schema.tag("generation-queued"),
|
||||
id: Schema.String,
|
||||
position: Schema.optional(Schema.Number),
|
||||
}).annotate({ identifier: "Generation.Event.Queued" })
|
||||
|
||||
export const ProgressEvent = Schema.Struct({
|
||||
type: Schema.tag("generation-progress"),
|
||||
id: Schema.String,
|
||||
progress: Schema.optional(Schema.Number),
|
||||
}).annotate({ identifier: "Generation.Event.Progress" })
|
||||
|
||||
export type Observation = Schema.Schema.Type<typeof QueuedEvent> | Schema.Schema.Type<typeof ProgressEvent>
|
||||
|
||||
export type Event = Observation | { readonly type: "generation-finished"; readonly id: string; readonly status: Status }
|
||||
|
||||
const TERMINAL: ReadonlySet<Status> = new Set(["completed", "failed", "cancelled", "expired"])
|
||||
|
||||
export const isTerminal = (status: Status) => TERMINAL.has(status)
|
||||
|
||||
export class Generation<Response> {
|
||||
readonly id: string
|
||||
readonly status: Status
|
||||
readonly progress?: number
|
||||
readonly position?: number
|
||||
|
||||
constructor(
|
||||
readonly route: Route<Response>,
|
||||
/** Route-owned serializable JSON; pass it to the modality's `resume` from another process. */
|
||||
readonly token: unknown,
|
||||
snapshot: Snapshot,
|
||||
) {
|
||||
this.id = snapshot.id
|
||||
this.status = snapshot.status
|
||||
this.progress = snapshot.progress
|
||||
this.position = snapshot.position
|
||||
}
|
||||
|
||||
get snapshot(): Snapshot {
|
||||
return {
|
||||
id: this.id,
|
||||
status: this.status,
|
||||
progress: this.progress,
|
||||
position: this.position,
|
||||
}
|
||||
}
|
||||
|
||||
get terminal() {
|
||||
return isTerminal(this.status)
|
||||
}
|
||||
|
||||
refresh(): Effect.Effect<Generation<Response>, AIError> {
|
||||
return this.route.status.pipe(Effect.map((snapshot) => new Generation(this.route, this.token, snapshot)))
|
||||
}
|
||||
|
||||
/** Fetch the result without polling; non-completed terminal generations fail with the provider's terminal body. */
|
||||
result(): Effect.Effect<Response, AIError> {
|
||||
return this.route.result
|
||||
}
|
||||
|
||||
/** Poll until the generation reaches a terminal status, then fetch the result. Fails with a `Timeout` reason on deadline. */
|
||||
await(options?: AwaitOptions): Effect.Effect<Response, AIError> {
|
||||
const timeout = Duration.fromInputUnsafe(options?.poll?.timeout ?? DEFAULT_POLL_TIMEOUT)
|
||||
const settled = this.terminal ? Effect.succeed(this) : this.poll(options?.poll)
|
||||
return settled.pipe(
|
||||
// Non-completed terminal states also go through `result` so the route can surface its provider failure body.
|
||||
Effect.flatMap((generation) => generation.result()),
|
||||
Effect.timeoutOrElse({ duration: timeout, orElse: () => timeoutError(this.id, timeout) }),
|
||||
)
|
||||
}
|
||||
|
||||
cancel(): Effect.Effect<void, AIError> {
|
||||
return this.route.cancel ?? Effect.void
|
||||
}
|
||||
|
||||
/**
|
||||
* Status observations as a stream, ending after the first terminal observation. Each poll and each sleep between polls
|
||||
* is bounded by the time remaining until `poll.timeout`, so a hung status request or a long interval fails the stream at
|
||||
* the deadline instead of stalling it. (`Stream.interruptWhen` would express this directly but deadlocks under
|
||||
* `TestClock` when the source completes while the timer sleeps.)
|
||||
*/
|
||||
events(options?: AwaitOptions): Stream.Stream<Event, AIError> {
|
||||
if (this.terminal) return Stream.make(this.event())
|
||||
const timeout = Duration.fromInputUnsafe(options?.poll?.timeout ?? DEFAULT_POLL_TIMEOUT)
|
||||
return Stream.unwrap(
|
||||
Clock.currentTimeMillis.pipe(
|
||||
Effect.map((start) => {
|
||||
const deadline = start + Duration.toMillis(timeout)
|
||||
const refresh = within(this.refresh(), this.id, timeout, deadline)
|
||||
const schedule = this.schedule(options?.poll).pipe(
|
||||
Schedule.modifyDelay((meta) =>
|
||||
Effect.succeed(Duration.min(meta.duration, Duration.millis(Math.max(0, deadline - meta.now)))),
|
||||
),
|
||||
)
|
||||
return Stream.fromEffectSchedule(refresh, schedule).pipe(
|
||||
Stream.takeUntil((generation) => generation.terminal),
|
||||
Stream.map((generation) => generation.event()),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private event(): Event {
|
||||
if (this.terminal) return { type: "generation-finished", id: this.id, status: this.status }
|
||||
if (this.status === "queued") return { type: "generation-queued", id: this.id, position: this.position }
|
||||
return { type: "generation-progress", id: this.id, progress: this.progress }
|
||||
}
|
||||
|
||||
private poll(poll: Poll | undefined) {
|
||||
return this.refresh().pipe(
|
||||
Effect.repeat({ schedule: this.schedule(poll), until: (generation) => generation.terminal }),
|
||||
)
|
||||
}
|
||||
|
||||
private schedule(poll: Poll | undefined) {
|
||||
return Schedule.spaced(poll?.interval ?? DEFAULT_POLL_INTERVAL)
|
||||
}
|
||||
}
|
||||
|
||||
/** `events` followed by the expanded result, with the result fetch bounded by the same `poll.timeout` deadline. */
|
||||
export const resultEvents = <Response, A>(
|
||||
generation: Generation<Response>,
|
||||
expand: (response: Response) => ReadonlyArray<A>,
|
||||
options?: AwaitOptions,
|
||||
): Stream.Stream<Observation | A, AIError> => {
|
||||
const timeout = Duration.fromInputUnsafe(options?.poll?.timeout ?? DEFAULT_POLL_TIMEOUT)
|
||||
return Stream.unwrap(
|
||||
Clock.currentTimeMillis.pipe(
|
||||
Effect.map((start) =>
|
||||
generation.events(options).pipe(
|
||||
Stream.filter((event): event is Observation => event.type !== "generation-finished"),
|
||||
Stream.concat(
|
||||
Stream.fromIterableEffect(
|
||||
within(generation.result(), generation.id, timeout, start + Duration.toMillis(timeout)).pipe(
|
||||
Effect.map(expand),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `effect` within the time left until `deadline`. Fails before starting once the deadline has passed: a fast
|
||||
* request could otherwise win the zero-budget race and schedule another zero-delay poll.
|
||||
*/
|
||||
const within = <A>(effect: Effect.Effect<A, AIError>, id: string, timeout: Duration.Duration, deadline: number) =>
|
||||
Clock.currentTimeMillis.pipe(
|
||||
Effect.flatMap((now) =>
|
||||
now >= deadline
|
||||
? Effect.fail(timeoutError(id, timeout))
|
||||
: effect.pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: Duration.millis(deadline - now),
|
||||
orElse: () => Effect.fail(timeoutError(id, timeout)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const timeoutError = (id: string, timeout: Duration.Duration) =>
|
||||
new AIError({
|
||||
reason: new TimeoutError({
|
||||
message: `Generation ${id} did not finish within ${Duration.format(timeout)}`,
|
||||
timeoutMs: Duration.toMillis(timeout),
|
||||
}),
|
||||
})
|
||||
@@ -1,50 +1,30 @@
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { RequestExecutor } from "./route/executor.js"
|
||||
import { mergeHttpOptions, type AIError } from "./schema/index.js"
|
||||
import { sanitizeSurrogates } from "./utils/sanitize.js"
|
||||
import type { ImageOptions, ImageRequest, ImageRequestFor, ImageResponse } from "./image.js"
|
||||
import { Context } from "effect"
|
||||
import { MediaClient } from "./media-client.js"
|
||||
import {
|
||||
ImageOutputEvent,
|
||||
ImageFinishEvent,
|
||||
type ImageEvent,
|
||||
type ImageRequestFor,
|
||||
type ImageResponse,
|
||||
} from "./image.js"
|
||||
|
||||
export type Execute = RequestExecutor.Interface["execute"]
|
||||
export type Interface = MediaClient.Interface<ImageRequestFor, ImageEvent, ImageResponse>
|
||||
|
||||
export interface Interface {
|
||||
readonly generate: <Options extends ImageOptions>(
|
||||
request: ImageRequestFor<Options>,
|
||||
) => Effect.Effect<ImageResponse, AIError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ImageClient") {}
|
||||
|
||||
export const generate = <Options extends ImageOptions>(
|
||||
request: ImageRequestFor<Options>,
|
||||
): Effect.Effect<ImageResponse, AIError, Service> =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* Service
|
||||
return yield* client.generate(request)
|
||||
})
|
||||
|
||||
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
return Service.of({
|
||||
generate: (request) =>
|
||||
request.model.route.generate(
|
||||
{
|
||||
...sanitizeSurrogates({
|
||||
...request,
|
||||
model: undefined,
|
||||
http: mergeHttpOptions(request.model.http, request.http),
|
||||
}),
|
||||
model: request.model,
|
||||
},
|
||||
executor.execute,
|
||||
),
|
||||
})
|
||||
}),
|
||||
)
|
||||
export class ImageClientService extends Context.Service<ImageClientService, Interface>()("@opencode/ImageClient") {}
|
||||
export const Service = ImageClientService
|
||||
export type Service = ImageClientService
|
||||
|
||||
export const ImageClient = {
|
||||
Service,
|
||||
layer,
|
||||
generate,
|
||||
...MediaClient.make(Service, {
|
||||
modality: "image",
|
||||
responseEvents: (response: ImageResponse) => [
|
||||
...response.images.map((image, index) => ImageOutputEvent.make({ index, image })),
|
||||
ImageFinishEvent.make({
|
||||
usage: response.usage,
|
||||
notices: response.notices,
|
||||
providerMetadata: response.providerMetadata,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
} as const
|
||||
@@ -1,134 +1,113 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import {
|
||||
HttpOptions,
|
||||
InvalidRequestError,
|
||||
AIError,
|
||||
ModelID,
|
||||
ProviderID,
|
||||
ProviderMetadata,
|
||||
Usage,
|
||||
} from "./schema/index.js"
|
||||
import { ImageClient, Service, type Execute as ImageExecute } from "./image-client.js"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { Generation, ProgressEvent, QueuedEvent, type AwaitOptions } from "./generation.js"
|
||||
import { Media } from "./media.js"
|
||||
import { MediaModel, composeRoute, tryRequest } from "./media-model.js"
|
||||
import { MediaRoute } from "./route/media.js"
|
||||
import { AIError, HttpOptions, MediaUsage, ProviderMetadata, type OpenString } from "./schema/index.js"
|
||||
import { ImageClient, Service } from "./image-client.js"
|
||||
|
||||
export interface ImageRoute<Options extends ImageOptions = ImageOptions> {
|
||||
readonly id: string
|
||||
readonly generate: (request: ImageRequestFor<Options>, execute: ImageExecute) => Effect.Effect<ImageResponse, AIError>
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Model
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ImageOptions = Record<string, unknown>
|
||||
export type ImageOptions = MediaModel.Options
|
||||
|
||||
export class ImageModel<Options extends ImageOptions = ImageOptions> {
|
||||
declare protected readonly _Options: (options: Options) => Options
|
||||
readonly id: ModelID
|
||||
readonly provider: ProviderID
|
||||
readonly route: ImageRoute<Options>
|
||||
readonly http?: HttpOptions
|
||||
export type ImageRoute = MediaRoute.AnyRoute<ImageRequestFor, ImageEvent, ImageResponse>
|
||||
|
||||
constructor(input: ImageModel.Input<Options>) {
|
||||
this.id = input.id
|
||||
this.provider = input.provider
|
||||
this.route = input.route
|
||||
this.http = input.http
|
||||
}
|
||||
export class ImageModel<Options extends ImageOptions = ImageOptions> extends MediaModel<ImageRoute, Options> {
|
||||
declare protected readonly _ImageModel: void
|
||||
|
||||
static make<Options extends ImageOptions = ImageOptions>(input: ImageModel.MakeInput<Options>) {
|
||||
/** The number of type arguments selects the kind: `<Options>`, `<Options, Frame, State>`, or `<Options, Token>`. */
|
||||
static fromRoute<Options extends ImageOptions>(
|
||||
route: MediaModel.InlineRouteInput<ImageRequestFor<Options>, ImageResponse>,
|
||||
input: MediaRoute.ModelInput,
|
||||
): ImageModel<Options>
|
||||
static fromRoute<Options extends ImageOptions, Frame, State>(
|
||||
route: MediaModel.StreamRouteInput<ImageRequestFor<Options>, ImageEvent, Frame, State>,
|
||||
input: MediaRoute.ModelInput,
|
||||
): ImageModel<Options>
|
||||
static fromRoute<Options extends ImageOptions, Token>(
|
||||
route: MediaModel.QueuedRouteInput<ImageRequestFor<Options>, ImageResponse, Token>,
|
||||
input: MediaRoute.ModelInput,
|
||||
): ImageModel<Options>
|
||||
static fromRoute<Options extends ImageOptions, Frame, State, Token>(
|
||||
route: MediaModel.AnyRouteInput<ImageRequestFor<Options>, ImageEvent, ImageResponse, Frame, State, Token>,
|
||||
input: MediaRoute.ModelInput,
|
||||
) {
|
||||
return new ImageModel<Options>({
|
||||
id: ModelID.make(input.id),
|
||||
provider: ProviderID.make(input.provider),
|
||||
route: input.route,
|
||||
id: input.id,
|
||||
provider: route.protocol.provider,
|
||||
http: input.http,
|
||||
route: composeRoute(route, input, collectResponse) as ImageRoute,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export namespace ImageModel {
|
||||
export interface Input<Options extends ImageOptions = ImageOptions> {
|
||||
readonly id: ModelID
|
||||
readonly provider: ProviderID
|
||||
readonly route: ImageRoute<Options>
|
||||
readonly http?: HttpOptions
|
||||
}
|
||||
|
||||
export interface MakeInput<Options extends ImageOptions = ImageOptions>
|
||||
extends Omit<Input<Options>, "id" | "provider"> {
|
||||
readonly id: string | ModelID
|
||||
readonly provider: string | ProviderID
|
||||
}
|
||||
}
|
||||
|
||||
export const ImageModelSchema = Schema.declare((value): value is ImageModel => value instanceof ImageModel, {
|
||||
expected: "Image.Model",
|
||||
})
|
||||
|
||||
const ImageBytesInput = Schema.Struct({
|
||||
type: Schema.Literal("bytes"),
|
||||
data: Schema.Uint8Array,
|
||||
mediaType: Schema.String,
|
||||
})
|
||||
const ImageUrlInput = Schema.Struct({
|
||||
type: Schema.Literal("url"),
|
||||
url: Schema.String,
|
||||
})
|
||||
const ImageFileIDInput = Schema.Struct({
|
||||
type: Schema.Literal("file-id"),
|
||||
id: Schema.String,
|
||||
})
|
||||
const ImageFileURIInput = Schema.Struct({
|
||||
type: Schema.Literal("file-uri"),
|
||||
uri: Schema.String,
|
||||
mediaType: Schema.String,
|
||||
})
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ImageInputSchema = Schema.Union([
|
||||
ImageBytesInput,
|
||||
ImageUrlInput,
|
||||
ImageFileIDInput,
|
||||
ImageFileURIInput,
|
||||
]).pipe(Schema.toTaggedUnion("type"))
|
||||
export type ImageInput = Schema.Schema.Type<typeof ImageInputSchema>
|
||||
export type ImageSize = `${number}x${number}`
|
||||
export const ImageSize = Schema.declare<ImageSize>(
|
||||
(value): value is ImageSize => typeof value === "string" && /^\d+x\d+$/.test(value),
|
||||
{ title: "ImageSize" },
|
||||
)
|
||||
|
||||
export const ImageInput = {
|
||||
bytes: (data: Uint8Array, mediaType: string): ImageInput => ({ type: "bytes", data, mediaType }),
|
||||
url: (url: string): ImageInput => ({ type: "url", url }),
|
||||
file: (id: string): ImageInput => ({ type: "file-id", id }),
|
||||
fileUri: (uri: string, mediaType: string): ImageInput => ({ type: "file-uri", uri, mediaType }),
|
||||
} as const
|
||||
export type ImageAspectRatio = Media.AspectRatio
|
||||
export const ImageAspectRatio = Media.AspectRatio
|
||||
|
||||
export type ImageFormat = OpenString<"png" | "jpeg" | "webp">
|
||||
|
||||
export class ImageRequest extends Schema.Class<ImageRequest>("Image.Request")({
|
||||
model: ImageModelSchema,
|
||||
prompt: Schema.String,
|
||||
images: Schema.optional(Schema.Array(ImageInputSchema)),
|
||||
options: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
/** Edit sources or style/subject references, in order. */
|
||||
images: Schema.optional(Schema.Array(Media.AssetSchema)),
|
||||
/** Inpainting mask; routes that cannot honor it fail with `UnsupportedOperation`. */
|
||||
mask: Schema.optional(Media.AssetSchema),
|
||||
n: Schema.optional(Schema.Int),
|
||||
size: Schema.optional(ImageSize),
|
||||
aspectRatio: Schema.optional(ImageAspectRatio),
|
||||
seed: Schema.optional(Schema.Number),
|
||||
format: Schema.optional(Schema.String),
|
||||
providerOptions: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
http: Schema.optional(HttpOptions),
|
||||
}) {
|
||||
declare protected readonly _ImageRequest: void
|
||||
}
|
||||
|
||||
export type ImageRequestFor<Options extends ImageOptions = ImageOptions> = Omit<ImageRequest, "model" | "options"> & {
|
||||
export type ImageRequestFor<Options extends ImageOptions = ImageOptions> = Omit<
|
||||
ImageRequest,
|
||||
"model" | "providerOptions"
|
||||
> & {
|
||||
readonly model: ImageModel<Options>
|
||||
readonly options?: Options
|
||||
readonly providerOptions?: Options
|
||||
}
|
||||
|
||||
export type ImageModelOptions<Model> = Model extends ImageModel<infer Options> ? Options : never
|
||||
|
||||
export type ImageRequestInput<Model extends object = ImageModel> = Omit<
|
||||
export type ImageRequestInput<Model extends ImageModel = ImageModel> = Omit<
|
||||
ConstructorParameters<typeof ImageRequest>[0],
|
||||
"model" | "options" | "http"
|
||||
"model" | "providerOptions" | "http"
|
||||
> & {
|
||||
readonly model: Model
|
||||
readonly options?: NoInfer<ImageModelOptions<Model>>
|
||||
readonly format?: ImageFormat
|
||||
readonly providerOptions?: NoInfer<ImageModelOptions<Model>>
|
||||
readonly http?: HttpOptions.Input
|
||||
} & (Model extends ImageModel<ImageModelOptions<Model>> ? unknown : never)
|
||||
}
|
||||
|
||||
export class GeneratedImage extends Schema.Class<GeneratedImage>("Image.Generated")({
|
||||
mediaType: Schema.String,
|
||||
data: Schema.Union([Schema.String, Schema.Uint8Array]),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}) {}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Response and events
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class ImageResponse extends Schema.Class<ImageResponse>("Image.Response")({
|
||||
images: Schema.Array(GeneratedImage),
|
||||
usage: Schema.optional(Usage),
|
||||
images: Schema.Array(Media.AssetSchema),
|
||||
usage: Schema.optional(MediaUsage),
|
||||
notices: Schema.optional(Schema.Array(Media.Notice)),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}) {
|
||||
get image() {
|
||||
@@ -136,7 +115,63 @@ export class ImageResponse extends Schema.Class<ImageResponse>("Image.Response")
|
||||
}
|
||||
}
|
||||
|
||||
export function request<const Model extends object>(
|
||||
/** The `index`-th lower-fidelity preview of an image still generating; the `image` event supersedes it. */
|
||||
export const ImagePartialEvent = Schema.Struct({
|
||||
type: Schema.tag("image-partial"),
|
||||
index: Schema.Number,
|
||||
image: Media.AssetSchema,
|
||||
}).annotate({ identifier: "Image.Event.Partial" })
|
||||
|
||||
export const ImageOutputEvent = Schema.Struct({
|
||||
type: Schema.tag("image"),
|
||||
index: Schema.Number,
|
||||
image: Media.AssetSchema,
|
||||
}).annotate({ identifier: "Image.Event.Image" })
|
||||
|
||||
export const ImageFinishEvent = Schema.Struct({
|
||||
type: Schema.tag("finish"),
|
||||
usage: Schema.optional(MediaUsage),
|
||||
notices: Schema.optional(Schema.Array(Media.Notice)),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "Image.Event.Finish" })
|
||||
|
||||
const imageEventTagged = Schema.Union([
|
||||
QueuedEvent,
|
||||
ProgressEvent,
|
||||
ImagePartialEvent,
|
||||
ImageOutputEvent,
|
||||
ImageFinishEvent,
|
||||
]).pipe(Schema.toTaggedUnion("type"))
|
||||
export const ImageEvent = Object.assign(imageEventTagged, {
|
||||
is: {
|
||||
generationQueued: imageEventTagged.guards["generation-queued"],
|
||||
generationProgress: imageEventTagged.guards["generation-progress"],
|
||||
imagePartial: imageEventTagged.guards["image-partial"],
|
||||
image: imageEventTagged.guards.image,
|
||||
finish: imageEventTagged.guards.finish,
|
||||
},
|
||||
})
|
||||
export type ImageEvent = Schema.Schema.Type<typeof imageEventTagged>
|
||||
|
||||
const collectResponse = (events: ReadonlyArray<ImageEvent>): Effect.Effect<ImageResponse> => {
|
||||
const finish = events.find(ImageEvent.is.finish)
|
||||
// Every image protocol's `finish` emits the terminal event or fails, so a completed stream always has one.
|
||||
if (finish === undefined) return Effect.die(new Error("The image stream completed without a finish event"))
|
||||
return Effect.succeed(
|
||||
new ImageResponse({
|
||||
images: events.filter(ImageEvent.is.image).map((event) => event.image),
|
||||
usage: finish.usage,
|
||||
notices: finish.notices,
|
||||
providerMetadata: finish.providerMetadata,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request-shaped call API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function request<const Model extends ImageModel>(
|
||||
input: ImageRequestInput<Model>,
|
||||
): ImageRequestFor<ImageModelOptions<Model>>
|
||||
export function request(input: ImageRequest): ImageRequest
|
||||
@@ -144,29 +179,43 @@ export function request(input: ImageRequest | ImageRequestInput) {
|
||||
if (input instanceof ImageRequest) return input
|
||||
return new ImageRequest({
|
||||
...input,
|
||||
model: input.model as unknown as ImageModel,
|
||||
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
|
||||
http: HttpOptions.make(input.http),
|
||||
})
|
||||
}
|
||||
|
||||
export function generate<const Model extends object>(
|
||||
input: ImageRequestInput<Model>,
|
||||
const requestEffect = (input: ImageRequest | ImageRequestInput) => tryRequest(() => request(input))
|
||||
|
||||
export function generate<const Model extends ImageModel>(
|
||||
input: ImageRequest | ImageRequestInput<Model>,
|
||||
options?: AwaitOptions,
|
||||
): Effect.Effect<ImageResponse, AIError, Service>
|
||||
export function generate(input: ImageRequest): Effect.Effect<ImageResponse, AIError, Service>
|
||||
export function generate(input: ImageRequest | ImageRequestInput) {
|
||||
return Effect.try({
|
||||
try: () => (input instanceof ImageRequest ? input : request(input)),
|
||||
catch: (error) =>
|
||||
new AIError({
|
||||
reason: new InvalidRequestError({
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
cause: error,
|
||||
}),
|
||||
}),
|
||||
}).pipe(Effect.flatMap((request) => ImageClient.generate(request as unknown as ImageRequestFor<ImageOptions>)))
|
||||
export function generate(input: ImageRequest | ImageRequestInput, options?: AwaitOptions) {
|
||||
return requestEffect(input).pipe(Effect.flatMap((request) => ImageClient.generate(request, options)))
|
||||
}
|
||||
|
||||
export function stream<const Model extends ImageModel>(
|
||||
input: ImageRequest | ImageRequestInput<Model>,
|
||||
options?: AwaitOptions,
|
||||
): Stream.Stream<ImageEvent, AIError, Service>
|
||||
export function stream(input: ImageRequest | ImageRequestInput, options?: AwaitOptions) {
|
||||
return Stream.unwrap(requestEffect(input).pipe(Effect.map((request) => ImageClient.stream(request, options))))
|
||||
}
|
||||
|
||||
/** Inline and streaming routes fail with `UnsupportedOperation`. */
|
||||
export function start<const Model extends ImageModel>(
|
||||
input: ImageRequest | ImageRequestInput<Model>,
|
||||
): Effect.Effect<Generation<ImageResponse>, AIError, Service>
|
||||
export function start(input: ImageRequest | ImageRequestInput) {
|
||||
return requestEffect(input).pipe(Effect.flatMap((request) => ImageClient.start(request)))
|
||||
}
|
||||
|
||||
export const resume = (model: ImageModel, token: unknown): Effect.Effect<Generation<ImageResponse>, AIError, Service> =>
|
||||
ImageClient.resume(model, token)
|
||||
|
||||
export const Image = {
|
||||
request,
|
||||
generate,
|
||||
stream,
|
||||
start,
|
||||
resume,
|
||||
} as const
|
||||