Compare commits
666 commits
@tpmjs/too
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae0d5e37cf | ||
|
|
4fa8344b34 | ||
|
|
66fb7ef226 | ||
|
|
5ac3beab37 | ||
|
|
894f9842d1 | ||
|
|
dda28d642c | ||
|
|
e42bbdff34 | ||
|
|
1cd44b4e97 | ||
|
|
c8e8a7d9b4 | ||
|
|
cc69d98b6f | ||
|
|
7327992a9d | ||
|
|
6eb0ce7e23 | ||
|
|
022ecda6bf | ||
|
|
7dca00b5da | ||
|
|
2a2f0f487d | ||
|
|
198f9f7d1e | ||
|
|
9ec8bf2454 | ||
|
|
293fe08910 | ||
|
|
15fc413f9c | ||
|
|
a9e01fe772 | ||
|
|
36598fec61 | ||
|
|
99017322ba | ||
|
|
bd232a3407 | ||
|
|
727a44af63 | ||
|
|
4bcdab09f2 | ||
|
|
a52d32c367 | ||
|
|
f3a46045ba | ||
|
|
54bfbff71c | ||
|
|
90d575797f | ||
|
|
0d30a9cabb | ||
|
|
86e523f3dc | ||
|
|
ce44aeab3a | ||
|
|
1675e6ce6c | ||
|
|
0f7e5a3ace | ||
|
|
fa4e7754e6 | ||
|
|
32c6e097ed | ||
|
|
760cc4b77e | ||
|
|
b092ca490b | ||
|
|
ffc6ddcdbb | ||
|
|
3f62228c56 | ||
|
|
ee066a20ca | ||
|
|
f9c5d903a1 | ||
|
|
211be5d197 | ||
|
|
59eefe8d55 | ||
|
|
0e5815e4ce | ||
|
|
2df4b53354 | ||
|
|
13e4fd954d | ||
|
|
19417964de | ||
|
|
8542e3d7d5 | ||
|
|
16c7b8155e | ||
|
|
9fc928adae | ||
|
|
88e66d54ce | ||
|
|
4e3cb78ba4 | ||
|
|
c51fee1484 | ||
|
|
67fa89b1fe | ||
|
|
5926373be6 | ||
|
|
9c6a09e747 | ||
|
|
dff523fa99 | ||
|
|
69162682d1 | ||
|
|
88c4848ab6 | ||
|
|
bfd45341bb | ||
|
|
209fd275f3 | ||
|
|
b5731cf1a1 | ||
|
|
9995d73052 | ||
|
|
9f0d7a2b17 | ||
|
|
234548c6b2 | ||
|
|
a44f38eda9 | ||
|
|
9c731d582e | ||
|
|
1118463e6b | ||
|
|
0489646e55 | ||
|
|
ca6f908ebd | ||
|
|
fb06055f80 | ||
|
|
c4ea363497 | ||
|
|
c4430fb1bf | ||
|
|
360e9ab2bf | ||
|
|
0d3395b32f | ||
|
|
19a905d9d1 | ||
|
|
3259e038fb | ||
|
|
d2740c3f69 | ||
|
|
f0b55a23f9 | ||
|
|
16c3b0df10 | ||
|
|
43c8a57c26 | ||
|
|
ab1133d202 | ||
|
|
a6681c5930 | ||
|
|
5d00f2a711 | ||
|
|
514ea3c0db | ||
|
|
9ab8946c9d | ||
|
|
c5a17a0a69 | ||
|
|
ad974239fd | ||
|
|
474e7f9cda | ||
|
|
a7c7c08f44 | ||
|
|
48c41e8733 | ||
|
|
5fafc90e3e | ||
|
|
92e4f5c467 | ||
|
|
192284ad07 | ||
|
|
01b7295daa | ||
|
|
f5d7364c96 | ||
|
|
ffc5807ba0 | ||
|
|
a3436ca5a5 | ||
|
|
cf2fd3ad9e | ||
|
|
8964e0c237 | ||
|
|
2d939ea11b | ||
|
|
474e11fc13 | ||
|
|
c1af7a2bde | ||
|
|
b71fe338c5 | ||
|
|
a8fe178dc6 | ||
|
|
36d8ec6f09 | ||
|
|
e079959cd8 | ||
|
|
f3a73f1457 | ||
|
|
ef95e10a1e | ||
|
|
3acb6f49e5 | ||
|
|
c56534a4ae | ||
|
|
ac6fdb87e7 | ||
|
|
3c5c218207 | ||
|
|
dc684d3b0e | ||
|
|
ad2f8a629b | ||
|
|
ee3dd3dcd0 | ||
|
|
a1f45fa33a | ||
|
|
c52e6eaf24 | ||
|
|
ea742c9386 | ||
|
|
154f000505 | ||
|
|
aa438078f9 | ||
|
|
028c87056a | ||
|
|
5abc85dafc | ||
|
|
69cec40fe1 | ||
|
|
21b2c46516 | ||
|
|
08cd131088 | ||
|
|
c677110bfe | ||
|
|
257aa55282 | ||
|
|
7e401c82eb | ||
|
|
7f907660c8 | ||
|
|
3cd2fc9674 | ||
|
|
1234b383e7 | ||
|
|
aad8b58031 | ||
|
|
6fbb104380 | ||
|
|
9ebb5ee02e | ||
|
|
0fa4374d9e | ||
|
|
c46a02247a | ||
|
|
ba451a6b4b | ||
|
|
3cde10faf4 | ||
|
|
4973679cbf | ||
|
|
2f02f0f412 | ||
|
|
f943e8e685 | ||
|
|
860d13f44f | ||
|
|
f8302c92d6 | ||
|
|
3e0923fc4a | ||
|
|
301d08303e | ||
|
|
5014822b67 | ||
|
|
014da0171e | ||
|
|
64ee34d35f | ||
|
|
de26322e18 | ||
|
|
cdadbc86d3 | ||
|
|
77204577c1 | ||
|
|
a7a0cbb988 | ||
|
|
839e1b3bef | ||
|
|
23d5159b28 | ||
|
|
7d9321d9c6 | ||
|
|
fdb61ed010 | ||
|
|
82a734b3bd | ||
|
|
fd87edd9e9 | ||
|
|
baea035adc | ||
|
|
0489f5cfdb | ||
|
|
7483e697fc | ||
|
|
76955bde18 | ||
|
|
0770d88815 | ||
|
|
144f07ea75 | ||
|
|
f0d271720c | ||
|
|
499a38ce1b | ||
|
|
066e599293 | ||
|
|
46b212e65a | ||
|
|
3324034e6f | ||
|
|
68480758f7 | ||
|
|
f0b247f449 | ||
|
|
55bd27a504 | ||
|
|
3b5bc83c52 | ||
|
|
3456b26c9d | ||
|
|
c21ed5b41e | ||
|
|
cae05f1504 | ||
|
|
44606e2b78 | ||
|
|
045ad36d14 | ||
|
|
c5eca8fdeb | ||
|
|
ad46943270 | ||
|
|
4461bf70c8 | ||
|
|
54d39c38fa | ||
|
|
4d265cd24d | ||
|
|
1c903cd26e | ||
|
|
27e7d45646 | ||
|
|
e4c09a475f | ||
|
|
e6857e245e | ||
|
|
a548edc0db | ||
|
|
c1655dd318 | ||
|
|
2c3cea8730 | ||
|
|
66670bc9fb | ||
|
|
69ae0dda2f | ||
|
|
b5fa46f6be | ||
|
|
ce3596ea22 | ||
|
|
0c78c1bf1f | ||
|
|
2cd2b10cd0 | ||
|
|
b1dd3371cd | ||
|
|
d22d0e6f59 | ||
|
|
1c3855fa4f | ||
|
|
3d021e079f | ||
|
|
c0511f3628 | ||
|
|
3955cde54b | ||
|
|
bcfa99677c | ||
|
|
f8bfbc3ed2 | ||
|
|
9e1fe2be43 | ||
|
|
a7b4c31881 | ||
|
|
e73b7955a3 | ||
|
|
faf4b622c7 | ||
|
|
d64983163c | ||
|
|
aefb23f318 | ||
|
|
002a537764 | ||
|
|
e0aa8f9619 | ||
|
|
1e49e5f9a5 | ||
|
|
366c1ca147 | ||
|
|
d364bea497 | ||
|
|
232980e5bc | ||
|
|
fefc316038 | ||
|
|
77e697db56 | ||
|
|
0e6be48fdc | ||
|
|
5aad08a3b8 | ||
|
|
907ac2301b | ||
|
|
a8a52c972c | ||
|
|
0f37e64783 | ||
|
|
83e2cededa | ||
|
|
8219f0dde5 | ||
|
|
0fc39da6c9 | ||
|
|
56c4df74b7 | ||
|
|
6880e78f75 | ||
|
|
d524d2ca16 | ||
|
|
b6d507fcf5 | ||
|
|
72ff35b333 | ||
|
|
a39b68e866 | ||
|
|
2fb790a847 | ||
|
|
2b4526ba95 | ||
|
|
3fe93b58d7 | ||
|
|
efb30f7874 | ||
|
|
cd5eaeaf59 | ||
|
|
74a9de290e | ||
|
|
6c781c0711 | ||
|
|
390194265b | ||
|
|
bf97513a59 | ||
|
|
95cb0a96f0 | ||
|
|
e2bb06df60 | ||
|
|
790a338f4d | ||
|
|
a3f1f3935e | ||
|
|
b663ca3e05 | ||
|
|
b4be05e004 | ||
|
|
c8c1a12f22 | ||
|
|
490d76a50b | ||
|
|
b87d5f627c | ||
|
|
c8abfb8968 | ||
|
|
41cbeefabb | ||
|
|
8bbde047b5 | ||
|
|
938a382aab | ||
|
|
079c328bfc | ||
|
|
74c7e63b9f | ||
|
|
0012eba137 | ||
|
|
536b727fc6 | ||
|
|
5c0b9c5ae9 | ||
|
|
49af625254 | ||
|
|
d95a7119c7 | ||
|
|
6661957111 | ||
|
|
d646e2310e | ||
|
|
55ec58065e | ||
|
|
6714f9dc63 | ||
|
|
e5e8750613 | ||
|
|
6bcbce8d1d | ||
|
|
5f48cb50df | ||
|
|
f0177a4a17 | ||
|
|
93cdb60a45 | ||
|
|
a5630e4f41 | ||
|
|
b889d64faf | ||
|
|
52c7d8d844 | ||
|
|
5e0651b987 | ||
|
|
5c9f1a1d0a | ||
|
|
cb157611df | ||
|
|
bc5eb61086 | ||
|
|
849802bfeb | ||
|
|
8c6185e6bb | ||
|
|
ad3457ab32 | ||
|
|
4c5972adc4 | ||
|
|
9b6a4a625e | ||
|
|
3612d12c90 | ||
|
|
9c52203e54 | ||
|
|
9d157800b9 | ||
|
|
9bcc1a8c15 | ||
|
|
e2153120f9 | ||
|
|
3cd65587c5 | ||
|
|
d65031bbeb | ||
|
|
7435a39a3d | ||
|
|
33287d931f | ||
|
|
f86ac2c6b0 | ||
|
|
846676ba15 | ||
|
|
300083aff9 | ||
|
|
346a3f6da0 | ||
|
|
b6a553341a | ||
|
|
bc36d366bc | ||
|
|
84d894e920 | ||
|
|
346b0e83ea | ||
|
|
eaaa40f130 | ||
|
|
1ff0e49e41 | ||
|
|
6f44a021cc | ||
|
|
00b79c1f1a | ||
|
|
337d70f663 | ||
|
|
836ebbf828 | ||
|
|
e02fbc3cd7 | ||
|
|
6f8617fead | ||
|
|
d6de3e025a | ||
|
|
9f254a5ae8 | ||
|
|
f7a6df06c5 | ||
|
|
dc5a2292fc | ||
|
|
cefd237005 | ||
|
|
f40fedae8d | ||
|
|
3bad010833 | ||
|
|
1594f98cc1 | ||
|
|
2c0e5f99d8 | ||
|
|
8e572714ae | ||
|
|
d24be7acbf | ||
|
|
2fe2fcef5c | ||
|
|
4b392a2eed | ||
|
|
361a49f3d7 | ||
|
|
54dedc3056 | ||
|
|
b8b4e44b50 | ||
|
|
114798ce9e | ||
|
|
fd4d78ae09 | ||
|
|
dc1397aa1f | ||
|
|
9bb856563e | ||
|
|
d99f810572 | ||
|
|
3f5c1bda61 | ||
|
|
d156b29286 | ||
|
|
42dac85d92 | ||
|
|
be4e802e7e | ||
|
|
d0e304cdab | ||
|
|
1efb9b0003 | ||
|
|
a137afd65d | ||
|
|
0730ae6f42 | ||
|
|
3dd0caf621 | ||
|
|
287d84163f | ||
|
|
152a076580 | ||
|
|
1afe5bbc19 | ||
|
|
0cbc3e0dd4 | ||
|
|
53ae105602 | ||
|
|
670e3e3ca3 | ||
|
|
724bf18822 | ||
|
|
a4cfea5cc3 | ||
|
|
bc1eb57e2a | ||
|
|
00908118b3 | ||
|
|
f519952441 | ||
|
|
edcff21be7 | ||
|
|
6b48ab224d | ||
|
|
e6bc107812 | ||
|
|
96fb52d047 | ||
|
|
515a461bbf | ||
|
|
3f15153dcf | ||
|
|
aba7c976c1 | ||
|
|
fbcd7150c9 | ||
|
|
d2c149c6be | ||
|
|
c0f9e9884d | ||
|
|
3a0a125b6d | ||
|
|
9371a11eb0 | ||
|
|
1469e36f3f | ||
|
|
1d771efef6 | ||
|
|
19884af03e | ||
|
|
2a6d4c6c2e | ||
|
|
471fbba786 | ||
|
|
cc3a991661 | ||
|
|
1b1c4f6474 | ||
|
|
4f95046249 | ||
|
|
5a0a21fd60 | ||
|
|
bfed830187 | ||
|
|
0b1bb7725d | ||
|
|
552f319583 | ||
|
|
4cbd84edb8 | ||
|
|
9c4004628f | ||
|
|
698165c0d7 | ||
|
|
2e37faf385 | ||
|
|
8230ab0eec | ||
|
|
828801f463 | ||
|
|
abd9682f5e | ||
|
|
137de1c353 | ||
|
|
f8740fb7ff | ||
|
|
e4b84fb1cd | ||
|
|
87a9517ec3 | ||
|
|
499f163c31 | ||
|
|
522c198041 | ||
|
|
4451eddc91 | ||
|
|
1c9a328929 | ||
|
|
b57d21ca65 | ||
|
|
4f417af2ab | ||
|
|
de41d42349 | ||
|
|
1d417d9a22 | ||
|
|
25d74e5c2e | ||
|
|
59ff53c5ba | ||
|
|
763650e976 | ||
|
|
ed3a215ee8 | ||
|
|
6060492169 | ||
|
|
09fd0a5833 | ||
|
|
d1aad1b3ea | ||
|
|
d00ab8afbb | ||
|
|
1560f38f85 | ||
|
|
03405ad754 | ||
|
|
ba633df0a5 | ||
|
|
8569def0e9 | ||
|
|
de97175d6c | ||
|
|
a42ec19b0b | ||
|
|
21f1818070 | ||
|
|
579aa99c56 | ||
|
|
0d93366659 | ||
|
|
17b9ffb6aa | ||
|
|
80bef7d84c | ||
|
|
5d2096fb5d | ||
|
|
2d9b06020d | ||
|
|
16004298b2 | ||
|
|
3519008631 | ||
|
|
2e6cc4642a | ||
|
|
be5421063a | ||
|
|
36a0735ab1 | ||
|
|
409d1232a6 | ||
|
|
913ce4ffe9 | ||
|
|
fd4520dbf6 | ||
|
|
8f4fd77d79 | ||
|
|
82efaf6d59 | ||
|
|
979bb92aff | ||
|
|
22a13166b2 | ||
|
|
ae5b3e6249 | ||
|
|
c2d244a5c9 | ||
|
|
1c8b516db8 | ||
|
|
26421be4e6 | ||
|
|
369979d3c8 | ||
|
|
7b575a87cc | ||
|
|
2fb63ad506 | ||
|
|
5f7a9881ea | ||
|
|
fada29ecbe | ||
|
|
4a13709a3d | ||
|
|
e298109e94 | ||
|
|
70d0c5ba94 | ||
|
|
b279be2a25 | ||
|
|
a57af97585 | ||
|
|
29e1b9d9ac | ||
|
|
f86794e463 | ||
|
|
1efb2623b3 | ||
|
|
289544f101 | ||
|
|
c8806c49aa | ||
|
|
fb8cce3b38 | ||
|
|
afc97c89a3 | ||
|
|
effd242b07 | ||
|
|
597fc2abf1 | ||
|
|
61c89824f9 | ||
|
|
606cf1714c | ||
|
|
c189bd366a | ||
|
|
9855425be4 | ||
|
|
421405eee0 | ||
|
|
62ec33f092 | ||
|
|
b992b6064a | ||
|
|
c2546a04bb | ||
|
|
99bd0fb061 | ||
|
|
365d6127af | ||
|
|
aa99bda14c | ||
|
|
82a6f33a68 | ||
|
|
fd4c0d9136 | ||
|
|
8b532ffa79 | ||
|
|
81efba0de5 | ||
|
|
861e8c2b7e | ||
|
|
e84eda7525 | ||
|
|
deb9e3ae06 | ||
|
|
56e688a13c | ||
|
|
78bdc37583 | ||
|
|
5e795bc086 | ||
|
|
b7ea9bf4d6 | ||
|
|
5fc584fc66 | ||
|
|
70d112982e | ||
|
|
06271e079f | ||
|
|
00441c30db | ||
|
|
489d0125ac | ||
|
|
7bd0caea97 | ||
|
|
30e9bb41b7 | ||
|
|
00718382fc | ||
|
|
0e5d4d6e3b | ||
|
|
1b0fad9baf | ||
|
|
7bb293b6de | ||
|
|
d75c489a68 | ||
|
|
3bb8033c69 | ||
|
|
7d7b5ab8b6 | ||
|
|
0e084de1c5 | ||
|
|
feeb3cf3e8 | ||
|
|
3f54f16573 | ||
|
|
9642f0b7e7 | ||
|
|
8a79396a0a | ||
|
|
a60c02576f | ||
|
|
33c20db291 | ||
|
|
8605a54310 | ||
|
|
1b655571cc | ||
|
|
9d44630675 | ||
|
|
0b93014242 | ||
|
|
f0566ea8c0 | ||
|
|
87b2ef3ad5 | ||
|
|
c20ee3df04 | ||
|
|
48aa12f351 | ||
|
|
6482c31f17 | ||
|
|
4413ac00f6 | ||
|
|
aa1a5cd246 | ||
|
|
097f35f69f | ||
|
|
2b81b6536e | ||
|
|
246a5429af | ||
|
|
5c935dca69 | ||
|
|
45480260d0 | ||
|
|
84f40f2bb4 | ||
|
|
b4acee2895 | ||
|
|
f536ebcee9 | ||
|
|
2b8b1547e9 | ||
|
|
ed53b68c75 | ||
|
|
9577a5979a | ||
|
|
311b331eee | ||
|
|
9bd47c0fad | ||
|
|
ee825aa97c | ||
|
|
e85bbe8638 | ||
|
|
8f27d1cfa7 | ||
|
|
bd4878576e | ||
|
|
c8e590419e | ||
|
|
1ae6923d1a | ||
|
|
3d00dee042 | ||
|
|
81bc2c96c2 | ||
|
|
4b337c8fe0 | ||
|
|
e09596a7fa | ||
|
|
04e7315185 | ||
|
|
4a49bb458e | ||
|
|
d744b12115 | ||
|
|
3e574ac243 | ||
|
|
2405ac279d | ||
|
|
1469b0a060 | ||
|
|
ac98491928 | ||
|
|
cab13f6df2 | ||
|
|
022d363ccb | ||
|
|
833a023688 | ||
|
|
a99b572ac2 | ||
|
|
70f2e421f9 | ||
|
|
4479189df8 | ||
|
|
150d48d0ba | ||
|
|
1e58537a65 | ||
|
|
89c0d552f6 | ||
|
|
e6d7fc6209 | ||
|
|
6ebe7353ab | ||
|
|
0ff50fbd9b | ||
|
|
903e1c1948 | ||
|
|
d10257a8f4 | ||
|
|
9ff4cd6d05 | ||
|
|
9fd56be9cd | ||
|
|
31ff2fa87f | ||
|
|
ade550dcf4 | ||
|
|
d843d7b78d | ||
|
|
0c7b058593 | ||
|
|
47577a43e5 | ||
|
|
1bd484797f | ||
|
|
e3b6ff6d25 | ||
|
|
e6c1be6a53 | ||
|
|
3219da5a03 | ||
|
|
83afeef2dc | ||
|
|
0d1fa3e292 | ||
|
|
38c85bd904 | ||
|
|
8b000cf0ca | ||
|
|
129f45353a | ||
|
|
0a37ca5aa7 | ||
|
|
5c2cc529ef | ||
|
|
c751a056a9 | ||
|
|
cbcb1e49c7 | ||
|
|
b02c464960 | ||
|
|
4ba39e07f8 | ||
|
|
9f1c3fb383 | ||
|
|
a9e91c02d1 | ||
|
|
54ee6439cb | ||
|
|
97db95243a | ||
|
|
e437f69333 | ||
|
|
f59a22dbad | ||
|
|
e0c5ca9969 | ||
|
|
89564af030 | ||
|
|
6676f01a2d | ||
|
|
63bab53b0e | ||
|
|
2a989c5527 | ||
|
|
b41382f365 | ||
|
|
ac22733cfc | ||
|
|
f9dfc67de4 | ||
|
|
dd8fceb0d5 | ||
|
|
46c3bcbff7 | ||
|
|
b9269c5574 | ||
|
|
77e7eedbe0 | ||
|
|
f6514314c0 | ||
|
|
4556213a1c | ||
|
|
958697f788 | ||
|
|
64a05482e4 | ||
|
|
e18ebb9282 | ||
|
|
b51060d9e7 | ||
|
|
c2b5284da4 | ||
|
|
e2af4cfd6a | ||
|
|
cf10a73e5a | ||
|
|
1d8b9a0f31 | ||
|
|
4cb7555c49 | ||
|
|
9b9f0f0859 | ||
|
|
9fc4339d52 | ||
|
|
6e7c75dd14 | ||
|
|
377f90989d | ||
|
|
1bf7879c1e | ||
|
|
8441b5fc70 | ||
|
|
08d1e5a717 | ||
|
|
9e863fe181 | ||
|
|
3aef5bb2bb | ||
|
|
323c7496f0 | ||
|
|
8454452476 | ||
|
|
cd2cfe25cf | ||
|
|
1c949f6a11 | ||
|
|
9aa7a1e2be | ||
|
|
ec27965ab4 | ||
|
|
956f9fdbae | ||
|
|
c1e8e55409 | ||
|
|
bb672edf15 | ||
|
|
287d9b97c9 | ||
|
|
b7e6a6b2dc | ||
|
|
ba893d646a | ||
|
|
d597a71eb4 | ||
|
|
141a64d888 | ||
|
|
f59c2c5123 | ||
|
|
0658aea425 | ||
|
|
277c9f74cd | ||
|
|
63cd815bf4 | ||
|
|
0b30c62ff9 | ||
|
|
484190720a | ||
|
|
da12d1fc5b | ||
|
|
37fcc4b42c | ||
|
|
3940e51299 | ||
|
|
81f0072cb2 | ||
|
|
013b98a53e | ||
|
|
cd3dee8133 | ||
|
|
a6ec7df800 | ||
|
|
d839536eb0 | ||
|
|
2474f884dd | ||
|
|
408102602c | ||
|
|
4876b2e4f4 | ||
|
|
fce4eece1a | ||
|
|
ac8f6d239c | ||
|
|
56e0c0da79 | ||
|
|
268112f96e | ||
|
|
a92c2c250c | ||
|
|
fa6ba5b6cd | ||
|
|
36eebc43d4 | ||
|
|
0ba4e023ac | ||
|
|
88b06362f1 | ||
|
|
d5fb091420 | ||
|
|
f0511eb498 | ||
|
|
f702ab8669 | ||
|
|
165cacf45a | ||
|
|
fe96bc780d | ||
|
|
295199d43f | ||
|
|
38cc823a58 | ||
|
|
30f581cbc3 | ||
|
|
0296433980 | ||
|
|
14b64f0320 | ||
|
|
6d8bea6c8d | ||
|
|
ab46b6e116 | ||
|
|
6d967f3501 | ||
|
|
bba538336a | ||
|
|
d5e8d50082 | ||
|
|
818f6e9ed6 | ||
|
|
32499bbc68 | ||
|
|
506251bfa5 | ||
|
|
10870b594c |
1880 changed files with 1150845 additions and 7699 deletions
102
.claude/commands/blocks-develop.md
Normal file
102
.claude/commands/blocks-develop.md
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
---
|
||||||
|
description: Develop and validate TPMJS tools using the blocks CLI
|
||||||
|
---
|
||||||
|
|
||||||
|
Help the user develop new tools for the TPMJS registry using the blocks CLI. This workflow covers defining tools in blocks.yml, implementing them with AI SDK v6, validating with the blocks CLI, and publishing to npm.
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
### 1. Define Tool in blocks.yml
|
||||||
|
|
||||||
|
Add tool definition to `packages/tools/official/blocks.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
blocks:
|
||||||
|
category.toolName:
|
||||||
|
type: utility
|
||||||
|
description: "Clear description for LLMs"
|
||||||
|
path: "tool-directory-name"
|
||||||
|
domain_rules:
|
||||||
|
- id: rule_name
|
||||||
|
description: "Implementation requirement"
|
||||||
|
inputs:
|
||||||
|
- name: paramName
|
||||||
|
type: string
|
||||||
|
description: "Parameter description"
|
||||||
|
outputs:
|
||||||
|
- name: result
|
||||||
|
type: ResultType
|
||||||
|
description: "Output description"
|
||||||
|
measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Create Package Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
packages/tools/official/tool-name/
|
||||||
|
├── package.json # npm package with tpmjs field
|
||||||
|
├── tsconfig.json # Extends @tpmjs/tsconfig
|
||||||
|
├── tsup.config.ts # Build config
|
||||||
|
├── block.ts # REQUIRED by validator
|
||||||
|
├── index.ts # Re-export from src
|
||||||
|
└── src/index.ts # Main implementation
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Implement with AI SDK v6
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { jsonSchema, tool } from 'ai';
|
||||||
|
|
||||||
|
export const myTool = tool({
|
||||||
|
description: 'Description for LLMs',
|
||||||
|
parameters: jsonSchema<InputType>({
|
||||||
|
type: 'object',
|
||||||
|
properties: { /* ... */ },
|
||||||
|
required: ['field1'],
|
||||||
|
}),
|
||||||
|
async execute(input): Promise<OutputType> {
|
||||||
|
// REAL implementation - no stubs
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default myTool;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Run Validation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packages/tools/official
|
||||||
|
pnpm blocks run tool-name # Validate single tool
|
||||||
|
pnpm blocks run tool-name --force # Force full validation
|
||||||
|
pnpm blocks run --all # Validate all tools
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Build and Publish
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm build
|
||||||
|
npm publish --access public
|
||||||
|
|
||||||
|
# Trigger sync to tpmjs.com
|
||||||
|
source apps/web/.env.local
|
||||||
|
curl -X POST https://tpmjs.com/api/sync/keyword -H "Authorization: Bearer $CRON_SECRET"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Valid Categories
|
||||||
|
|
||||||
|
For `tpmjs.category` in package.json: `research`, `web`, `data`, `documentation`, `engineering`, `security`, `statistics`, `ops`, `agent`, `sandbox`, `utilities`, `html`, `compliance`
|
||||||
|
|
||||||
|
## Required Files
|
||||||
|
|
||||||
|
- **block.ts** at root: `export const block = { name: 'tool-name', tools: { myTool } };`
|
||||||
|
- **index.ts** at root: `export * from './src/index.js';`
|
||||||
|
- Both are required for the validator to find the tool
|
||||||
|
|
||||||
|
## Common Issues
|
||||||
|
|
||||||
|
- "invalid tpmjs field" during sync = Invalid category or missing tools array
|
||||||
|
- "Tool not found in exports" = Export name must match blocks.yml
|
||||||
|
- "Required file not found" = Need index.ts and block.ts at package root
|
||||||
|
|
||||||
|
When helping the user, read the full skill documentation at `.claude/skills/blocks-develop.md` for comprehensive details on entities, measures, and multi-tool packages.
|
||||||
248
.claude/pipelines/tool-request.md
Normal file
248
.claude/pipelines/tool-request.md
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
# Tool Request Pipeline Specification
|
||||||
|
|
||||||
|
Automated pipeline for creating TPMJS tools from GitHub issues using Claude.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
When a maintainer applies the `tool-request` label to an issue, Claude automatically:
|
||||||
|
1. Analyzes the tool idea and designs the implementation
|
||||||
|
2. Determines the best package (existing or new)
|
||||||
|
3. Implements the tool with AI SDK v6
|
||||||
|
4. Validates using blocks CLI
|
||||||
|
5. Creates an auto-merge PR
|
||||||
|
6. Publishes to npm
|
||||||
|
7. Syncs to tpmjs.com registry
|
||||||
|
|
||||||
|
## Trigger
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
|---------|-------|
|
||||||
|
| Label | `tool-request` |
|
||||||
|
| Who can apply | Maintainers only |
|
||||||
|
| Trigger mechanism | Label application triggers workflow, which comments `@claude` |
|
||||||
|
| Concurrency | Parallel execution allowed |
|
||||||
|
| Rate limit | None (trust maintainers) |
|
||||||
|
|
||||||
|
## Input Requirements
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
|---------|-------|
|
||||||
|
| Input format | Accept vague ideas - Claude designs autonomously |
|
||||||
|
| Structured template | Not required |
|
||||||
|
| Clarification | Claude fills gaps autonomously, doesn't ask first |
|
||||||
|
| Mid-flight edits | Incorporate edits - check for changes at each step |
|
||||||
|
|
||||||
|
## Package Organization
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
|---------|-------|
|
||||||
|
| Strategy | Hybrid - default to categories, allow functional cohesion exceptions |
|
||||||
|
| Package selection | Analyze all existing tools in candidate packages to find best fit |
|
||||||
|
| New vs existing | Claude decides based on functional cohesion analysis |
|
||||||
|
| blocks.yml access | Full access - Claude adds entries as part of workflow |
|
||||||
|
|
||||||
|
### Decision Logic for Package Selection
|
||||||
|
|
||||||
|
1. Search existing packages for functionally related tools
|
||||||
|
2. If strong match found (>70% conceptual overlap), add to existing package
|
||||||
|
3. If no match or tool is foundational for a new domain, create new package
|
||||||
|
4. Exception: tightly coupled tools (e.g., e2b-*) stay together regardless of category
|
||||||
|
|
||||||
|
## Validation & Iteration
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
|---------|-------|
|
||||||
|
| Max attempts | 3 before escalating to human review |
|
||||||
|
| On failure | Iterate in-issue - Claude fixes and retries |
|
||||||
|
| Runtime test | Execute with sample inputs, capture output as screenshot |
|
||||||
|
| Tool restrictions | None - any valid tool that passes validation is allowed |
|
||||||
|
|
||||||
|
### Validation Steps
|
||||||
|
|
||||||
|
1. `pnpm blocks run <tool-name>` - domain rules and output measures
|
||||||
|
2. TypeScript compilation check
|
||||||
|
3. Execute tool with generated sample inputs
|
||||||
|
4. Verify output structure matches schema
|
||||||
|
5. Capture execution output as proof in issue comment
|
||||||
|
|
||||||
|
## Publishing
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
|---------|-------|
|
||||||
|
| Branch strategy | Auto-merge PR - create for visibility, auto-merge if CI passes |
|
||||||
|
| Version bump | Minor (0.X.0) - new functionality = minor version |
|
||||||
|
| NPM auth | Use existing `NPM_TOKEN` secret |
|
||||||
|
| On publish failure | Comment explaining failure, wait for human to fix and re-trigger |
|
||||||
|
|
||||||
|
### PR Template
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Tool: `<tool-name>`
|
||||||
|
|
||||||
|
**Package:** `@tpmjs/tools-<package>`
|
||||||
|
**Version:** `0.X.0` -> `0.Y.0`
|
||||||
|
|
||||||
|
### Description
|
||||||
|
<tool description>
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
- [ ] blocks.yml entry added
|
||||||
|
- [ ] Package files created
|
||||||
|
- [ ] Validation passed
|
||||||
|
- [ ] Runtime test passed
|
||||||
|
|
||||||
|
### Test Output
|
||||||
|
<screenshot of tool execution>
|
||||||
|
|
||||||
|
---
|
||||||
|
Auto-generated by Claude from #<issue-number>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Post-Publish
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
|---------|-------|
|
||||||
|
| Registry sync | Auto-sync - call `/api/sync/keyword` after publish |
|
||||||
|
| Verify listing | Confirm tool appears on tpmjs.com before reporting success |
|
||||||
|
| Collections | Standalone only - no auto-add |
|
||||||
|
| Duplicates | Propose enhancement to existing tool if duplicate detected |
|
||||||
|
|
||||||
|
## Status Tracking
|
||||||
|
|
||||||
|
### Labels (managed by Claude)
|
||||||
|
|
||||||
|
| Label | Meaning |
|
||||||
|
|-------|---------|
|
||||||
|
| `tool-request` | Initial trigger (applied by maintainer) |
|
||||||
|
| `claude-working` | Claude is actively processing |
|
||||||
|
| `validation-failed` | Validation failed, iterating |
|
||||||
|
| `published` | Successfully published to npm |
|
||||||
|
| `escalated` | Requires human intervention |
|
||||||
|
|
||||||
|
### Issue Lifecycle
|
||||||
|
|
||||||
|
1. Maintainer applies `tool-request` label
|
||||||
|
2. Workflow triggers, adds `claude-working` label
|
||||||
|
3. On validation failure: add `validation-failed`, retry (max 3x)
|
||||||
|
4. On success: remove other labels, add `published`
|
||||||
|
5. Keep issue open 24h for feedback
|
||||||
|
6. Auto-close after 24h
|
||||||
|
|
||||||
|
## Success Report
|
||||||
|
|
||||||
|
Full changelog posted to issue:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Tool Published Successfully
|
||||||
|
|
||||||
|
**Package:** `@tpmjs/tools-<package>@<version>`
|
||||||
|
**NPM:** https://www.npmjs.com/package/@tpmjs/tools-<package>
|
||||||
|
**Registry:** https://tpmjs.com/tool/@tpmjs/tools-<package>/<tool-name>
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Added `<tool-name>` tool
|
||||||
|
- Updated blocks.yml
|
||||||
|
- Bumped version from X.Y.Z to X.Y+1.0
|
||||||
|
|
||||||
|
### Validation Results
|
||||||
|
<validation output>
|
||||||
|
|
||||||
|
### Test Execution
|
||||||
|
<screenshot of tool running with sample inputs>
|
||||||
|
|
||||||
|
### Files Changed
|
||||||
|
<file diff summary>
|
||||||
|
|
||||||
|
---
|
||||||
|
This issue will auto-close in 24 hours. Reply if you have feedback.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
| Scenario | Action |
|
||||||
|
|----------|--------|
|
||||||
|
| Validation fails 3x | Add `escalated` label, assign to maintainer with diagnostic info |
|
||||||
|
| NPM publish fails | Comment explaining failure, wait for human fix |
|
||||||
|
| Duplicate detected | Comment explaining existing tool, propose enhancement instead |
|
||||||
|
| blocks.yml conflict | Rebase and retry automatically |
|
||||||
|
| Issue edited mid-work | Detect changes, incorporate into implementation |
|
||||||
|
|
||||||
|
## Context & Memory
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
|---------|-------|
|
||||||
|
| State tracking | Full conversation - Claude remembers entire issue thread |
|
||||||
|
| Previous attempts | Tracked within issue context |
|
||||||
|
| Cross-issue | No memory between different issues |
|
||||||
|
|
||||||
|
## Workflow File Structure
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: Tool Request Pipeline
|
||||||
|
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types: [labeled]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
trigger-claude:
|
||||||
|
if: github.event.label.name == 'tool-request'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Add working label
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
await github.rest.issues.addLabels({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
labels: ['claude-working']
|
||||||
|
});
|
||||||
|
|
||||||
|
- name: Comment to trigger Claude
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
body: '@claude Please implement this tool request following the tool-request pipeline specification at `.claude/pipelines/tool-request.md`.'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Claude Instructions
|
||||||
|
|
||||||
|
When triggered, Claude should:
|
||||||
|
|
||||||
|
1. **Read this spec** at `.claude/pipelines/tool-request.md`
|
||||||
|
2. **Analyze the issue** - extract tool name, description, intended functionality
|
||||||
|
3. **Check for duplicates** - search existing tools for similar functionality
|
||||||
|
4. **Select package** - analyze existing packages, decide new vs existing
|
||||||
|
5. **Design the tool** - define inputs, outputs, implementation approach
|
||||||
|
6. **Implement** - create/update blocks.yml, create package files
|
||||||
|
7. **Validate** - run `pnpm blocks run <tool>` in packages/tools/official
|
||||||
|
8. **Test** - execute with sample inputs, capture output
|
||||||
|
9. **Create PR** - feature branch, include all changes
|
||||||
|
10. **Publish** - after CI passes, `npm publish`
|
||||||
|
11. **Sync** - trigger registry sync
|
||||||
|
12. **Report** - full changelog to issue
|
||||||
|
13. **Cleanup** - update labels, schedule auto-close
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
- Only maintainers can apply trigger label
|
||||||
|
- NPM_TOKEN is existing secret, not exposed in logs
|
||||||
|
- Tool code is reviewed via PR (even if auto-merged)
|
||||||
|
- No restrictions on tool types - trust validation + maintainer judgment
|
||||||
|
- Full audit trail in issue comments
|
||||||
|
|
||||||
|
## Dry Run
|
||||||
|
|
||||||
|
No dry run mode. Validation is sufficient safeguard. If testing needed, create a test issue and manually delete artifacts after.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Specification created: 2026-01-19*
|
||||||
|
*Interview conducted with: @ajax*
|
||||||
16
.claude/plugins/ralph-wiggum/commands/cancel-ralph.md
Normal file
16
.claude/plugins/ralph-wiggum/commands/cancel-ralph.md
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
---
|
||||||
|
description: Cancel the active Ralph loop
|
||||||
|
command: rm -f .claude/ralph-loop.local.md && echo "Ralph loop cancelled"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Cancel Ralph Loop
|
||||||
|
|
||||||
|
Immediately cancel any active Ralph loop and allow normal session exit.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```
|
||||||
|
/cancel-ralph
|
||||||
|
```
|
||||||
|
|
||||||
|
This removes the state file that drives the loop, allowing the session to exit normally.
|
||||||
43
.claude/plugins/ralph-wiggum/commands/ralph-loop.md
Normal file
43
.claude/plugins/ralph-wiggum/commands/ralph-loop.md
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
---
|
||||||
|
description: Start Ralph Wiggum loop in current session
|
||||||
|
command: "${CLAUDE_PLUGIN_ROOT}/scripts/setup-ralph-loop.sh" $ARGUMENTS
|
||||||
|
---
|
||||||
|
|
||||||
|
# Ralph Loop
|
||||||
|
|
||||||
|
Start an iterative development loop that continues until the task is complete.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```
|
||||||
|
/ralph-loop "Your task description" [--max-iterations N] [--validation-script PATH] [--completion-promise TEXT]
|
||||||
|
```
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. You provide a task and optional validation criteria
|
||||||
|
2. Claude works on the task
|
||||||
|
3. When Claude tries to exit, the stop hook intercepts
|
||||||
|
4. If validation fails OR completion promise not met, the loop continues
|
||||||
|
5. Claude sees previous work and continues iterating
|
||||||
|
6. Loop ends when validation passes or max iterations reached
|
||||||
|
|
||||||
|
## Important Rules
|
||||||
|
|
||||||
|
- If a completion promise is set, you may ONLY output it when the statement is completely and unequivocally TRUE
|
||||||
|
- Do NOT use false completion promises as an exit strategy
|
||||||
|
- The loop persists until genuine completion is achieved
|
||||||
|
- Use validation scripts for programmatic verification
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# With validation script only
|
||||||
|
/ralph-loop "Build the SDK package" --validation-script ./scripts/validate-sdk.sh
|
||||||
|
|
||||||
|
# With completion promise
|
||||||
|
/ralph-loop "Fix all type errors" --completion-promise "ALL_TYPES_PASS"
|
||||||
|
|
||||||
|
# With both
|
||||||
|
/ralph-loop "Complete feature X" --max-iterations 15 --validation-script ./validate.sh --completion-promise "FEATURE_COMPLETE"
|
||||||
|
```
|
||||||
15
.claude/plugins/ralph-wiggum/hooks/hooks.json
Normal file
15
.claude/plugins/ralph-wiggum/hooks/hooks.json
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"description": "Ralph Wiggum plugin stop hook for self-referential loops",
|
||||||
|
"hooks": {
|
||||||
|
"Stop": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/stop-hook.sh"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
111
.claude/plugins/ralph-wiggum/hooks/stop-hook.sh
Executable file
111
.claude/plugins/ralph-wiggum/hooks/stop-hook.sh
Executable file
|
|
@ -0,0 +1,111 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Ralph Wiggum Stop Hook - Self-referential loop for iterative development
|
||||||
|
# This hook intercepts the Stop event and decides whether to continue the loop
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
STATE_FILE=".claude/ralph-loop.local.md"
|
||||||
|
TRANSCRIPT_FILE="${CLAUDE_TRANSCRIPT:-}"
|
||||||
|
|
||||||
|
# Check if ralph loop is active
|
||||||
|
if [[ ! -f "$STATE_FILE" ]]; then
|
||||||
|
# No active loop, allow normal exit
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Parse the state file frontmatter
|
||||||
|
parse_frontmatter() {
|
||||||
|
local key="$1"
|
||||||
|
sed -n '/^---$/,/^---$/p' "$STATE_FILE" | grep "^${key}:" | sed "s/^${key}: *//" | tr -d '"'
|
||||||
|
}
|
||||||
|
|
||||||
|
iteration=$(parse_frontmatter "iteration")
|
||||||
|
max_iterations=$(parse_frontmatter "max_iterations")
|
||||||
|
completion_promise=$(parse_frontmatter "completion_promise")
|
||||||
|
prompt=$(parse_frontmatter "prompt")
|
||||||
|
validation_script=$(parse_frontmatter "validation_script")
|
||||||
|
|
||||||
|
# Validate numeric fields
|
||||||
|
if ! [[ "$iteration" =~ ^[0-9]+$ ]]; then
|
||||||
|
echo "Error: Invalid iteration count in state file" >&2
|
||||||
|
rm -f "$STATE_FILE"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! [[ "$max_iterations" =~ ^[0-9]+$ ]]; then
|
||||||
|
echo "Error: Invalid max_iterations in state file" >&2
|
||||||
|
rm -f "$STATE_FILE"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if max iterations reached
|
||||||
|
if [[ "$max_iterations" -gt 0 ]] && [[ "$iteration" -ge "$max_iterations" ]]; then
|
||||||
|
echo "Ralph loop reached max iterations ($max_iterations). Exiting." >&2
|
||||||
|
rm -f "$STATE_FILE"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run validation script if provided
|
||||||
|
validation_passed=false
|
||||||
|
if [[ -n "$validation_script" ]] && [[ -f "$validation_script" ]]; then
|
||||||
|
echo "Running validation script: $validation_script" >&2
|
||||||
|
if bash "$validation_script" 2>&1; then
|
||||||
|
validation_passed=true
|
||||||
|
echo "Validation PASSED!" >&2
|
||||||
|
else
|
||||||
|
echo "Validation FAILED. Continuing loop..." >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for completion promise in transcript
|
||||||
|
if [[ -n "$completion_promise" ]] && [[ -n "$TRANSCRIPT_FILE" ]] && [[ -f "$TRANSCRIPT_FILE" ]]; then
|
||||||
|
# Get the last assistant message
|
||||||
|
last_message=$(tail -100 "$TRANSCRIPT_FILE" | grep -o '<promise>[^<]*</promise>' | tail -1 | sed 's/<promise>\(.*\)<\/promise>/\1/' || true)
|
||||||
|
|
||||||
|
if [[ "$last_message" == "$completion_promise" ]]; then
|
||||||
|
# Also check if validation passed (if validation script exists)
|
||||||
|
if [[ -z "$validation_script" ]] || [[ "$validation_passed" == "true" ]]; then
|
||||||
|
echo "Completion promise matched and validation passed. Ralph loop complete!" >&2
|
||||||
|
rm -f "$STATE_FILE"
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
echo "Completion promise matched but validation failed. Continuing..." >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If validation passed without explicit promise, we can exit
|
||||||
|
if [[ "$validation_passed" == "true" ]] && [[ -z "$completion_promise" ]]; then
|
||||||
|
echo "Validation passed. Ralph loop complete!" >&2
|
||||||
|
rm -f "$STATE_FILE"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increment iteration
|
||||||
|
new_iteration=$((iteration + 1))
|
||||||
|
|
||||||
|
# Update state file
|
||||||
|
sed -i.bak "s/^iteration: .*/iteration: $new_iteration/" "$STATE_FILE"
|
||||||
|
rm -f "${STATE_FILE}.bak"
|
||||||
|
|
||||||
|
# Build the continuation message
|
||||||
|
cat << EOF
|
||||||
|
{
|
||||||
|
"decision": "block",
|
||||||
|
"reason": "Ralph loop iteration $new_iteration of $max_iterations",
|
||||||
|
"message": "
|
||||||
|
---
|
||||||
|
RALPH LOOP - Iteration $new_iteration / $max_iterations
|
||||||
|
---
|
||||||
|
|
||||||
|
Continue working on the task. Your previous iteration's work is preserved in the codebase.
|
||||||
|
|
||||||
|
TASK: $prompt
|
||||||
|
|
||||||
|
$(if [[ -n "$validation_script" ]]; then echo "VALIDATION: Run the validation to check progress. Script: $validation_script"; fi)
|
||||||
|
$(if [[ -n "$completion_promise" ]]; then echo "COMPLETION: Output <promise>$completion_promise</promise> ONLY when the task is completely done AND validation passes."; fi)
|
||||||
|
|
||||||
|
Review what you've done so far and continue from where you left off.
|
||||||
|
"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
120
.claude/plugins/ralph-wiggum/scripts/setup-ralph-loop.sh
Executable file
120
.claude/plugins/ralph-wiggum/scripts/setup-ralph-loop.sh
Executable file
|
|
@ -0,0 +1,120 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Setup Ralph Loop - Initialize the iterative development loop
|
||||||
|
# Usage: setup-ralph-loop.sh "PROMPT" [--max-iterations N] [--completion-promise TEXT] [--validation-script PATH]
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
STATE_FILE=".claude/ralph-loop.local.md"
|
||||||
|
|
||||||
|
# Default values
|
||||||
|
MAX_ITERATIONS=20
|
||||||
|
COMPLETION_PROMISE=""
|
||||||
|
VALIDATION_SCRIPT=""
|
||||||
|
PROMPT=""
|
||||||
|
|
||||||
|
# Parse arguments
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case $1 in
|
||||||
|
--max-iterations)
|
||||||
|
MAX_ITERATIONS="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--completion-promise)
|
||||||
|
COMPLETION_PROMISE="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--validation-script)
|
||||||
|
VALIDATION_SCRIPT="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--help|-h)
|
||||||
|
cat << EOF
|
||||||
|
Ralph Loop Setup
|
||||||
|
|
||||||
|
Usage: setup-ralph-loop.sh "PROMPT" [OPTIONS]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--max-iterations N Maximum iterations before stopping (default: 20, 0 = unlimited)
|
||||||
|
--completion-promise TXT Phrase to output when complete (use <promise>TXT</promise>)
|
||||||
|
--validation-script PATH Script to run for validation (exit 0 = pass)
|
||||||
|
--help, -h Show this help
|
||||||
|
|
||||||
|
Example:
|
||||||
|
setup-ralph-loop.sh "Build the SDK package" --max-iterations 10 --validation-script ./validate.sh
|
||||||
|
EOF
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
if [[ -z "$PROMPT" ]]; then
|
||||||
|
PROMPT="$1"
|
||||||
|
else
|
||||||
|
PROMPT="$PROMPT $1"
|
||||||
|
fi
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Validate prompt
|
||||||
|
if [[ -z "$PROMPT" ]]; then
|
||||||
|
echo "Error: PROMPT is required" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Validate max iterations
|
||||||
|
if ! [[ "$MAX_ITERATIONS" =~ ^[0-9]+$ ]]; then
|
||||||
|
echo "Error: --max-iterations must be a number" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Validate validation script exists if provided
|
||||||
|
if [[ -n "$VALIDATION_SCRIPT" ]] && [[ ! -f "$VALIDATION_SCRIPT" ]]; then
|
||||||
|
echo "Error: Validation script not found: $VALIDATION_SCRIPT" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create state directory
|
||||||
|
mkdir -p "$(dirname "$STATE_FILE")"
|
||||||
|
|
||||||
|
# Create state file
|
||||||
|
cat << EOF > "$STATE_FILE"
|
||||||
|
---
|
||||||
|
iteration: 1
|
||||||
|
max_iterations: $MAX_ITERATIONS
|
||||||
|
completion_promise: "$COMPLETION_PROMISE"
|
||||||
|
validation_script: "$VALIDATION_SCRIPT"
|
||||||
|
prompt: "$PROMPT"
|
||||||
|
started_at: "$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Ralph Loop State
|
||||||
|
|
||||||
|
This file tracks the state of an active Ralph loop. DO NOT DELETE while loop is running.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
- **Task**: $PROMPT
|
||||||
|
- **Max Iterations**: $MAX_ITERATIONS
|
||||||
|
- **Validation Script**: ${VALIDATION_SCRIPT:-"None"}
|
||||||
|
- **Completion Promise**: ${COMPLETION_PROMISE:-"None (validation only)"}
|
||||||
|
|
||||||
|
## Progress Log
|
||||||
|
|
||||||
|
Iteration logs will be appended below as the loop progresses.
|
||||||
|
|
||||||
|
---
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "Ralph loop initialized!"
|
||||||
|
echo " Task: $PROMPT"
|
||||||
|
echo " Max iterations: $MAX_ITERATIONS"
|
||||||
|
echo " Validation: ${VALIDATION_SCRIPT:-"None"}"
|
||||||
|
echo " Completion promise: ${COMPLETION_PROMISE:-"None"}"
|
||||||
|
echo ""
|
||||||
|
echo "The loop will continue until:"
|
||||||
|
if [[ -n "$VALIDATION_SCRIPT" ]]; then
|
||||||
|
echo " - Validation script passes ($VALIDATION_SCRIPT returns exit code 0)"
|
||||||
|
fi
|
||||||
|
if [[ -n "$COMPLETION_PROMISE" ]]; then
|
||||||
|
echo " - You output: <promise>$COMPLETION_PROMISE</promise>"
|
||||||
|
fi
|
||||||
|
echo " - OR max iterations ($MAX_ITERATIONS) is reached"
|
||||||
1
.claude/skills/agentmail
Symbolic link
1
.claude/skills/agentmail
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
../../.agents/skills/agentmail
|
||||||
357
.claude/skills/blocks-develop.md
Normal file
357
.claude/skills/blocks-develop.md
Normal file
|
|
@ -0,0 +1,357 @@
|
||||||
|
# TPMJS Tool Development with Blocks CLI
|
||||||
|
|
||||||
|
Use this skill when developing new tools for the TPMJS registry. This covers the full workflow from defining a tool in blocks.yml through implementation, validation, and publishing.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Navigate to official tools directory
|
||||||
|
cd packages/tools/official
|
||||||
|
|
||||||
|
# Run validation on a specific tool
|
||||||
|
pnpm blocks run <block-name>
|
||||||
|
|
||||||
|
# Run validation on all tools
|
||||||
|
pnpm blocks run --all
|
||||||
|
|
||||||
|
# Force full validation (ignore cache)
|
||||||
|
pnpm blocks run <block-name> --force
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
### 1. Define the Tool Block in blocks.yml
|
||||||
|
|
||||||
|
Add your tool definition to `packages/tools/official/blocks.yml` in the `blocks:` section:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
blocks:
|
||||||
|
# Category.toolName format
|
||||||
|
sandbox.myTool:
|
||||||
|
type: utility
|
||||||
|
description: "Clear, LLM-friendly description of what the tool does"
|
||||||
|
path: "my-tool" # Directory name under packages/tools/official/
|
||||||
|
domain_rules:
|
||||||
|
- id: rule_name
|
||||||
|
description: "What this implementation must do"
|
||||||
|
inputs:
|
||||||
|
- name: inputName
|
||||||
|
type: string
|
||||||
|
description: "Description for LLMs"
|
||||||
|
- name: optionalInput
|
||||||
|
type: number
|
||||||
|
optional: true
|
||||||
|
description: "Optional parameter"
|
||||||
|
outputs:
|
||||||
|
- name: result
|
||||||
|
type: MyResultType
|
||||||
|
description: "What the tool returns"
|
||||||
|
measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Fields:**
|
||||||
|
- `type`: Usually `utility` for single-shot tools
|
||||||
|
- `path`: Directory name (kebab-case)
|
||||||
|
- `domain_rules`: Implementation requirements the validator checks
|
||||||
|
- `inputs/outputs`: Schema for validation
|
||||||
|
- `measures`: Quality constraints from the domain section
|
||||||
|
|
||||||
|
### 2. Create the Tool Package
|
||||||
|
|
||||||
|
Create the directory structure:
|
||||||
|
|
||||||
|
```
|
||||||
|
packages/tools/official/my-tool/
|
||||||
|
├── package.json
|
||||||
|
├── tsconfig.json
|
||||||
|
├── tsup.config.ts
|
||||||
|
├── block.ts # Required by validator
|
||||||
|
├── index.ts # Re-export from src
|
||||||
|
└── src/
|
||||||
|
└── index.ts # Main implementation
|
||||||
|
```
|
||||||
|
|
||||||
|
**package.json:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "@tpmjs/tools-my-tool",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Short description for npm",
|
||||||
|
"type": "module",
|
||||||
|
"keywords": ["tpmjs", "category-name", "ai"],
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"default": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": ["dist"],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsup",
|
||||||
|
"dev": "tsup --watch",
|
||||||
|
"type-check": "tsc --noEmit",
|
||||||
|
"clean": "rm -rf dist .turbo"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tpmjs/tsconfig": "workspace:*",
|
||||||
|
"tsup": "^8.5.1",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"ai": "6.0.23"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/tpmjs/tpmjs.git",
|
||||||
|
"directory": "packages/tools/official/my-tool"
|
||||||
|
},
|
||||||
|
"homepage": "https://tpmjs.com",
|
||||||
|
"license": "MIT",
|
||||||
|
"tpmjs": {
|
||||||
|
"category": "sandbox",
|
||||||
|
"frameworks": ["vercel-ai"],
|
||||||
|
"tools": [
|
||||||
|
{
|
||||||
|
"name": "myTool",
|
||||||
|
"description": "Clear description (20+ chars) of what this tool does."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Valid categories for tpmjs.category:**
|
||||||
|
- `research`, `web`, `data`, `documentation`, `engineering`
|
||||||
|
- `security`, `statistics`, `ops`, `agent`, `sandbox`, `utilities`
|
||||||
|
- `html`, `compliance`
|
||||||
|
|
||||||
|
**tsconfig.json:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"extends": "@tpmjs/tsconfig/react-library.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**tsup.config.ts:**
|
||||||
|
```typescript
|
||||||
|
import { defineConfig } from 'tsup';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
entry: ['src/index.ts'],
|
||||||
|
format: ['esm'],
|
||||||
|
dts: true,
|
||||||
|
clean: true,
|
||||||
|
sourcemap: true,
|
||||||
|
target: 'es2022',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**block.ts (Required by validator):**
|
||||||
|
```typescript
|
||||||
|
import { myTool } from './src/index.js';
|
||||||
|
|
||||||
|
export const block = {
|
||||||
|
name: 'my-tool',
|
||||||
|
description: 'Short description',
|
||||||
|
tools: { myTool },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default block;
|
||||||
|
```
|
||||||
|
|
||||||
|
**index.ts (Root re-export):**
|
||||||
|
```typescript
|
||||||
|
export * from './src/index.js';
|
||||||
|
export { default } from './src/index.js';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Implement the Tool
|
||||||
|
|
||||||
|
**src/index.ts:**
|
||||||
|
```typescript
|
||||||
|
import { jsonSchema, tool } from 'ai';
|
||||||
|
|
||||||
|
// Define input/output types
|
||||||
|
interface MyToolInput {
|
||||||
|
param1: string;
|
||||||
|
param2?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MyToolResult {
|
||||||
|
data: string;
|
||||||
|
metadata: {
|
||||||
|
processedAt: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export the tool using AI SDK v6 pattern
|
||||||
|
export const myTool = tool({
|
||||||
|
description: 'Clear description for LLMs explaining what this tool does and when to use it.',
|
||||||
|
parameters: jsonSchema<MyToolInput>({
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
param1: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Description of param1',
|
||||||
|
},
|
||||||
|
param2: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Optional description of param2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['param1'],
|
||||||
|
}),
|
||||||
|
async execute(input): Promise<MyToolResult> {
|
||||||
|
// REAL implementation - no stubs, no TODOs
|
||||||
|
const result = await doSomething(input.param1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: result,
|
||||||
|
metadata: {
|
||||||
|
processedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Default export for compatibility
|
||||||
|
export default myTool;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Run Validation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packages/tools/official
|
||||||
|
|
||||||
|
# Validate your tool
|
||||||
|
pnpm blocks run my-tool
|
||||||
|
|
||||||
|
# The validator runs 3 stages:
|
||||||
|
# 1. schema - Validates inputs/outputs match blocks.yml
|
||||||
|
# 2. shape - Verifies exports and structure
|
||||||
|
# 3. domain - Checks domain rules are satisfied
|
||||||
|
```
|
||||||
|
|
||||||
|
**Common validation errors:**
|
||||||
|
- `Required file "index.ts" not found` - Need index.ts at package root
|
||||||
|
- `Required file "block.ts" not found` - Need block.ts at package root
|
||||||
|
- `Tool "myTool" not found in exports` - Export name must match blocks.yml
|
||||||
|
- `invalid tpmjs field` - Category must be valid, tools array required
|
||||||
|
|
||||||
|
### 5. Build and Publish
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build the package
|
||||||
|
pnpm build
|
||||||
|
|
||||||
|
# Publish to npm
|
||||||
|
npm publish --access public
|
||||||
|
|
||||||
|
# Trigger sync to tpmjs.com
|
||||||
|
source apps/web/.env.local
|
||||||
|
curl -X POST https://tpmjs.com/api/sync/keyword \
|
||||||
|
-H "Authorization: Bearer $CRON_SECRET"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Multi-Tool Packages
|
||||||
|
|
||||||
|
For packages with multiple tools (like unsandbox):
|
||||||
|
|
||||||
|
**blocks.yml:**
|
||||||
|
```yaml
|
||||||
|
blocks:
|
||||||
|
sandbox.executeCodeAsync:
|
||||||
|
type: utility
|
||||||
|
path: "unsandbox" # Same path for all tools in package
|
||||||
|
# ...
|
||||||
|
|
||||||
|
sandbox.getJob:
|
||||||
|
type: utility
|
||||||
|
path: "unsandbox" # Same path
|
||||||
|
# ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**block.ts:**
|
||||||
|
```typescript
|
||||||
|
import { executeCodeAsync, getJob, listJobs } from './src/index.js';
|
||||||
|
|
||||||
|
export const block = {
|
||||||
|
name: 'unsandbox',
|
||||||
|
tools: { executeCodeAsync, getJob, listJobs },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default block;
|
||||||
|
```
|
||||||
|
|
||||||
|
**package.json tpmjs field:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tpmjs": {
|
||||||
|
"category": "sandbox",
|
||||||
|
"frameworks": ["vercel-ai"],
|
||||||
|
"tools": [
|
||||||
|
{ "name": "executeCodeAsync", "description": "..." },
|
||||||
|
{ "name": "getJob", "description": "..." },
|
||||||
|
{ "name": "listJobs", "description": "..." }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Philosophy (from blocks.yml)
|
||||||
|
|
||||||
|
- Every tool MUST be a working, production-ready implementation - no stubs, no TODOs
|
||||||
|
- Tools use AI SDK v6 `tool()` + `jsonSchema()` pattern exclusively
|
||||||
|
- Each tool does ONE thing exceptionally well (single-shot, one call in, one result out)
|
||||||
|
- Tools return structured, typed outputs that agents can reliably parse
|
||||||
|
- Error handling is explicit - throw meaningful errors, never silently fail
|
||||||
|
- Dependencies are minimal and production-stable
|
||||||
|
|
||||||
|
## Domain Entities
|
||||||
|
|
||||||
|
When defining outputs, reference existing entities from blocks.yml:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Example entities available:
|
||||||
|
url: [href, domain, protocol, path, query, fragment]
|
||||||
|
webpage: [url, title, html, text, metadata]
|
||||||
|
text_content: [raw, sentences, paragraphs, wordCount]
|
||||||
|
claim: [statement, confidence, needsCitation, category]
|
||||||
|
timeline: [events, dateRange, gaps, eventCount]
|
||||||
|
```
|
||||||
|
|
||||||
|
Or define new entities in the `domain.entities` section if needed.
|
||||||
|
|
||||||
|
## Quality Measures
|
||||||
|
|
||||||
|
Reference these in your tool's `measures` array:
|
||||||
|
|
||||||
|
- `working_implementation` - No stubs, TODOs, or placeholders
|
||||||
|
- `valid_output_structure` - Returns correct typed object
|
||||||
|
- `proper_error_handling` - Throws descriptive errors
|
||||||
|
- `ai_sdk_compliance` - Uses tool() and jsonSchema()
|
||||||
|
- `npm_publishable` - Valid package.json with tpmjs field
|
||||||
|
- `readme_documentation` - Has README with examples
|
||||||
|
|
||||||
|
## Debugging Tips
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Force rebuild without cache
|
||||||
|
pnpm blocks run my-tool --force --no-cache
|
||||||
|
|
||||||
|
# See JSON output for debugging
|
||||||
|
pnpm blocks run my-tool --json
|
||||||
|
|
||||||
|
# Check if validator finds your package
|
||||||
|
ls packages/tools/official/my-tool/
|
||||||
|
# Must have: index.ts, block.ts at root level
|
||||||
|
```
|
||||||
43
.claude/skills/remotion-best-practices/SKILL.md
Normal file
43
.claude/skills/remotion-best-practices/SKILL.md
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
---
|
||||||
|
name: remotion-best-practices
|
||||||
|
description: Best practices for Remotion - Video creation in React
|
||||||
|
metadata:
|
||||||
|
tags: remotion, video, react, animation, composition
|
||||||
|
---
|
||||||
|
|
||||||
|
## When to use
|
||||||
|
|
||||||
|
Use this skills whenever you are dealing with Remotion code to obtain the domain-specific knowledge.
|
||||||
|
|
||||||
|
## How to use
|
||||||
|
|
||||||
|
Read individual rule files for detailed explanations and code examples:
|
||||||
|
|
||||||
|
- [rules/3d.md](rules/3d.md) - 3D content in Remotion using Three.js and React Three Fiber
|
||||||
|
- [rules/animations.md](rules/animations.md) - Fundamental animation skills for Remotion
|
||||||
|
- [rules/assets.md](rules/assets.md) - Importing images, videos, audio, and fonts into Remotion
|
||||||
|
- [rules/audio.md](rules/audio.md) - Using audio and sound in Remotion - importing, trimming, volume, speed, pitch
|
||||||
|
- [rules/calculate-metadata.md](rules/calculate-metadata.md) - Dynamically set composition duration, dimensions, and props
|
||||||
|
- [rules/can-decode.md](rules/can-decode.md) - Check if a video can be decoded by the browser using Mediabunny
|
||||||
|
- [rules/charts.md](rules/charts.md) - Chart and data visualization patterns for Remotion
|
||||||
|
- [rules/compositions.md](rules/compositions.md) - Defining compositions, stills, folders, default props and dynamic metadata
|
||||||
|
- [rules/display-captions.md](rules/display-captions.md) - Displaying captions in Remotion with TikTok-style pages and word highlighting
|
||||||
|
- [rules/extract-frames.md](rules/extract-frames.md) - Extract frames from videos at specific timestamps using Mediabunny
|
||||||
|
- [rules/fonts.md](rules/fonts.md) - Loading Google Fonts and local fonts in Remotion
|
||||||
|
- [rules/get-audio-duration.md](rules/get-audio-duration.md) - Getting the duration of an audio file in seconds with Mediabunny
|
||||||
|
- [rules/get-video-dimensions.md](rules/get-video-dimensions.md) - Getting the width and height of a video file with Mediabunny
|
||||||
|
- [rules/get-video-duration.md](rules/get-video-duration.md) - Getting the duration of a video file in seconds with Mediabunny
|
||||||
|
- [rules/gifs.md](rules/gifs.md) - Displaying GIFs synchronized with Remotion's timeline
|
||||||
|
- [rules/images.md](rules/images.md) - Embedding images in Remotion using the Img component
|
||||||
|
- [rules/import-srt-captions.md](rules/import-srt-captions.md) - Importing .srt subtitle files into Remotion using @remotion/captions
|
||||||
|
- [rules/lottie.md](rules/lottie.md) - Embedding Lottie animations in Remotion
|
||||||
|
- [rules/measuring-dom-nodes.md](rules/measuring-dom-nodes.md) - Measuring DOM element dimensions in Remotion
|
||||||
|
- [rules/measuring-text.md](rules/measuring-text.md) - Measuring text dimensions, fitting text to containers, and checking overflow
|
||||||
|
- [rules/sequencing.md](rules/sequencing.md) - Sequencing patterns for Remotion - delay, trim, limit duration of items
|
||||||
|
- [rules/tailwind.md](rules/tailwind.md) - Using TailwindCSS in Remotion
|
||||||
|
- [rules/text-animations.md](rules/text-animations.md) - Typography and text animation patterns for Remotion
|
||||||
|
- [rules/timing.md](rules/timing.md) - Interpolation curves in Remotion - linear, easing, spring animations
|
||||||
|
- [rules/transcribe-captions.md](rules/transcribe-captions.md) - Transcribing audio to generate captions in Remotion
|
||||||
|
- [rules/transitions.md](rules/transitions.md) - Scene transition patterns for Remotion
|
||||||
|
- [rules/trimming.md](rules/trimming.md) - Trimming patterns for Remotion - cut the beginning or end of animations
|
||||||
|
- [rules/videos.md](rules/videos.md) - Embedding videos in Remotion - trimming, volume, speed, looping, pitch
|
||||||
86
.claude/skills/remotion-best-practices/rules/3d.md
Normal file
86
.claude/skills/remotion-best-practices/rules/3d.md
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
---
|
||||||
|
name: 3d
|
||||||
|
description: 3D content in Remotion using Three.js and React Three Fiber.
|
||||||
|
metadata:
|
||||||
|
tags: 3d, three, threejs
|
||||||
|
---
|
||||||
|
|
||||||
|
# Using Three.js and React Three Fiber in Remotion
|
||||||
|
|
||||||
|
Follow React Three Fiber and Three.js best practices.
|
||||||
|
Only the following Remotion-specific rules need to be followed:
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
First, the `@remotion/three` package needs to be installed.
|
||||||
|
If it is not, use the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx remotion add @remotion/three # If project uses npm
|
||||||
|
bunx remotion add @remotion/three # If project uses bun
|
||||||
|
yarn remotion add @remotion/three # If project uses yarn
|
||||||
|
pnpm exec remotion add @remotion/three # If project uses pnpm
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using ThreeCanvas
|
||||||
|
|
||||||
|
You MUST wrap 3D content in `<ThreeCanvas>` and include proper lighting.
|
||||||
|
`<ThreeCanvas>` MUST have a `width` and `height` prop.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ThreeCanvas } from "@remotion/three";
|
||||||
|
import { useVideoConfig } from "remotion";
|
||||||
|
|
||||||
|
const { width, height } = useVideoConfig();
|
||||||
|
|
||||||
|
<ThreeCanvas width={width} height={height}>
|
||||||
|
<ambientLight intensity={0.4} />
|
||||||
|
<directionalLight position={[5, 5, 5]} intensity={0.8} />
|
||||||
|
<mesh>
|
||||||
|
<sphereGeometry args={[1, 32, 32]} />
|
||||||
|
<meshStandardMaterial color="red" />
|
||||||
|
</mesh>
|
||||||
|
</ThreeCanvas>
|
||||||
|
```
|
||||||
|
|
||||||
|
## No animations not driven by `useCurrentFrame()`
|
||||||
|
|
||||||
|
Shaders, models etc MUST NOT animate by themselves.
|
||||||
|
No animations are allowed unless they are driven by `useCurrentFrame()`.
|
||||||
|
Otherwise, it will cause flickering during rendering.
|
||||||
|
|
||||||
|
Using `useFrame()` from `@react-three/fiber` is forbidden.
|
||||||
|
|
||||||
|
## Animate using `useCurrentFrame()`
|
||||||
|
|
||||||
|
Use `useCurrentFrame()` to perform animations.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const rotationY = frame * 0.02;
|
||||||
|
|
||||||
|
<mesh rotation={[0, rotationY, 0]}>
|
||||||
|
<boxGeometry args={[2, 2, 2]} />
|
||||||
|
<meshStandardMaterial color="#4a9eff" />
|
||||||
|
</mesh>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using `<Sequence>` inside `<ThreeCanvas>`
|
||||||
|
|
||||||
|
The `layout` prop of any `<Sequence>` inside a `<ThreeCanvas>` must be set to `none`.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Sequence } from "remotion";
|
||||||
|
import { ThreeCanvas } from "@remotion/three";
|
||||||
|
|
||||||
|
const { width, height } = useVideoConfig();
|
||||||
|
|
||||||
|
<ThreeCanvas width={width} height={height}>
|
||||||
|
<Sequence layout="none">
|
||||||
|
<mesh>
|
||||||
|
<boxGeometry args={[2, 2, 2]} />
|
||||||
|
<meshStandardMaterial color="#4a9eff" />
|
||||||
|
</mesh>
|
||||||
|
</Sequence>
|
||||||
|
</ThreeCanvas>
|
||||||
|
```
|
||||||
29
.claude/skills/remotion-best-practices/rules/animations.md
Normal file
29
.claude/skills/remotion-best-practices/rules/animations.md
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
---
|
||||||
|
name: animations
|
||||||
|
description: Fundamental animation skills for Remotion
|
||||||
|
metadata:
|
||||||
|
tags: animations, transitions, frames, useCurrentFrame
|
||||||
|
---
|
||||||
|
|
||||||
|
All animations MUST be driven by the `useCurrentFrame()` hook.
|
||||||
|
Write animations in seconds and multiply them by the `fps` value from `useVideoConfig()`.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useCurrentFrame } from "remotion";
|
||||||
|
|
||||||
|
export const FadeIn = () => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const { fps } = useVideoConfig();
|
||||||
|
|
||||||
|
const opacity = interpolate(frame, [0, 2 * fps], [0, 1], {
|
||||||
|
extrapolateRight: 'clamp',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ opacity }}>Hello World!</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
CSS transitions or animations are FORBIDDEN - they will not render correctly.
|
||||||
|
Tailwind animation class names are FORBIDDEN - they will not render correctly.
|
||||||
78
.claude/skills/remotion-best-practices/rules/assets.md
Normal file
78
.claude/skills/remotion-best-practices/rules/assets.md
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
---
|
||||||
|
name: assets
|
||||||
|
description: Importing images, videos, audio, and fonts into Remotion
|
||||||
|
metadata:
|
||||||
|
tags: assets, staticFile, images, fonts, public
|
||||||
|
---
|
||||||
|
|
||||||
|
# Importing assets in Remotion
|
||||||
|
|
||||||
|
## The public folder
|
||||||
|
|
||||||
|
Place assets in the `public/` folder at your project root.
|
||||||
|
|
||||||
|
## Using staticFile()
|
||||||
|
|
||||||
|
You MUST use `staticFile()` to reference files from the `public/` folder:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {Img, staticFile} from 'remotion';
|
||||||
|
|
||||||
|
export const MyComposition = () => {
|
||||||
|
return <Img src={staticFile('logo.png')} />;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
The function returns an encoded URL that works correctly when deploying to subdirectories.
|
||||||
|
|
||||||
|
## Using with components
|
||||||
|
|
||||||
|
**Images:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {Img, staticFile} from 'remotion';
|
||||||
|
|
||||||
|
<Img src={staticFile('photo.png')} />;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Videos:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {Video} from '@remotion/media';
|
||||||
|
import {staticFile} from 'remotion';
|
||||||
|
|
||||||
|
<Video src={staticFile('clip.mp4')} />;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Audio:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {Audio} from '@remotion/media';
|
||||||
|
import {staticFile} from 'remotion';
|
||||||
|
|
||||||
|
<Audio src={staticFile('music.mp3')} />;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fonts:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {staticFile} from 'remotion';
|
||||||
|
|
||||||
|
const fontFamily = new FontFace('MyFont', `url(${staticFile('font.woff2')})`);
|
||||||
|
await fontFamily.load();
|
||||||
|
document.fonts.add(fontFamily);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Remote URLs
|
||||||
|
|
||||||
|
Remote URLs can be used directly without `staticFile()`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Img src="https://example.com/image.png" />
|
||||||
|
<Video src="https://remotion.media/video.mp4" />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Important notes
|
||||||
|
|
||||||
|
- Remotion components (`<Img>`, `<Video>`, `<Audio>`) ensure assets are fully loaded before rendering
|
||||||
|
- Special characters in filenames (`#`, `?`, `&`) are automatically encoded
|
||||||
|
|
@ -0,0 +1,165 @@
|
||||||
|
import { loadFont } from '@remotion/google-fonts/Inter';
|
||||||
|
import { AbsoluteFill, spring, useCurrentFrame, useVideoConfig } from 'remotion';
|
||||||
|
|
||||||
|
const { fontFamily } = loadFont();
|
||||||
|
|
||||||
|
const COLOR_BAR = '#D4AF37';
|
||||||
|
const COLOR_TEXT = '#ffffff';
|
||||||
|
const COLOR_MUTED = '#888888';
|
||||||
|
const COLOR_BG = '#0a0a0a';
|
||||||
|
const COLOR_AXIS = '#333333';
|
||||||
|
|
||||||
|
// Ideal composition size: 1280x720
|
||||||
|
|
||||||
|
const Title: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: 40 }}>
|
||||||
|
<div style={{ color: COLOR_TEXT, fontSize: 48, fontWeight: 600 }}>{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const YAxis: React.FC<{ steps: number[]; height: number }> = ({ steps, height }) => (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
height,
|
||||||
|
paddingRight: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{steps
|
||||||
|
.slice()
|
||||||
|
.reverse()
|
||||||
|
.map((step) => (
|
||||||
|
<div
|
||||||
|
key={step}
|
||||||
|
style={{
|
||||||
|
color: COLOR_MUTED,
|
||||||
|
fontSize: 20,
|
||||||
|
textAlign: 'right',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{step.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Bar: React.FC<{
|
||||||
|
height: number;
|
||||||
|
progress: number;
|
||||||
|
}> = ({ height, progress }) => (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height,
|
||||||
|
backgroundColor: COLOR_BAR,
|
||||||
|
borderRadius: '8px 8px 0 0',
|
||||||
|
opacity: progress,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const XAxis: React.FC<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
labels: string[];
|
||||||
|
height: number;
|
||||||
|
}> = ({ children, labels, height }) => (
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
gap: 16,
|
||||||
|
height,
|
||||||
|
borderLeft: `2px solid ${COLOR_AXIS}`,
|
||||||
|
borderBottom: `2px solid ${COLOR_AXIS}`,
|
||||||
|
paddingLeft: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 16,
|
||||||
|
paddingLeft: 16,
|
||||||
|
marginTop: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{labels.map((label) => (
|
||||||
|
<div
|
||||||
|
key={label}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
textAlign: 'center',
|
||||||
|
color: COLOR_MUTED,
|
||||||
|
fontSize: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const MyAnimation = () => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const { fps, height } = useVideoConfig();
|
||||||
|
|
||||||
|
const data = [
|
||||||
|
{ month: 'Jan', price: 2039 },
|
||||||
|
{ month: 'Mar', price: 2160 },
|
||||||
|
{ month: 'May', price: 2327 },
|
||||||
|
{ month: 'Jul', price: 2426 },
|
||||||
|
{ month: 'Sep', price: 2634 },
|
||||||
|
{ month: 'Nov', price: 2672 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const minPrice = 2000;
|
||||||
|
const maxPrice = 2800;
|
||||||
|
const priceRange = maxPrice - minPrice;
|
||||||
|
const chartHeight = height - 280;
|
||||||
|
const yAxisSteps = [2000, 2400, 2800];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AbsoluteFill
|
||||||
|
style={{
|
||||||
|
backgroundColor: COLOR_BG,
|
||||||
|
padding: 60,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
fontFamily,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Title>Gold Price 2024</Title>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flex: 1 }}>
|
||||||
|
<YAxis steps={yAxisSteps} height={chartHeight} />
|
||||||
|
<XAxis height={chartHeight} labels={data.map((d) => d.month)}>
|
||||||
|
{data.map((item, i) => {
|
||||||
|
const progress = spring({
|
||||||
|
frame: frame - i * 5 - 10,
|
||||||
|
fps,
|
||||||
|
config: { damping: 18, stiffness: 80 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const barHeight = ((item.price - minPrice) / priceRange) * chartHeight * progress;
|
||||||
|
|
||||||
|
return <Bar key={item.month} height={barHeight} progress={progress} />;
|
||||||
|
})}
|
||||||
|
</XAxis>
|
||||||
|
</div>
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
import { AbsoluteFill, interpolate, useCurrentFrame, useVideoConfig } from 'remotion';
|
||||||
|
|
||||||
|
const COLOR_BG = '#ffffff';
|
||||||
|
const COLOR_TEXT = '#000000';
|
||||||
|
const FULL_TEXT = 'From prompt to motion graphics. This is Remotion.';
|
||||||
|
const PAUSE_AFTER = 'From prompt to motion graphics.';
|
||||||
|
const FONT_SIZE = 72;
|
||||||
|
const FONT_WEIGHT = 700;
|
||||||
|
const CHAR_FRAMES = 2;
|
||||||
|
const CURSOR_BLINK_FRAMES = 16;
|
||||||
|
const PAUSE_SECONDS = 1;
|
||||||
|
|
||||||
|
// Ideal composition size: 1280x720
|
||||||
|
|
||||||
|
const getTypedText = ({
|
||||||
|
frame,
|
||||||
|
fullText,
|
||||||
|
pauseAfter,
|
||||||
|
charFrames,
|
||||||
|
pauseFrames,
|
||||||
|
}: {
|
||||||
|
frame: number;
|
||||||
|
fullText: string;
|
||||||
|
pauseAfter: string;
|
||||||
|
charFrames: number;
|
||||||
|
pauseFrames: number;
|
||||||
|
}): string => {
|
||||||
|
const pauseIndex = fullText.indexOf(pauseAfter);
|
||||||
|
const preLen = pauseIndex >= 0 ? pauseIndex + pauseAfter.length : fullText.length;
|
||||||
|
|
||||||
|
let typedChars = 0;
|
||||||
|
if (frame < preLen * charFrames) {
|
||||||
|
typedChars = Math.floor(frame / charFrames);
|
||||||
|
} else if (frame < preLen * charFrames + pauseFrames) {
|
||||||
|
typedChars = preLen;
|
||||||
|
} else {
|
||||||
|
const postPhase = frame - preLen * charFrames - pauseFrames;
|
||||||
|
typedChars = Math.min(fullText.length, preLen + Math.floor(postPhase / charFrames));
|
||||||
|
}
|
||||||
|
return fullText.slice(0, typedChars);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Cursor: React.FC<{
|
||||||
|
frame: number;
|
||||||
|
blinkFrames: number;
|
||||||
|
symbol?: string;
|
||||||
|
}> = ({ frame, blinkFrames, symbol = '\u258C' }) => {
|
||||||
|
const opacity = interpolate(frame % blinkFrames, [0, blinkFrames / 2, blinkFrames], [1, 0, 1], {
|
||||||
|
extrapolateLeft: 'clamp',
|
||||||
|
extrapolateRight: 'clamp',
|
||||||
|
});
|
||||||
|
|
||||||
|
return <span style={{ opacity }}>{symbol}</span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MyAnimation = () => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const { fps } = useVideoConfig();
|
||||||
|
|
||||||
|
const pauseFrames = Math.round(fps * PAUSE_SECONDS);
|
||||||
|
|
||||||
|
const typedText = getTypedText({
|
||||||
|
frame,
|
||||||
|
fullText: FULL_TEXT,
|
||||||
|
pauseAfter: PAUSE_AFTER,
|
||||||
|
charFrames: CHAR_FRAMES,
|
||||||
|
pauseFrames,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AbsoluteFill
|
||||||
|
style={{
|
||||||
|
backgroundColor: COLOR_BG,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: COLOR_TEXT,
|
||||||
|
fontSize: FONT_SIZE,
|
||||||
|
fontWeight: FONT_WEIGHT,
|
||||||
|
fontFamily: 'sans-serif',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{typedText}</span>
|
||||||
|
<Cursor frame={frame} blinkFrames={CURSOR_BLINK_FRAMES} />
|
||||||
|
</div>
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
import { loadFont } from '@remotion/google-fonts/Inter';
|
||||||
|
import type React from 'react';
|
||||||
|
import { AbsoluteFill, spring, useCurrentFrame, useVideoConfig } from 'remotion';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Highlight a word in a sentence with a spring-animated wipe effect.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Ideal composition size: 1280x720
|
||||||
|
|
||||||
|
const COLOR_BG = '#ffffff';
|
||||||
|
const COLOR_TEXT = '#000000';
|
||||||
|
const COLOR_HIGHLIGHT = '#A7C7E7';
|
||||||
|
const FULL_TEXT = 'This is Remotion.';
|
||||||
|
const HIGHLIGHT_WORD = 'Remotion';
|
||||||
|
const FONT_SIZE = 72;
|
||||||
|
const FONT_WEIGHT = 700;
|
||||||
|
const HIGHLIGHT_START_FRAME = 30;
|
||||||
|
const HIGHLIGHT_WIPE_DURATION = 18;
|
||||||
|
|
||||||
|
const { fontFamily } = loadFont();
|
||||||
|
|
||||||
|
const Highlight: React.FC<{
|
||||||
|
word: string;
|
||||||
|
color: string;
|
||||||
|
delay: number;
|
||||||
|
durationInFrames: number;
|
||||||
|
}> = ({ word, color, delay, durationInFrames }) => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const { fps } = useVideoConfig();
|
||||||
|
|
||||||
|
const highlightProgress = spring({
|
||||||
|
fps,
|
||||||
|
frame,
|
||||||
|
config: { damping: 200 },
|
||||||
|
delay,
|
||||||
|
durationInFrames,
|
||||||
|
});
|
||||||
|
const scaleX = Math.max(0, Math.min(1, highlightProgress));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span style={{ position: 'relative', display: 'inline-block' }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: '50%',
|
||||||
|
height: '1.05em',
|
||||||
|
transform: `translateY(-50%) scaleX(${scaleX})`,
|
||||||
|
transformOrigin: 'left center',
|
||||||
|
backgroundColor: color,
|
||||||
|
borderRadius: '0.18em',
|
||||||
|
zIndex: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ position: 'relative', zIndex: 1 }}>{word}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MyAnimation = () => {
|
||||||
|
const highlightIndex = FULL_TEXT.indexOf(HIGHLIGHT_WORD);
|
||||||
|
const hasHighlight = highlightIndex >= 0;
|
||||||
|
const preText = hasHighlight ? FULL_TEXT.slice(0, highlightIndex) : FULL_TEXT;
|
||||||
|
const postText = hasHighlight ? FULL_TEXT.slice(highlightIndex + HIGHLIGHT_WORD.length) : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AbsoluteFill
|
||||||
|
style={{
|
||||||
|
backgroundColor: COLOR_BG,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontFamily,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: COLOR_TEXT,
|
||||||
|
fontSize: FONT_SIZE,
|
||||||
|
fontWeight: FONT_WEIGHT,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hasHighlight ? (
|
||||||
|
<>
|
||||||
|
<span>{preText}</span>
|
||||||
|
<Highlight
|
||||||
|
word={HIGHLIGHT_WORD}
|
||||||
|
color={COLOR_HIGHLIGHT}
|
||||||
|
delay={HIGHLIGHT_START_FRAME}
|
||||||
|
durationInFrames={HIGHLIGHT_WIPE_DURATION}
|
||||||
|
/>
|
||||||
|
<span>{postText}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span>{FULL_TEXT}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
172
.claude/skills/remotion-best-practices/rules/audio.md
Normal file
172
.claude/skills/remotion-best-practices/rules/audio.md
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
---
|
||||||
|
name: audio
|
||||||
|
description: Using audio and sound in Remotion - importing, trimming, volume, speed, pitch
|
||||||
|
metadata:
|
||||||
|
tags: audio, media, trim, volume, speed, loop, pitch, mute, sound, sfx
|
||||||
|
---
|
||||||
|
|
||||||
|
# Using audio in Remotion
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
First, the @remotion/media package needs to be installed.
|
||||||
|
If it is not installed, use the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx remotion add @remotion/media # If project uses npm
|
||||||
|
bunx remotion add @remotion/media # If project uses bun
|
||||||
|
yarn remotion add @remotion/media # If project uses yarn
|
||||||
|
pnpm exec remotion add @remotion/media # If project uses pnpm
|
||||||
|
```
|
||||||
|
|
||||||
|
## Importing Audio
|
||||||
|
|
||||||
|
Use `<Audio>` from `@remotion/media` to add audio to your composition.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Audio } from "@remotion/media";
|
||||||
|
import { staticFile } from "remotion";
|
||||||
|
|
||||||
|
export const MyComposition = () => {
|
||||||
|
return <Audio src={staticFile("audio.mp3")} />;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Remote URLs are also supported:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Audio src="https://remotion.media/audio.mp3" />
|
||||||
|
```
|
||||||
|
|
||||||
|
By default, audio plays from the start, at full volume and full length.
|
||||||
|
Multiple audio tracks can be layered by adding multiple `<Audio>` components.
|
||||||
|
|
||||||
|
## Trimming
|
||||||
|
|
||||||
|
Use `trimBefore` and `trimAfter` to remove portions of the audio. Values are in frames.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const { fps } = useVideoConfig();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Audio
|
||||||
|
src={staticFile("audio.mp3")}
|
||||||
|
trimBefore={2 * fps} // Skip the first 2 seconds
|
||||||
|
trimAfter={10 * fps} // End at the 10 second mark
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
The audio still starts playing at the beginning of the composition - only the specified portion is played.
|
||||||
|
|
||||||
|
## Delaying
|
||||||
|
|
||||||
|
Wrap the audio in a `<Sequence>` to delay when it starts:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Sequence, staticFile } from "remotion";
|
||||||
|
import { Audio } from "@remotion/media";
|
||||||
|
|
||||||
|
const { fps } = useVideoConfig();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sequence from={1 * fps}>
|
||||||
|
<Audio src={staticFile("audio.mp3")} />
|
||||||
|
</Sequence>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
The audio will start playing after 1 second.
|
||||||
|
|
||||||
|
## Volume
|
||||||
|
|
||||||
|
Set a static volume (0 to 1):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Audio src={staticFile("audio.mp3")} volume={0.5} />
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use a callback for dynamic volume based on the current frame:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { interpolate } from "remotion";
|
||||||
|
|
||||||
|
const { fps } = useVideoConfig();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Audio
|
||||||
|
src={staticFile("audio.mp3")}
|
||||||
|
volume={(f) =>
|
||||||
|
interpolate(f, [0, 1 * fps], [0, 1], { extrapolateRight: "clamp" })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
The value of `f` starts at 0 when the audio begins to play, not the composition frame.
|
||||||
|
|
||||||
|
## Muting
|
||||||
|
|
||||||
|
Use `muted` to silence the audio. It can be set dynamically:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const { fps } = useVideoConfig();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Audio
|
||||||
|
src={staticFile("audio.mp3")}
|
||||||
|
muted={frame >= 2 * fps && frame <= 4 * fps} // Mute between 2s and 4s
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Speed
|
||||||
|
|
||||||
|
Use `playbackRate` to change the playback speed:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Audio src={staticFile("audio.mp3")} playbackRate={2} /> {/* 2x speed */}
|
||||||
|
<Audio src={staticFile("audio.mp3")} playbackRate={0.5} /> {/* Half speed */}
|
||||||
|
```
|
||||||
|
|
||||||
|
Reverse playback is not supported.
|
||||||
|
|
||||||
|
## Looping
|
||||||
|
|
||||||
|
Use `loop` to loop the audio indefinitely:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Audio src={staticFile("audio.mp3")} loop />
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `loopVolumeCurveBehavior` to control how the frame count behaves when looping:
|
||||||
|
|
||||||
|
- `"repeat"`: Frame count resets to 0 each loop (default)
|
||||||
|
- `"extend"`: Frame count continues incrementing
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Audio
|
||||||
|
src={staticFile("audio.mp3")}
|
||||||
|
loop
|
||||||
|
loopVolumeCurveBehavior="extend"
|
||||||
|
volume={(f) => interpolate(f, [0, 300], [1, 0])} // Fade out over multiple loops
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pitch
|
||||||
|
|
||||||
|
Use `toneFrequency` to adjust the pitch without affecting speed. Values range from 0.01 to 2:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Audio
|
||||||
|
src={staticFile("audio.mp3")}
|
||||||
|
toneFrequency={1.5} // Higher pitch
|
||||||
|
/>
|
||||||
|
<Audio
|
||||||
|
src={staticFile("audio.mp3")}
|
||||||
|
toneFrequency={0.8} // Lower pitch
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
Pitch shifting only works during server-side rendering, not in the Remotion Studio preview or in the `<Player />`.
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
---
|
||||||
|
name: calculate-metadata
|
||||||
|
description: Dynamically set composition duration, dimensions, and props
|
||||||
|
metadata:
|
||||||
|
tags: calculateMetadata, duration, dimensions, props, dynamic
|
||||||
|
---
|
||||||
|
|
||||||
|
# Using calculateMetadata
|
||||||
|
|
||||||
|
Use `calculateMetadata` on a `<Composition>` to dynamically set duration, dimensions, and transform props before rendering.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Composition id="MyComp" component={MyComponent} durationInFrames={300} fps={30} width={1920} height={1080} defaultProps={{videoSrc: 'https://remotion.media/video.mp4'}} calculateMetadata={calculateMetadata} />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Setting duration based on a video
|
||||||
|
|
||||||
|
Use the `getMediaMetadata()` function from the mediabunny/metadata skill to get the video duration:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {CalculateMetadataFunction} from 'remotion';
|
||||||
|
import {getMediaMetadata} from '../get-media-metadata';
|
||||||
|
|
||||||
|
const calculateMetadata: CalculateMetadataFunction<Props> = async ({props}) => {
|
||||||
|
const {durationInSeconds} = await getMediaMetadata(props.videoSrc);
|
||||||
|
|
||||||
|
return {
|
||||||
|
durationInFrames: Math.ceil(durationInSeconds * 30),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Matching dimensions of a video
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const calculateMetadata: CalculateMetadataFunction<Props> = async ({props}) => {
|
||||||
|
const {durationInSeconds, dimensions} = await getMediaMetadata(props.videoSrc);
|
||||||
|
|
||||||
|
return {
|
||||||
|
durationInFrames: Math.ceil(durationInSeconds * 30),
|
||||||
|
width: dimensions?.width ?? 1920,
|
||||||
|
height: dimensions?.height ?? 1080,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Setting duration based on multiple videos
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const calculateMetadata: CalculateMetadataFunction<Props> = async ({props}) => {
|
||||||
|
const metadataPromises = props.videos.map((video) => getMediaMetadata(video.src));
|
||||||
|
const allMetadata = await Promise.all(metadataPromises);
|
||||||
|
|
||||||
|
const totalDuration = allMetadata.reduce((sum, meta) => sum + meta.durationInSeconds, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
durationInFrames: Math.ceil(totalDuration * 30),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Setting a default outName
|
||||||
|
|
||||||
|
Set the default output filename based on props:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const calculateMetadata: CalculateMetadataFunction<Props> = async ({props}) => {
|
||||||
|
return {
|
||||||
|
defaultOutName: `video-${props.id}.mp4`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Transforming props
|
||||||
|
|
||||||
|
Fetch data or transform props before rendering:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const calculateMetadata: CalculateMetadataFunction<Props> = async ({props, abortSignal}) => {
|
||||||
|
const response = await fetch(props.dataUrl, {signal: abortSignal});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
...props,
|
||||||
|
fetchedData: data,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
The `abortSignal` cancels stale requests when props change in the Studio.
|
||||||
|
|
||||||
|
## Return value
|
||||||
|
|
||||||
|
All fields are optional. Returned values override the `<Composition>` props:
|
||||||
|
|
||||||
|
- `durationInFrames`: Number of frames
|
||||||
|
- `width`: Composition width in pixels
|
||||||
|
- `height`: Composition height in pixels
|
||||||
|
- `fps`: Frames per second
|
||||||
|
- `props`: Transformed props passed to the component
|
||||||
|
- `defaultOutName`: Default output filename
|
||||||
|
- `defaultCodec`: Default codec for rendering
|
||||||
75
.claude/skills/remotion-best-practices/rules/can-decode.md
Normal file
75
.claude/skills/remotion-best-practices/rules/can-decode.md
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
---
|
||||||
|
name: can-decode
|
||||||
|
description: Check if a video can be decoded by the browser using Mediabunny
|
||||||
|
metadata:
|
||||||
|
tags: decode, validation, video, audio, compatibility, browser
|
||||||
|
---
|
||||||
|
|
||||||
|
# Checking if a video can be decoded
|
||||||
|
|
||||||
|
Use Mediabunny to check if a video can be decoded by the browser before attempting to play it.
|
||||||
|
|
||||||
|
## The `canDecode()` function
|
||||||
|
|
||||||
|
This function can be copy-pasted into any project.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Input, ALL_FORMATS, UrlSource } from "mediabunny";
|
||||||
|
|
||||||
|
export const canDecode = async (src: string) => {
|
||||||
|
const input = new Input({
|
||||||
|
formats: ALL_FORMATS,
|
||||||
|
source: new UrlSource(src, {
|
||||||
|
getRetryDelay: () => null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await input.getFormat();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoTrack = await input.getPrimaryVideoTrack();
|
||||||
|
if (videoTrack && !(await videoTrack.canDecode())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioTrack = await input.getPrimaryAudioTrack();
|
||||||
|
if (audioTrack && !(await audioTrack.canDecode())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const src = "https://remotion.media/video.mp4";
|
||||||
|
const isDecodable = await canDecode(src);
|
||||||
|
|
||||||
|
if (isDecodable) {
|
||||||
|
console.log("Video can be decoded");
|
||||||
|
} else {
|
||||||
|
console.log("Video cannot be decoded by this browser");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using with Blob
|
||||||
|
|
||||||
|
For file uploads or drag-and-drop, use `BlobSource`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Input, ALL_FORMATS, BlobSource } from "mediabunny";
|
||||||
|
|
||||||
|
export const canDecodeBlob = async (blob: Blob) => {
|
||||||
|
const input = new Input({
|
||||||
|
formats: ALL_FORMATS,
|
||||||
|
source: new BlobSource(blob),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Same validation logic as above
|
||||||
|
};
|
||||||
|
```
|
||||||
58
.claude/skills/remotion-best-practices/rules/charts.md
Normal file
58
.claude/skills/remotion-best-practices/rules/charts.md
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
---
|
||||||
|
name: charts
|
||||||
|
description: Chart and data visualization patterns for Remotion. Use when creating bar charts, pie charts, histograms, progress bars, or any data-driven animations.
|
||||||
|
metadata:
|
||||||
|
tags: charts, data, visualization, bar-chart, pie-chart, graphs
|
||||||
|
---
|
||||||
|
|
||||||
|
# Charts in Remotion
|
||||||
|
|
||||||
|
You can create bar charts in Remotion by using regular React code - HTML and SVG is allowed, as well as D3.js.
|
||||||
|
|
||||||
|
## No animations not powered by `useCurrentFrame()`
|
||||||
|
|
||||||
|
Disable all animations by third party libraries.
|
||||||
|
They will cause flickering during rendering.
|
||||||
|
Instead, drive all animations from `useCurrentFrame()`.
|
||||||
|
|
||||||
|
## Bar Chart Animations
|
||||||
|
|
||||||
|
See [Bar Chart Example](assets/charts/bar-chart.tsx) for a basic example implmentation.
|
||||||
|
|
||||||
|
### Staggered Bars
|
||||||
|
|
||||||
|
You can animate the height of the bars and stagger them like this:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const STAGGER_DELAY = 5;
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const {fps} = useVideoConfig();
|
||||||
|
|
||||||
|
const bars = data.map((item, i) => {
|
||||||
|
const delay = i * STAGGER_DELAY;
|
||||||
|
const height = spring({
|
||||||
|
frame,
|
||||||
|
fps,
|
||||||
|
delay,
|
||||||
|
config: {damping: 200},
|
||||||
|
});
|
||||||
|
return <div style={{height: height * item.value}} />;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pie Chart Animation
|
||||||
|
|
||||||
|
Animate segments using stroke-dashoffset, starting from 12 o'clock.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const {fps} = useVideoConfig();
|
||||||
|
|
||||||
|
const progress = interpolate(frame, [0, 100], [0, 1]);
|
||||||
|
|
||||||
|
const circumference = 2 * Math.PI * radius;
|
||||||
|
const segmentLength = (value / total) * circumference;
|
||||||
|
const offset = interpolate(progress, [0, 1], [segmentLength, 0]);
|
||||||
|
|
||||||
|
<circle r={radius} cx={center} cy={center} fill="none" stroke={color} strokeWidth={strokeWidth} strokeDasharray={`${segmentLength} ${circumference}`} strokeDashoffset={offset} transform={`rotate(-90 ${center} ${center})`} />;
|
||||||
|
```
|
||||||
146
.claude/skills/remotion-best-practices/rules/compositions.md
Normal file
146
.claude/skills/remotion-best-practices/rules/compositions.md
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
---
|
||||||
|
name: compositions
|
||||||
|
description: Defining compositions, stills, folders, default props and dynamic metadata
|
||||||
|
metadata:
|
||||||
|
tags: composition, still, folder, props, metadata
|
||||||
|
---
|
||||||
|
|
||||||
|
A `<Composition>` defines the component, width, height, fps and duration of a renderable video.
|
||||||
|
|
||||||
|
It normally is placed in the `src/Root.tsx` file.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Composition } from "remotion";
|
||||||
|
import { MyComposition } from "./MyComposition";
|
||||||
|
|
||||||
|
export const RemotionRoot = () => {
|
||||||
|
return (
|
||||||
|
<Composition
|
||||||
|
id="MyComposition"
|
||||||
|
component={MyComposition}
|
||||||
|
durationInFrames={100}
|
||||||
|
fps={30}
|
||||||
|
width={1080}
|
||||||
|
height={1080}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Default Props
|
||||||
|
|
||||||
|
Pass `defaultProps` to provide initial values for your component.
|
||||||
|
Values must be JSON-serializable (`Date`, `Map`, `Set`, and `staticFile()` are supported).
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Composition } from "remotion";
|
||||||
|
import { MyComposition, MyCompositionProps } from "./MyComposition";
|
||||||
|
|
||||||
|
export const RemotionRoot = () => {
|
||||||
|
return (
|
||||||
|
<Composition
|
||||||
|
id="MyComposition"
|
||||||
|
component={MyComposition}
|
||||||
|
durationInFrames={100}
|
||||||
|
fps={30}
|
||||||
|
width={1080}
|
||||||
|
height={1080}
|
||||||
|
defaultProps={{
|
||||||
|
title: "Hello World",
|
||||||
|
color: "#ff0000",
|
||||||
|
} satisfies MyCompositionProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `type` declarations for props rather than `interface` to ensure `defaultProps` type safety.
|
||||||
|
|
||||||
|
## Folders
|
||||||
|
|
||||||
|
Use `<Folder>` to organize compositions in the sidebar.
|
||||||
|
Folder names can only contain letters, numbers, and hyphens.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Composition, Folder } from "remotion";
|
||||||
|
|
||||||
|
export const RemotionRoot = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Folder name="Marketing">
|
||||||
|
<Composition id="Promo" /* ... */ />
|
||||||
|
<Composition id="Ad" /* ... */ />
|
||||||
|
</Folder>
|
||||||
|
<Folder name="Social">
|
||||||
|
<Folder name="Instagram">
|
||||||
|
<Composition id="Story" /* ... */ />
|
||||||
|
<Composition id="Reel" /* ... */ />
|
||||||
|
</Folder>
|
||||||
|
</Folder>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Stills
|
||||||
|
|
||||||
|
Use `<Still>` for single-frame images. It does not require `durationInFrames` or `fps`.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Still } from "remotion";
|
||||||
|
import { Thumbnail } from "./Thumbnail";
|
||||||
|
|
||||||
|
export const RemotionRoot = () => {
|
||||||
|
return (
|
||||||
|
<Still
|
||||||
|
id="Thumbnail"
|
||||||
|
component={Thumbnail}
|
||||||
|
width={1280}
|
||||||
|
height={720}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Calculate Metadata
|
||||||
|
|
||||||
|
Use `calculateMetadata` to make dimensions, duration, or props dynamic based on data.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Composition, CalculateMetadataFunction } from "remotion";
|
||||||
|
import { MyComposition, MyCompositionProps } from "./MyComposition";
|
||||||
|
|
||||||
|
const calculateMetadata: CalculateMetadataFunction<MyCompositionProps> = async ({
|
||||||
|
props,
|
||||||
|
abortSignal,
|
||||||
|
}) => {
|
||||||
|
const data = await fetch(`https://api.example.com/video/${props.videoId}`, {
|
||||||
|
signal: abortSignal,
|
||||||
|
}).then((res) => res.json());
|
||||||
|
|
||||||
|
return {
|
||||||
|
durationInFrames: Math.ceil(data.duration * 30),
|
||||||
|
props: {
|
||||||
|
...props,
|
||||||
|
videoUrl: data.url,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RemotionRoot = () => {
|
||||||
|
return (
|
||||||
|
<Composition
|
||||||
|
id="MyComposition"
|
||||||
|
component={MyComposition}
|
||||||
|
durationInFrames={100} // Placeholder, will be overridden
|
||||||
|
fps={30}
|
||||||
|
width={1080}
|
||||||
|
height={1080}
|
||||||
|
defaultProps={{ videoId: "abc123" }}
|
||||||
|
calculateMetadata={calculateMetadata}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
The function can return `props`, `durationInFrames`, `width`, `height`, `fps`, and codec-related defaults. It runs once before rendering begins.
|
||||||
126
.claude/skills/remotion-best-practices/rules/display-captions.md
Normal file
126
.claude/skills/remotion-best-practices/rules/display-captions.md
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
---
|
||||||
|
name: display-captions
|
||||||
|
description: Displaying captions in Remotion with TikTok-style pages and word highlighting
|
||||||
|
metadata:
|
||||||
|
tags: captions, subtitles, display, tiktok, highlight
|
||||||
|
---
|
||||||
|
|
||||||
|
# Displaying captions in Remotion
|
||||||
|
|
||||||
|
This guide explains how to display captions in Remotion, assuming you already have captions in the `Caption` format.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
First, the @remotion/captions package needs to be installed.
|
||||||
|
If it is not installed, use the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx remotion add @remotion/captions # If project uses npm
|
||||||
|
bunx remotion add @remotion/captions # If project uses bun
|
||||||
|
yarn remotion add @remotion/captions # If project uses yarn
|
||||||
|
pnpm exec remotion add @remotion/captions # If project uses pnpm
|
||||||
|
```
|
||||||
|
|
||||||
|
## Creating pages
|
||||||
|
|
||||||
|
Use `createTikTokStyleCaptions()` to group captions into pages. The `combineTokensWithinMilliseconds` option controls how many words appear at once:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {useMemo} from 'react';
|
||||||
|
import {createTikTokStyleCaptions} from '@remotion/captions';
|
||||||
|
import type {Caption} from '@remotion/captions';
|
||||||
|
|
||||||
|
// How often captions should switch (in milliseconds)
|
||||||
|
// Higher values = more words per page
|
||||||
|
// Lower values = fewer words (more word-by-word)
|
||||||
|
const SWITCH_CAPTIONS_EVERY_MS = 1200;
|
||||||
|
|
||||||
|
const {pages} = useMemo(() => {
|
||||||
|
return createTikTokStyleCaptions({
|
||||||
|
captions,
|
||||||
|
combineTokensWithinMilliseconds: SWITCH_CAPTIONS_EVERY_MS,
|
||||||
|
});
|
||||||
|
}, [captions]);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rendering with Sequences
|
||||||
|
|
||||||
|
Map over the pages and render each one in a `<Sequence>`. Calculate the start frame and duration from the page timing:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {Sequence, useVideoConfig, AbsoluteFill} from 'remotion';
|
||||||
|
import type {TikTokPage} from '@remotion/captions';
|
||||||
|
|
||||||
|
const CaptionedContent: React.FC = () => {
|
||||||
|
const {fps} = useVideoConfig();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AbsoluteFill>
|
||||||
|
{pages.map((page, index) => {
|
||||||
|
const nextPage = pages[index + 1] ?? null;
|
||||||
|
const startFrame = (page.startMs / 1000) * fps;
|
||||||
|
const endFrame = Math.min(
|
||||||
|
nextPage ? (nextPage.startMs / 1000) * fps : Infinity,
|
||||||
|
startFrame + (SWITCH_CAPTIONS_EVERY_MS / 1000) * fps,
|
||||||
|
);
|
||||||
|
const durationInFrames = endFrame - startFrame;
|
||||||
|
|
||||||
|
if (durationInFrames <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sequence
|
||||||
|
key={index}
|
||||||
|
from={startFrame}
|
||||||
|
durationInFrames={durationInFrames}
|
||||||
|
>
|
||||||
|
<CaptionPage page={page} />
|
||||||
|
</Sequence>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Word highlighting
|
||||||
|
|
||||||
|
A caption page contains `tokens` which you can use to highlight the currently spoken word:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {AbsoluteFill, useCurrentFrame, useVideoConfig} from 'remotion';
|
||||||
|
import type {TikTokPage} from '@remotion/captions';
|
||||||
|
|
||||||
|
const HIGHLIGHT_COLOR = '#39E508';
|
||||||
|
|
||||||
|
const CaptionPage: React.FC<{page: TikTokPage}> = ({page}) => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const {fps} = useVideoConfig();
|
||||||
|
|
||||||
|
// Current time relative to the start of the sequence
|
||||||
|
const currentTimeMs = (frame / fps) * 1000;
|
||||||
|
// Convert to absolute time by adding the page start
|
||||||
|
const absoluteTimeMs = page.startMs + currentTimeMs;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AbsoluteFill style={{justifyContent: 'center', alignItems: 'center'}}>
|
||||||
|
<div style={{fontSize: 80, fontWeight: 'bold', whiteSpace: 'pre'}}>
|
||||||
|
{page.tokens.map((token) => {
|
||||||
|
const isActive =
|
||||||
|
token.fromMs <= absoluteTimeMs && token.toMs > absoluteTimeMs;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={token.fromMs}
|
||||||
|
style={{color: isActive ? HIGHLIGHT_COLOR : 'white'}}
|
||||||
|
>
|
||||||
|
{token.text}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
229
.claude/skills/remotion-best-practices/rules/extract-frames.md
Normal file
229
.claude/skills/remotion-best-practices/rules/extract-frames.md
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
---
|
||||||
|
name: extract-frames
|
||||||
|
description: Extract frames from videos at specific timestamps using Mediabunny
|
||||||
|
metadata:
|
||||||
|
tags: frames, extract, video, thumbnail, filmstrip, canvas
|
||||||
|
---
|
||||||
|
|
||||||
|
# Extracting frames from videos
|
||||||
|
|
||||||
|
Use Mediabunny to extract frames from videos at specific timestamps. This is useful for generating thumbnails, filmstrips, or processing individual frames.
|
||||||
|
|
||||||
|
## The `extractFrames()` function
|
||||||
|
|
||||||
|
This function can be copy-pasted into any project.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {
|
||||||
|
ALL_FORMATS,
|
||||||
|
Input,
|
||||||
|
UrlSource,
|
||||||
|
VideoSample,
|
||||||
|
VideoSampleSink,
|
||||||
|
} from "mediabunny";
|
||||||
|
|
||||||
|
type Options = {
|
||||||
|
track: { width: number; height: number };
|
||||||
|
container: string;
|
||||||
|
durationInSeconds: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExtractFramesTimestampsInSecondsFn = (
|
||||||
|
options: Options
|
||||||
|
) => Promise<number[]> | number[];
|
||||||
|
|
||||||
|
export type ExtractFramesProps = {
|
||||||
|
src: string;
|
||||||
|
timestampsInSeconds: number[] | ExtractFramesTimestampsInSecondsFn;
|
||||||
|
onVideoSample: (sample: VideoSample) => void;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function extractFrames({
|
||||||
|
src,
|
||||||
|
timestampsInSeconds,
|
||||||
|
onVideoSample,
|
||||||
|
signal,
|
||||||
|
}: ExtractFramesProps): Promise<void> {
|
||||||
|
using input = new Input({
|
||||||
|
formats: ALL_FORMATS,
|
||||||
|
source: new UrlSource(src),
|
||||||
|
});
|
||||||
|
|
||||||
|
const [durationInSeconds, format, videoTrack] = await Promise.all([
|
||||||
|
input.computeDuration(),
|
||||||
|
input.getFormat(),
|
||||||
|
input.getPrimaryVideoTrack(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!videoTrack) {
|
||||||
|
throw new Error("No video track found in the input");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signal?.aborted) {
|
||||||
|
throw new Error("Aborted");
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamps =
|
||||||
|
typeof timestampsInSeconds === "function"
|
||||||
|
? await timestampsInSeconds({
|
||||||
|
track: {
|
||||||
|
width: videoTrack.displayWidth,
|
||||||
|
height: videoTrack.displayHeight,
|
||||||
|
},
|
||||||
|
container: format.name,
|
||||||
|
durationInSeconds,
|
||||||
|
})
|
||||||
|
: timestampsInSeconds;
|
||||||
|
|
||||||
|
if (timestamps.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signal?.aborted) {
|
||||||
|
throw new Error("Aborted");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sink = new VideoSampleSink(videoTrack);
|
||||||
|
|
||||||
|
for await (using videoSample of sink.samplesAtTimestamps(timestamps)) {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!videoSample) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
onVideoSample(videoSample);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Basic usage
|
||||||
|
|
||||||
|
Extract frames at specific timestamps:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
await extractFrames({
|
||||||
|
src: "https://remotion.media/video.mp4",
|
||||||
|
timestampsInSeconds: [0, 1, 2, 3, 4],
|
||||||
|
onVideoSample: (sample) => {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = sample.displayWidth;
|
||||||
|
canvas.height = sample.displayHeight;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
sample.draw(ctx!, 0, 0);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Creating a filmstrip
|
||||||
|
|
||||||
|
Use a callback function to dynamically calculate timestamps based on video metadata:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const canvasWidth = 500;
|
||||||
|
const canvasHeight = 80;
|
||||||
|
const fromSeconds = 0;
|
||||||
|
const toSeconds = 10;
|
||||||
|
|
||||||
|
await extractFrames({
|
||||||
|
src: "https://remotion.media/video.mp4",
|
||||||
|
timestampsInSeconds: async ({ track, durationInSeconds }) => {
|
||||||
|
const aspectRatio = track.width / track.height;
|
||||||
|
const amountOfFramesFit = Math.ceil(
|
||||||
|
canvasWidth / (canvasHeight * aspectRatio)
|
||||||
|
);
|
||||||
|
const segmentDuration = toSeconds - fromSeconds;
|
||||||
|
const timestamps: number[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < amountOfFramesFit; i++) {
|
||||||
|
timestamps.push(
|
||||||
|
fromSeconds + (segmentDuration / amountOfFramesFit) * (i + 0.5)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return timestamps;
|
||||||
|
},
|
||||||
|
onVideoSample: (sample) => {
|
||||||
|
console.log(`Frame at ${sample.timestamp}s`);
|
||||||
|
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = sample.displayWidth;
|
||||||
|
canvas.height = sample.displayHeight;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
sample.draw(ctx!, 0, 0);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cancellation with AbortSignal
|
||||||
|
|
||||||
|
Cancel frame extraction after a timeout:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
setTimeout(() => controller.abort(), 5000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await extractFrames({
|
||||||
|
src: "https://remotion.media/video.mp4",
|
||||||
|
timestampsInSeconds: [0, 1, 2, 3, 4],
|
||||||
|
onVideoSample: (sample) => {
|
||||||
|
using frame = sample;
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = frame.displayWidth;
|
||||||
|
canvas.height = frame.displayHeight;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
frame.draw(ctx!, 0, 0);
|
||||||
|
},
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Frame extraction complete!");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Frame extraction was aborted or failed:", error);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Timeout with Promise.race
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
controller.abort();
|
||||||
|
reject(new Error("Frame extraction timed out after 10 seconds"));
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
controller.signal.addEventListener("abort", () => clearTimeout(timeoutId), {
|
||||||
|
once: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.race([
|
||||||
|
extractFrames({
|
||||||
|
src: "https://remotion.media/video.mp4",
|
||||||
|
timestampsInSeconds: [0, 1, 2, 3, 4],
|
||||||
|
onVideoSample: (sample) => {
|
||||||
|
using frame = sample;
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = frame.displayWidth;
|
||||||
|
canvas.height = frame.displayHeight;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
frame.draw(ctx!, 0, 0);
|
||||||
|
},
|
||||||
|
signal: controller.signal,
|
||||||
|
}),
|
||||||
|
timeoutPromise,
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log("Frame extraction complete!");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Frame extraction was aborted or failed:", error);
|
||||||
|
}
|
||||||
|
```
|
||||||
152
.claude/skills/remotion-best-practices/rules/fonts.md
Normal file
152
.claude/skills/remotion-best-practices/rules/fonts.md
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
---
|
||||||
|
name: fonts
|
||||||
|
description: Loading Google Fonts and local fonts in Remotion
|
||||||
|
metadata:
|
||||||
|
tags: fonts, google-fonts, typography, text
|
||||||
|
---
|
||||||
|
|
||||||
|
# Using fonts in Remotion
|
||||||
|
|
||||||
|
## Google Fonts with @remotion/google-fonts
|
||||||
|
|
||||||
|
The recommended way to use Google Fonts. It's type-safe and automatically blocks rendering until the font is ready.
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
First, the @remotion/google-fonts package needs to be installed.
|
||||||
|
If it is not installed, use the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx remotion add @remotion/google-fonts # If project uses npm
|
||||||
|
bunx remotion add @remotion/google-fonts # If project uses bun
|
||||||
|
yarn remotion add @remotion/google-fonts # If project uses yarn
|
||||||
|
pnpm exec remotion add @remotion/google-fonts # If project uses pnpm
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { loadFont } from "@remotion/google-fonts/Lobster";
|
||||||
|
|
||||||
|
const { fontFamily } = loadFont();
|
||||||
|
|
||||||
|
export const MyComposition = () => {
|
||||||
|
return <div style={{ fontFamily }}>Hello World</div>;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Preferrably, specify only needed weights and subsets to reduce file size:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { loadFont } from "@remotion/google-fonts/Roboto";
|
||||||
|
|
||||||
|
const { fontFamily } = loadFont("normal", {
|
||||||
|
weights: ["400", "700"],
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Waiting for font to load
|
||||||
|
|
||||||
|
Use `waitUntilDone()` if you need to know when the font is ready:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { loadFont } from "@remotion/google-fonts/Lobster";
|
||||||
|
|
||||||
|
const { fontFamily, waitUntilDone } = loadFont();
|
||||||
|
|
||||||
|
await waitUntilDone();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Local fonts with @remotion/fonts
|
||||||
|
|
||||||
|
For local font files, use the `@remotion/fonts` package.
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
First, install @remotion/fonts:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx remotion add @remotion/fonts # If project uses npm
|
||||||
|
bunx remotion add @remotion/fonts # If project uses bun
|
||||||
|
yarn remotion add @remotion/fonts # If project uses yarn
|
||||||
|
pnpm exec remotion add @remotion/fonts # If project uses pnpm
|
||||||
|
```
|
||||||
|
|
||||||
|
### Loading a local font
|
||||||
|
|
||||||
|
Place your font file in the `public/` folder and use `loadFont()`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { loadFont } from "@remotion/fonts";
|
||||||
|
import { staticFile } from "remotion";
|
||||||
|
|
||||||
|
await loadFont({
|
||||||
|
family: "MyFont",
|
||||||
|
url: staticFile("MyFont-Regular.woff2"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MyComposition = () => {
|
||||||
|
return <div style={{ fontFamily: "MyFont" }}>Hello World</div>;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Loading multiple weights
|
||||||
|
|
||||||
|
Load each weight separately with the same family name:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { loadFont } from "@remotion/fonts";
|
||||||
|
import { staticFile } from "remotion";
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
loadFont({
|
||||||
|
family: "Inter",
|
||||||
|
url: staticFile("Inter-Regular.woff2"),
|
||||||
|
weight: "400",
|
||||||
|
}),
|
||||||
|
loadFont({
|
||||||
|
family: "Inter",
|
||||||
|
url: staticFile("Inter-Bold.woff2"),
|
||||||
|
weight: "700",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Available options
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
loadFont({
|
||||||
|
family: "MyFont", // Required: name to use in CSS
|
||||||
|
url: staticFile("font.woff2"), // Required: font file URL
|
||||||
|
format: "woff2", // Optional: auto-detected from extension
|
||||||
|
weight: "400", // Optional: font weight
|
||||||
|
style: "normal", // Optional: normal or italic
|
||||||
|
display: "block", // Optional: font-display behavior
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using in components
|
||||||
|
|
||||||
|
Call `loadFont()` at the top level of your component or in a separate file that's imported early:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { loadFont } from "@remotion/google-fonts/Montserrat";
|
||||||
|
|
||||||
|
const { fontFamily } = loadFont("normal", {
|
||||||
|
weights: ["400", "700"],
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Title: React.FC<{ text: string }> = ({ text }) => {
|
||||||
|
return (
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontFamily,
|
||||||
|
fontSize: 80,
|
||||||
|
fontWeight: "bold",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</h1>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
---
|
||||||
|
name: get-audio-duration
|
||||||
|
description: Getting the duration of an audio file in seconds with Mediabunny
|
||||||
|
metadata:
|
||||||
|
tags: duration, audio, length, time, seconds, mp3, wav
|
||||||
|
---
|
||||||
|
|
||||||
|
# Getting audio duration with Mediabunny
|
||||||
|
|
||||||
|
Mediabunny can extract the duration of an audio file. It works in browser, Node.js, and Bun environments.
|
||||||
|
|
||||||
|
## Getting audio duration
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Input, ALL_FORMATS, UrlSource } from "mediabunny";
|
||||||
|
|
||||||
|
export const getAudioDuration = async (src: string) => {
|
||||||
|
const input = new Input({
|
||||||
|
formats: ALL_FORMATS,
|
||||||
|
source: new UrlSource(src, {
|
||||||
|
getRetryDelay: () => null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const durationInSeconds = await input.computeDuration();
|
||||||
|
return durationInSeconds;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const duration = await getAudioDuration("https://remotion.media/audio.mp3");
|
||||||
|
console.log(duration); // e.g. 180.5 (seconds)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using with local files
|
||||||
|
|
||||||
|
For local files, use `FileSource` instead of `UrlSource`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Input, ALL_FORMATS, FileSource } from "mediabunny";
|
||||||
|
|
||||||
|
const input = new Input({
|
||||||
|
formats: ALL_FORMATS,
|
||||||
|
source: new FileSource(file), // File object from input or drag-drop
|
||||||
|
});
|
||||||
|
|
||||||
|
const durationInSeconds = await input.computeDuration();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using with staticFile in Remotion
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { staticFile } from "remotion";
|
||||||
|
|
||||||
|
const duration = await getAudioDuration(staticFile("audio.mp3"));
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
---
|
||||||
|
name: get-video-dimensions
|
||||||
|
description: Getting the width and height of a video file with Mediabunny
|
||||||
|
metadata:
|
||||||
|
tags: dimensions, width, height, resolution, size, video
|
||||||
|
---
|
||||||
|
|
||||||
|
# Getting video dimensions with Mediabunny
|
||||||
|
|
||||||
|
Mediabunny can extract the width and height of a video file. It works in browser, Node.js, and Bun environments.
|
||||||
|
|
||||||
|
## Getting video dimensions
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Input, ALL_FORMATS, UrlSource } from "mediabunny";
|
||||||
|
|
||||||
|
export const getVideoDimensions = async (src: string) => {
|
||||||
|
const input = new Input({
|
||||||
|
formats: ALL_FORMATS,
|
||||||
|
source: new UrlSource(src, {
|
||||||
|
getRetryDelay: () => null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const videoTrack = await input.getPrimaryVideoTrack();
|
||||||
|
if (!videoTrack) {
|
||||||
|
throw new Error("No video track found");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: videoTrack.displayWidth,
|
||||||
|
height: videoTrack.displayHeight,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const dimensions = await getVideoDimensions("https://remotion.media/video.mp4");
|
||||||
|
console.log(dimensions.width); // e.g. 1920
|
||||||
|
console.log(dimensions.height); // e.g. 1080
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using with local files
|
||||||
|
|
||||||
|
For local files, use `FileSource` instead of `UrlSource`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Input, ALL_FORMATS, FileSource } from "mediabunny";
|
||||||
|
|
||||||
|
const input = new Input({
|
||||||
|
formats: ALL_FORMATS,
|
||||||
|
source: new FileSource(file), // File object from input or drag-drop
|
||||||
|
});
|
||||||
|
|
||||||
|
const videoTrack = await input.getPrimaryVideoTrack();
|
||||||
|
const width = videoTrack.displayWidth;
|
||||||
|
const height = videoTrack.displayHeight;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using with staticFile in Remotion
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { staticFile } from "remotion";
|
||||||
|
|
||||||
|
const dimensions = await getVideoDimensions(staticFile("video.mp4"));
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
---
|
||||||
|
name: get-video-duration
|
||||||
|
description: Getting the duration of a video file in seconds with Mediabunny
|
||||||
|
metadata:
|
||||||
|
tags: duration, video, length, time, seconds
|
||||||
|
---
|
||||||
|
|
||||||
|
# Getting video duration with Mediabunny
|
||||||
|
|
||||||
|
Mediabunny can extract the duration of a video file. It works in browser, Node.js, and Bun environments.
|
||||||
|
|
||||||
|
## Getting video duration
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Input, ALL_FORMATS, UrlSource } from "mediabunny";
|
||||||
|
|
||||||
|
export const getVideoDuration = async (src: string) => {
|
||||||
|
const input = new Input({
|
||||||
|
formats: ALL_FORMATS,
|
||||||
|
source: new UrlSource(src, {
|
||||||
|
getRetryDelay: () => null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const durationInSeconds = await input.computeDuration();
|
||||||
|
return durationInSeconds;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const duration = await getVideoDuration("https://remotion.media/video.mp4");
|
||||||
|
console.log(duration); // e.g. 10.5 (seconds)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using with local files
|
||||||
|
|
||||||
|
For local files, use `FileSource` instead of `UrlSource`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Input, ALL_FORMATS, FileSource } from "mediabunny";
|
||||||
|
|
||||||
|
const input = new Input({
|
||||||
|
formats: ALL_FORMATS,
|
||||||
|
source: new FileSource(file), // File object from input or drag-drop
|
||||||
|
});
|
||||||
|
|
||||||
|
const durationInSeconds = await input.computeDuration();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using with staticFile in Remotion
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { staticFile } from "remotion";
|
||||||
|
|
||||||
|
const duration = await getVideoDuration(staticFile("video.mp4"));
|
||||||
|
```
|
||||||
138
.claude/skills/remotion-best-practices/rules/gifs.md
Normal file
138
.claude/skills/remotion-best-practices/rules/gifs.md
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
---
|
||||||
|
name: gif
|
||||||
|
description: Displaying GIFs, APNG, AVIF and WebP in Remotion
|
||||||
|
metadata:
|
||||||
|
tags: gif, animation, images, animated, apng, avif, webp
|
||||||
|
---
|
||||||
|
|
||||||
|
# Using Animated images in Remotion
|
||||||
|
|
||||||
|
## Basic usage
|
||||||
|
|
||||||
|
Use `<AnimatedImage>` to display a GIF, APNG, AVIF or WebP image synchronized with Remotion's timeline:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {AnimatedImage, staticFile} from 'remotion';
|
||||||
|
|
||||||
|
export const MyComposition = () => {
|
||||||
|
return <AnimatedImage src={staticFile('animation.gif')} width={500} height={500} />;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Remote URLs are also supported (must have CORS enabled):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<AnimatedImage src="https://example.com/animation.gif" width={500} height={500} />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sizing and fit
|
||||||
|
|
||||||
|
Control how the image fills its container with the `fit` prop:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Stretch to fill (default)
|
||||||
|
<AnimatedImage src={staticFile("animation.gif")} width={500} height={300} fit="fill" />
|
||||||
|
|
||||||
|
// Maintain aspect ratio, fit inside container
|
||||||
|
<AnimatedImage src={staticFile("animation.gif")} width={500} height={300} fit="contain" />
|
||||||
|
|
||||||
|
// Fill container, crop if needed
|
||||||
|
<AnimatedImage src={staticFile("animation.gif")} width={500} height={300} fit="cover" />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Playback speed
|
||||||
|
|
||||||
|
Use `playbackRate` to control the animation speed:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<AnimatedImage src={staticFile("animation.gif")} width={500} height={500} playbackRate={2} /> {/* 2x speed */}
|
||||||
|
<AnimatedImage src={staticFile("animation.gif")} width={500} height={500} playbackRate={0.5} /> {/* Half speed */}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Looping behavior
|
||||||
|
|
||||||
|
Control what happens when the animation finishes:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Loop indefinitely (default)
|
||||||
|
<AnimatedImage src={staticFile("animation.gif")} width={500} height={500} loopBehavior="loop" />
|
||||||
|
|
||||||
|
// Play once, show final frame
|
||||||
|
<AnimatedImage src={staticFile("animation.gif")} width={500} height={500} loopBehavior="pause-after-finish" />
|
||||||
|
|
||||||
|
// Play once, then clear canvas
|
||||||
|
<AnimatedImage src={staticFile("animation.gif")} width={500} height={500} loopBehavior="clear-after-finish" />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Styling
|
||||||
|
|
||||||
|
Use the `style` prop for additional CSS (use `width` and `height` props for sizing):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<AnimatedImage
|
||||||
|
src={staticFile('animation.gif')}
|
||||||
|
width={500}
|
||||||
|
height={500}
|
||||||
|
style={{
|
||||||
|
borderRadius: 20,
|
||||||
|
position: 'absolute',
|
||||||
|
top: 100,
|
||||||
|
left: 50,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Getting GIF duration
|
||||||
|
|
||||||
|
Use `getGifDurationInSeconds()` from `@remotion/gif` to get the duration of a GIF.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx remotion add @remotion/gif # If project uses npm
|
||||||
|
bunx remotion add @remotion/gif # If project uses bun
|
||||||
|
yarn remotion add @remotion/gif # If project uses yarn
|
||||||
|
pnpm exec remotion add @remotion/gif # If project uses pnpm
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {getGifDurationInSeconds} from '@remotion/gif';
|
||||||
|
import {staticFile} from 'remotion';
|
||||||
|
|
||||||
|
const duration = await getGifDurationInSeconds(staticFile('animation.gif'));
|
||||||
|
console.log(duration); // e.g. 2.5
|
||||||
|
```
|
||||||
|
|
||||||
|
This is useful for setting the composition duration to match the GIF:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {getGifDurationInSeconds} from '@remotion/gif';
|
||||||
|
import {staticFile, CalculateMetadataFunction} from 'remotion';
|
||||||
|
|
||||||
|
const calculateMetadata: CalculateMetadataFunction = async () => {
|
||||||
|
const duration = await getGifDurationInSeconds(staticFile('animation.gif'));
|
||||||
|
return {
|
||||||
|
durationInFrames: Math.ceil(duration * 30),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Alternative
|
||||||
|
|
||||||
|
If `<AnimatedImage>` does not work (only supported in Chrome and Firefox), you can use `<Gif>` from `@remotion/gif` instead.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx remotion add @remotion/gif # If project uses npm
|
||||||
|
bunx remotion add @remotion/gif # If project uses bun
|
||||||
|
yarn remotion add @remotion/gif # If project uses yarn
|
||||||
|
pnpm exec remotion add @remotion/gif # If project uses pnpm
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {Gif} from '@remotion/gif';
|
||||||
|
import {staticFile} from 'remotion';
|
||||||
|
|
||||||
|
export const MyComposition = () => {
|
||||||
|
return <Gif src={staticFile('animation.gif')} width={500} height={500} />;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
The `<Gif>` component has the same props as `<AnimatedImage>` but only supports GIF files.
|
||||||
130
.claude/skills/remotion-best-practices/rules/images.md
Normal file
130
.claude/skills/remotion-best-practices/rules/images.md
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
---
|
||||||
|
name: images
|
||||||
|
description: Embedding images in Remotion using the <Img> component
|
||||||
|
metadata:
|
||||||
|
tags: images, img, staticFile, png, jpg, svg, webp
|
||||||
|
---
|
||||||
|
|
||||||
|
# Using images in Remotion
|
||||||
|
|
||||||
|
## The `<Img>` component
|
||||||
|
|
||||||
|
Always use the `<Img>` component from `remotion` to display images:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Img, staticFile } from "remotion";
|
||||||
|
|
||||||
|
export const MyComposition = () => {
|
||||||
|
return <Img src={staticFile("photo.png")} />;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Important restrictions
|
||||||
|
|
||||||
|
**You MUST use the `<Img>` component from `remotion`.** Do not use:
|
||||||
|
|
||||||
|
- Native HTML `<img>` elements
|
||||||
|
- Next.js `<Image>` component
|
||||||
|
- CSS `background-image`
|
||||||
|
|
||||||
|
The `<Img>` component ensures images are fully loaded before rendering, preventing flickering and blank frames during video export.
|
||||||
|
|
||||||
|
## Local images with staticFile()
|
||||||
|
|
||||||
|
Place images in the `public/` folder and use `staticFile()` to reference them:
|
||||||
|
|
||||||
|
```
|
||||||
|
my-video/
|
||||||
|
├─ public/
|
||||||
|
│ ├─ logo.png
|
||||||
|
│ ├─ avatar.jpg
|
||||||
|
│ └─ icon.svg
|
||||||
|
├─ src/
|
||||||
|
├─ package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Img, staticFile } from "remotion";
|
||||||
|
|
||||||
|
<Img src={staticFile("logo.png")} />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Remote images
|
||||||
|
|
||||||
|
Remote URLs can be used directly without `staticFile()`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Img src="https://example.com/image.png" />
|
||||||
|
```
|
||||||
|
|
||||||
|
Ensure remote images have CORS enabled.
|
||||||
|
|
||||||
|
For animated GIFs, use the `<Gif>` component from `@remotion/gif` instead.
|
||||||
|
|
||||||
|
## Sizing and positioning
|
||||||
|
|
||||||
|
Use the `style` prop to control size and position:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Img
|
||||||
|
src={staticFile("photo.png")}
|
||||||
|
style={{
|
||||||
|
width: 500,
|
||||||
|
height: 300,
|
||||||
|
position: "absolute",
|
||||||
|
top: 100,
|
||||||
|
left: 50,
|
||||||
|
objectFit: "cover",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dynamic image paths
|
||||||
|
|
||||||
|
Use template literals for dynamic file references:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Img, staticFile, useCurrentFrame } from "remotion";
|
||||||
|
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
|
||||||
|
// Image sequence
|
||||||
|
<Img src={staticFile(`frames/frame${frame}.png`)} />
|
||||||
|
|
||||||
|
// Selecting based on props
|
||||||
|
<Img src={staticFile(`avatars/${props.userId}.png`)} />
|
||||||
|
|
||||||
|
// Conditional images
|
||||||
|
<Img src={staticFile(`icons/${isActive ? "active" : "inactive"}.svg`)} />
|
||||||
|
```
|
||||||
|
|
||||||
|
This pattern is useful for:
|
||||||
|
|
||||||
|
- Image sequences (frame-by-frame animations)
|
||||||
|
- User-specific avatars or profile images
|
||||||
|
- Theme-based icons
|
||||||
|
- State-dependent graphics
|
||||||
|
|
||||||
|
## Getting image dimensions
|
||||||
|
|
||||||
|
Use `getImageDimensions()` to get the dimensions of an image:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { getImageDimensions, staticFile } from "remotion";
|
||||||
|
|
||||||
|
const { width, height } = await getImageDimensions(staticFile("photo.png"));
|
||||||
|
```
|
||||||
|
|
||||||
|
This is useful for calculating aspect ratios or sizing compositions:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { getImageDimensions, staticFile, CalculateMetadataFunction } from "remotion";
|
||||||
|
|
||||||
|
const calculateMetadata: CalculateMetadataFunction = async () => {
|
||||||
|
const { width, height } = await getImageDimensions(staticFile("photo.png"));
|
||||||
|
return {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
---
|
||||||
|
name: import-srt-captions
|
||||||
|
description: Importing .srt subtitle files into Remotion using @remotion/captions
|
||||||
|
metadata:
|
||||||
|
tags: captions, subtitles, srt, import, parse
|
||||||
|
---
|
||||||
|
|
||||||
|
# Importing .srt subtitles into Remotion
|
||||||
|
|
||||||
|
If you have an existing `.srt` subtitle file, you can import it into Remotion using `parseSrt()` from `@remotion/captions`.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
First, the @remotion/captions package needs to be installed.
|
||||||
|
If it is not installed, use the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx remotion add @remotion/captions # If project uses npm
|
||||||
|
bunx remotion add @remotion/captions # If project uses bun
|
||||||
|
yarn remotion add @remotion/captions # If project uses yarn
|
||||||
|
pnpm exec remotion add @remotion/captions # If project uses pnpm
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reading an .srt file
|
||||||
|
|
||||||
|
Use `staticFile()` to reference an `.srt` file in your `public` folder, then fetch and parse it:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {useState, useEffect, useCallback} from 'react';
|
||||||
|
import {AbsoluteFill, staticFile, useDelayRender} from 'remotion';
|
||||||
|
import {parseSrt} from '@remotion/captions';
|
||||||
|
import type {Caption} from '@remotion/captions';
|
||||||
|
|
||||||
|
export const MyComponent: React.FC = () => {
|
||||||
|
const [captions, setCaptions] = useState<Caption[] | null>(null);
|
||||||
|
const {delayRender, continueRender, cancelRender} = useDelayRender();
|
||||||
|
const [handle] = useState(() => delayRender());
|
||||||
|
|
||||||
|
const fetchCaptions = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(staticFile('subtitles.srt'));
|
||||||
|
const text = await response.text();
|
||||||
|
const {captions: parsed} = parseSrt({input: text});
|
||||||
|
setCaptions(parsed);
|
||||||
|
continueRender(handle);
|
||||||
|
} catch (e) {
|
||||||
|
cancelRender(e);
|
||||||
|
}
|
||||||
|
}, [continueRender, cancelRender, handle]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCaptions();
|
||||||
|
}, [fetchCaptions]);
|
||||||
|
|
||||||
|
if (!captions) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AbsoluteFill>{/* Use captions here */}</AbsoluteFill>;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Remote URLs are also supported - you can `fetch()` a remote file via URL instead of using `staticFile()`.
|
||||||
|
|
||||||
|
## Using imported captions
|
||||||
|
|
||||||
|
Once parsed, the captions are in the `Caption` format and can be used with all `@remotion/captions` utilities.
|
||||||
68
.claude/skills/remotion-best-practices/rules/lottie.md
Normal file
68
.claude/skills/remotion-best-practices/rules/lottie.md
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
---
|
||||||
|
name: lottie
|
||||||
|
description: Embedding Lottie animations in Remotion.
|
||||||
|
metadata:
|
||||||
|
category: Animation
|
||||||
|
---
|
||||||
|
|
||||||
|
# Using Lottie Animations in Remotion
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
First, the @remotion/lottie package needs to be installed.
|
||||||
|
If it is not, use the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx remotion add @remotion/lottie # If project uses npm
|
||||||
|
bunx remotion add @remotion/lottie # If project uses bun
|
||||||
|
yarn remotion add @remotion/lottie # If project uses yarn
|
||||||
|
pnpm exec remotion add @remotion/lottie # If project uses pnpm
|
||||||
|
```
|
||||||
|
|
||||||
|
## Displaying a Lottie file
|
||||||
|
|
||||||
|
To import a Lottie animation:
|
||||||
|
|
||||||
|
- Fetch the Lottie asset
|
||||||
|
- Wrap the loading process in `delayRender()` and `continueRender()`
|
||||||
|
- Save the animation data in a state
|
||||||
|
- Render the Lottie animation using the `Lottie` component from the `@remotion/lottie` package
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {Lottie, LottieAnimationData} from '@remotion/lottie';
|
||||||
|
import {useEffect, useState} from 'react';
|
||||||
|
import {cancelRender, continueRender, delayRender} from 'remotion';
|
||||||
|
|
||||||
|
export const MyAnimation = () => {
|
||||||
|
const [handle] = useState(() => delayRender('Loading Lottie animation'));
|
||||||
|
|
||||||
|
const [animationData, setAnimationData] = useState<LottieAnimationData | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('https://assets4.lottiefiles.com/packages/lf20_zyquagfl.json')
|
||||||
|
.then((data) => data.json())
|
||||||
|
.then((json) => {
|
||||||
|
setAnimationData(json);
|
||||||
|
continueRender(handle);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
cancelRender(err);
|
||||||
|
});
|
||||||
|
}, [handle]);
|
||||||
|
|
||||||
|
if (!animationData) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Lottie animationData={animationData} />;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Styling and animating
|
||||||
|
|
||||||
|
Lottie supports the `style` prop to allow styles and animations:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
return <Lottie animationData={animationData} style={{width: 400, height: 400}} />;
|
||||||
|
```
|
||||||
|
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
---
|
||||||
|
name: measuring-dom-nodes
|
||||||
|
description: Measuring DOM element dimensions in Remotion
|
||||||
|
metadata:
|
||||||
|
tags: measure, layout, dimensions, getBoundingClientRect, scale
|
||||||
|
---
|
||||||
|
|
||||||
|
# Measuring DOM nodes in Remotion
|
||||||
|
|
||||||
|
Remotion applies a `scale()` transform to the video container, which affects values from `getBoundingClientRect()`. Use `useCurrentScale()` to get correct measurements.
|
||||||
|
|
||||||
|
## Measuring element dimensions
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useCurrentScale } from "remotion";
|
||||||
|
import { useRef, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export const MyComponent = () => {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const scale = useCurrentScale();
|
||||||
|
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ref.current) return;
|
||||||
|
const rect = ref.current.getBoundingClientRect();
|
||||||
|
setDimensions({
|
||||||
|
width: rect.width / scale,
|
||||||
|
height: rect.height / scale,
|
||||||
|
});
|
||||||
|
}, [scale]);
|
||||||
|
|
||||||
|
return <div ref={ref}>Content to measure</div>;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
143
.claude/skills/remotion-best-practices/rules/measuring-text.md
Normal file
143
.claude/skills/remotion-best-practices/rules/measuring-text.md
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
---
|
||||||
|
name: measuring-text
|
||||||
|
description: Measuring text dimensions, fitting text to containers, and checking overflow
|
||||||
|
metadata:
|
||||||
|
tags: measure, text, layout, dimensions, fitText, fillTextBox
|
||||||
|
---
|
||||||
|
|
||||||
|
# Measuring text in Remotion
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Install @remotion/layout-utils if it is not already installed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx remotion add @remotion/layout-utils # If project uses npm
|
||||||
|
bunx remotion add @remotion/layout-utils # If project uses bun
|
||||||
|
yarn remotion add @remotion/layout-utils # If project uses yarn
|
||||||
|
pnpm exec remotion add @remotion/layout-utils # If project uses pnpm
|
||||||
|
```
|
||||||
|
|
||||||
|
## Measuring text dimensions
|
||||||
|
|
||||||
|
Use `measureText()` to calculate the width and height of text:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { measureText } from "@remotion/layout-utils";
|
||||||
|
|
||||||
|
const { width, height } = measureText({
|
||||||
|
text: "Hello World",
|
||||||
|
fontFamily: "Arial",
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: "bold",
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Results are cached - duplicate calls return the cached result.
|
||||||
|
|
||||||
|
## Fitting text to a width
|
||||||
|
|
||||||
|
Use `fitText()` to find the optimal font size for a container:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { fitText } from "@remotion/layout-utils";
|
||||||
|
|
||||||
|
const { fontSize } = fitText({
|
||||||
|
text: "Hello World",
|
||||||
|
withinWidth: 600,
|
||||||
|
fontFamily: "Inter",
|
||||||
|
fontWeight: "bold",
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: Math.min(fontSize, 80), // Cap at 80px
|
||||||
|
fontFamily: "Inter",
|
||||||
|
fontWeight: "bold",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Hello World
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Checking text overflow
|
||||||
|
|
||||||
|
Use `fillTextBox()` to check if text exceeds a box:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { fillTextBox } from "@remotion/layout-utils";
|
||||||
|
|
||||||
|
const box = fillTextBox({ maxBoxWidth: 400, maxLines: 3 });
|
||||||
|
|
||||||
|
const words = ["Hello", "World", "This", "is", "a", "test"];
|
||||||
|
for (const word of words) {
|
||||||
|
const { exceedsBox } = box.add({
|
||||||
|
text: word + " ",
|
||||||
|
fontFamily: "Arial",
|
||||||
|
fontSize: 24,
|
||||||
|
});
|
||||||
|
if (exceedsBox) {
|
||||||
|
// Text would overflow, handle accordingly
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best practices
|
||||||
|
|
||||||
|
**Load fonts first:** Only call measurement functions after fonts are loaded.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { loadFont } from "@remotion/google-fonts/Inter";
|
||||||
|
|
||||||
|
const { fontFamily, waitUntilDone } = loadFont("normal", {
|
||||||
|
weights: ["400"],
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
waitUntilDone().then(() => {
|
||||||
|
// Now safe to measure
|
||||||
|
const { width } = measureText({
|
||||||
|
text: "Hello",
|
||||||
|
fontFamily,
|
||||||
|
fontSize: 32,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use validateFontIsLoaded:** Catch font loading issues early:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
measureText({
|
||||||
|
text: "Hello",
|
||||||
|
fontFamily: "MyCustomFont",
|
||||||
|
fontSize: 32,
|
||||||
|
validateFontIsLoaded: true, // Throws if font not loaded
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Match font properties:** Use the same properties for measurement and rendering:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const fontStyle = {
|
||||||
|
fontFamily: "Inter",
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: "bold" as const,
|
||||||
|
letterSpacing: "0.5px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const { width } = measureText({
|
||||||
|
text: "Hello",
|
||||||
|
...fontStyle,
|
||||||
|
});
|
||||||
|
|
||||||
|
return <div style={fontStyle}>Hello</div>;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Avoid padding and border:** Use `outline` instead of `border` to prevent layout differences:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div style={{ outline: "2px solid red" }}>Text</div>
|
||||||
|
```
|
||||||
106
.claude/skills/remotion-best-practices/rules/sequencing.md
Normal file
106
.claude/skills/remotion-best-practices/rules/sequencing.md
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
---
|
||||||
|
name: sequencing
|
||||||
|
description: Sequencing patterns for Remotion - delay, trim, limit duration of items
|
||||||
|
metadata:
|
||||||
|
tags: sequence, series, timing, delay, trim
|
||||||
|
---
|
||||||
|
|
||||||
|
Use `<Sequence>` to delay when an element appears in the timeline.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Sequence } from "remotion";
|
||||||
|
|
||||||
|
const {fps} = useVideoConfig();
|
||||||
|
|
||||||
|
<Sequence from={1 * fps} durationInFrames={2 * fps} premountFor={1 * fps}>
|
||||||
|
<Title />
|
||||||
|
</Sequence>
|
||||||
|
<Sequence from={2 * fps} durationInFrames={2 * fps} premountFor={1 * fps}>
|
||||||
|
<Subtitle />
|
||||||
|
</Sequence>
|
||||||
|
```
|
||||||
|
|
||||||
|
This will by default wrap the component in an absolute fill element.
|
||||||
|
If the items should not be wrapped, use the `layout` prop:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Sequence layout="none">
|
||||||
|
<Title />
|
||||||
|
</Sequence>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Premounting
|
||||||
|
|
||||||
|
This loads the component in the timeline before it is actually played.
|
||||||
|
Always premount any `<Sequence>`!
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Sequence premountFor={1 * fps}>
|
||||||
|
<Title />
|
||||||
|
</Sequence>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Series
|
||||||
|
|
||||||
|
Use `<Series>` when elements should play one after another without overlap.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {Series} from 'remotion';
|
||||||
|
|
||||||
|
<Series>
|
||||||
|
<Series.Sequence durationInFrames={45}>
|
||||||
|
<Intro />
|
||||||
|
</Series.Sequence>
|
||||||
|
<Series.Sequence durationInFrames={60}>
|
||||||
|
<MainContent />
|
||||||
|
</Series.Sequence>
|
||||||
|
<Series.Sequence durationInFrames={30}>
|
||||||
|
<Outro />
|
||||||
|
</Series.Sequence>
|
||||||
|
</Series>;
|
||||||
|
```
|
||||||
|
|
||||||
|
Same as with `<Sequence>`, the items will be wrapped in an absolute fill element by default when using `<Series.Sequence>`, unless the `layout` prop is set to `none`.
|
||||||
|
|
||||||
|
### Series with overlaps
|
||||||
|
|
||||||
|
Use negative offset for overlapping sequences:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Series>
|
||||||
|
<Series.Sequence durationInFrames={60}>
|
||||||
|
<SceneA />
|
||||||
|
</Series.Sequence>
|
||||||
|
<Series.Sequence offset={-15} durationInFrames={60}>
|
||||||
|
{/* Starts 15 frames before SceneA ends */}
|
||||||
|
<SceneB />
|
||||||
|
</Series.Sequence>
|
||||||
|
</Series>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frame References Inside Sequences
|
||||||
|
|
||||||
|
Inside a Sequence, `useCurrentFrame()` returns the local frame (starting from 0):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Sequence from={60} durationInFrames={30}>
|
||||||
|
<MyComponent />
|
||||||
|
{/* Inside MyComponent, useCurrentFrame() returns 0-29, not 60-89 */}
|
||||||
|
</Sequence>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Nested Sequences
|
||||||
|
|
||||||
|
Sequences can be nested for complex timing:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Sequence from={0} durationInFrames={120}>
|
||||||
|
<Background />
|
||||||
|
<Sequence from={15} durationInFrames={90} layout="none">
|
||||||
|
<Title />
|
||||||
|
</Sequence>
|
||||||
|
<Sequence from={45} durationInFrames={60} layout="none">
|
||||||
|
<Subtitle />
|
||||||
|
</Sequence>
|
||||||
|
</Sequence>
|
||||||
|
```
|
||||||
11
.claude/skills/remotion-best-practices/rules/tailwind.md
Normal file
11
.claude/skills/remotion-best-practices/rules/tailwind.md
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
---
|
||||||
|
name: tailwind
|
||||||
|
description: Using TailwindCSS in Remotion.
|
||||||
|
metadata:
|
||||||
|
---
|
||||||
|
|
||||||
|
You can and should use TailwindCSS in Remotion, if TailwindCSS is installed in the project.
|
||||||
|
|
||||||
|
Don't use `transition-*` or `animate-*` classes - always animate using the `useCurrentFrame()` hook.
|
||||||
|
|
||||||
|
Tailwind must be installed and enabled first in a Remotion project - fetch https://www.remotion.dev/docs/tailwind using WebFetch for instructions.
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
---
|
||||||
|
name: text-animations
|
||||||
|
description: Typography and text animation patterns for Remotion.
|
||||||
|
metadata:
|
||||||
|
tags: typography, text, typewriter, highlighter ken
|
||||||
|
---
|
||||||
|
|
||||||
|
## Text animations
|
||||||
|
|
||||||
|
Based on `useCurrentFrame()`, reduce the string character by character to create a typewriter effect.
|
||||||
|
|
||||||
|
## Typewriter Effect
|
||||||
|
|
||||||
|
See [Typewriter](assets/text-animations-typewriter.tsx) for an advanced example with a blinking cursor and a pause after the first sentence.
|
||||||
|
|
||||||
|
Always use string slicing for typewriter effects. Never use per-character opacity.
|
||||||
|
|
||||||
|
## Word Highlighting
|
||||||
|
|
||||||
|
See [Word Highlight](assets/text-animations-word-highlight.tsx) for an example for how a word highlight is animated, like with a highlighter pen.
|
||||||
179
.claude/skills/remotion-best-practices/rules/timing.md
Normal file
179
.claude/skills/remotion-best-practices/rules/timing.md
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
---
|
||||||
|
name: timing
|
||||||
|
description: Interpolation curves in Remotion - linear, easing, spring animations
|
||||||
|
metadata:
|
||||||
|
tags: spring, bounce, easing, interpolation
|
||||||
|
---
|
||||||
|
|
||||||
|
A simple linear interpolation is done using the `interpolate` function.
|
||||||
|
|
||||||
|
```ts title="Going from 0 to 1 over 100 frames"
|
||||||
|
import {interpolate} from 'remotion';
|
||||||
|
|
||||||
|
const opacity = interpolate(frame, [0, 100], [0, 1]);
|
||||||
|
```
|
||||||
|
|
||||||
|
By default, the values are not clamped, so the value can go outside the range [0, 1].
|
||||||
|
Here is how they can be clamped:
|
||||||
|
|
||||||
|
```ts title="Going from 0 to 1 over 100 frames with extrapolation"
|
||||||
|
const opacity = interpolate(frame, [0, 100], [0, 1], {
|
||||||
|
extrapolateRight: 'clamp',
|
||||||
|
extrapolateLeft: 'clamp',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Spring animations
|
||||||
|
|
||||||
|
Spring animations have a more natural motion.
|
||||||
|
They go from 0 to 1 over time.
|
||||||
|
|
||||||
|
```ts title="Spring animation from 0 to 1 over 100 frames"
|
||||||
|
import {spring, useCurrentFrame, useVideoConfig} from 'remotion';
|
||||||
|
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const {fps} = useVideoConfig();
|
||||||
|
|
||||||
|
const scale = spring({
|
||||||
|
frame,
|
||||||
|
fps,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Physical properties
|
||||||
|
|
||||||
|
The default configuration is: `mass: 1, damping: 10, stiffness: 100`.
|
||||||
|
This leads to the animation having a bit of bounce before it settles.
|
||||||
|
|
||||||
|
The config can be overwritten like this:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const scale = spring({
|
||||||
|
frame,
|
||||||
|
fps,
|
||||||
|
config: {damping: 200},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
The recommended configuration for a natural motion without a bounce is: `{ damping: 200 }`.
|
||||||
|
|
||||||
|
Here are some common configurations:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const smooth = {damping: 200}; // Smooth, no bounce (subtle reveals)
|
||||||
|
const snappy = {damping: 20, stiffness: 200}; // Snappy, minimal bounce (UI elements)
|
||||||
|
const bouncy = {damping: 8}; // Bouncy entrance (playful animations)
|
||||||
|
const heavy = {damping: 15, stiffness: 80, mass: 2}; // Heavy, slow, small bounce
|
||||||
|
```
|
||||||
|
|
||||||
|
### Delay
|
||||||
|
|
||||||
|
The animation starts immediately by default.
|
||||||
|
Use the `delay` parameter to delay the animation by a number of frames.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const entrance = spring({
|
||||||
|
frame: frame - ENTRANCE_DELAY,
|
||||||
|
fps,
|
||||||
|
delay: 20,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Duration
|
||||||
|
|
||||||
|
A `spring()` has a natural duration based on the physical properties.
|
||||||
|
To stretch the animation to a specific duration, use the `durationInFrames` parameter.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const spring = spring({
|
||||||
|
frame,
|
||||||
|
fps,
|
||||||
|
durationInFrames: 40,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Combining spring() with interpolate()
|
||||||
|
|
||||||
|
Map spring output (0-1) to custom ranges:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const springProgress = spring({
|
||||||
|
frame,
|
||||||
|
fps,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Map to rotation
|
||||||
|
const rotation = interpolate(springProgress, [0, 1], [0, 360]);
|
||||||
|
|
||||||
|
<div style={{rotate: rotation + 'deg'}} />;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding springs
|
||||||
|
|
||||||
|
Springs return just numbers, so math can be performed:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const {fps, durationInFrames} = useVideoConfig();
|
||||||
|
|
||||||
|
const inAnimation = spring({
|
||||||
|
frame,
|
||||||
|
fps,
|
||||||
|
});
|
||||||
|
const outAnimation = spring({
|
||||||
|
frame,
|
||||||
|
fps,
|
||||||
|
durationInFrames: 1 * fps,
|
||||||
|
delay: durationInFrames - 1 * fps,
|
||||||
|
});
|
||||||
|
|
||||||
|
const scale = inAnimation - outAnimation;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Easing
|
||||||
|
|
||||||
|
Easing can be added to the `interpolate` function:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {interpolate, Easing} from 'remotion';
|
||||||
|
|
||||||
|
const value1 = interpolate(frame, [0, 100], [0, 1], {
|
||||||
|
easing: Easing.inOut(Easing.quad),
|
||||||
|
extrapolateLeft: 'clamp',
|
||||||
|
extrapolateRight: 'clamp',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
The default easing is `Easing.linear`.
|
||||||
|
There are various other convexities:
|
||||||
|
|
||||||
|
- `Easing.in` for starting slow and accelerating
|
||||||
|
- `Easing.out` for starting fast and slowing down
|
||||||
|
- `Easing.inOut`
|
||||||
|
|
||||||
|
and curves (sorted from most linear to most curved):
|
||||||
|
|
||||||
|
- `Easing.quad`
|
||||||
|
- `Easing.sin`
|
||||||
|
- `Easing.exp`
|
||||||
|
- `Easing.circle`
|
||||||
|
|
||||||
|
Convexities and curves need be combined for an easing function:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const value1 = interpolate(frame, [0, 100], [0, 1], {
|
||||||
|
easing: Easing.inOut(Easing.quad),
|
||||||
|
extrapolateLeft: 'clamp',
|
||||||
|
extrapolateRight: 'clamp',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Cubic bezier curves are also supported:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const value1 = interpolate(frame, [0, 100], [0, 1], {
|
||||||
|
easing: Easing.bezier(0.8, 0.22, 0.96, 0.65),
|
||||||
|
extrapolateLeft: 'clamp',
|
||||||
|
extrapolateRight: 'clamp',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
---
|
||||||
|
name: transcribe-captions
|
||||||
|
description: Transcribing audio to generate captions in Remotion
|
||||||
|
metadata:
|
||||||
|
tags: captions, transcribe, whisper, audio, speech-to-text
|
||||||
|
---
|
||||||
|
|
||||||
|
# Transcribing audio
|
||||||
|
|
||||||
|
Remotion provides several built-in options for transcribing audio to generate captions:
|
||||||
|
|
||||||
|
- `@remotion/install-whisper-cpp` - Transcribe locally on a server using Whisper.cpp. Fast and free, but requires server infrastructure.
|
||||||
|
https://remotion.dev/docs/install-whisper-cpp
|
||||||
|
|
||||||
|
- `@remotion/whisper-web` - Transcribe in the browser using WebAssembly. No server needed and free, but slower due to WASM overhead.
|
||||||
|
https://remotion.dev/docs/whisper-web
|
||||||
|
|
||||||
|
- `@remotion/openai-whisper` - Use OpenAI Whisper API for cloud-based transcription. Fast and no server needed, but requires payment.
|
||||||
|
https://remotion.dev/docs/openai-whisper/openai-whisper-api-to-captions
|
||||||
122
.claude/skills/remotion-best-practices/rules/transitions.md
Normal file
122
.claude/skills/remotion-best-practices/rules/transitions.md
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
---
|
||||||
|
name: transitions
|
||||||
|
description: Fullscreen scene transitions for Remotion.
|
||||||
|
metadata:
|
||||||
|
tags: transitions, fade, slide, wipe, scenes
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fullscreen transitions
|
||||||
|
|
||||||
|
Using `<TransitionSeries>` to animate between multiple scenes or clips.
|
||||||
|
This will absolutely position the children.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
First, the @remotion/transitions package needs to be installed.
|
||||||
|
If it is not, use the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx remotion add @remotion/transitions # If project uses npm
|
||||||
|
bunx remotion add @remotion/transitions # If project uses bun
|
||||||
|
yarn remotion add @remotion/transitions # If project uses yarn
|
||||||
|
pnpm exec remotion add @remotion/transitions # If project uses pnpm
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example usage
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {TransitionSeries, linearTiming} from '@remotion/transitions';
|
||||||
|
import {fade} from '@remotion/transitions/fade';
|
||||||
|
|
||||||
|
<TransitionSeries>
|
||||||
|
<TransitionSeries.Sequence durationInFrames={60}>
|
||||||
|
<SceneA />
|
||||||
|
</TransitionSeries.Sequence>
|
||||||
|
<TransitionSeries.Transition presentation={fade()} timing={linearTiming({durationInFrames: 15})} />
|
||||||
|
<TransitionSeries.Sequence durationInFrames={60}>
|
||||||
|
<SceneB />
|
||||||
|
</TransitionSeries.Sequence>
|
||||||
|
</TransitionSeries>;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Available Transition Types
|
||||||
|
|
||||||
|
Import transitions from their respective modules:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {fade} from '@remotion/transitions/fade';
|
||||||
|
import {slide} from '@remotion/transitions/slide';
|
||||||
|
import {wipe} from '@remotion/transitions/wipe';
|
||||||
|
import {flip} from '@remotion/transitions/flip';
|
||||||
|
import {clockWipe} from '@remotion/transitions/clock-wipe';
|
||||||
|
```
|
||||||
|
|
||||||
|
## Slide Transition with Direction
|
||||||
|
|
||||||
|
Specify slide direction for enter/exit animations.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {slide} from '@remotion/transitions/slide';
|
||||||
|
|
||||||
|
<TransitionSeries.Transition presentation={slide({direction: 'from-left'})} timing={linearTiming({durationInFrames: 20})} />;
|
||||||
|
```
|
||||||
|
|
||||||
|
Directions: `"from-left"`, `"from-right"`, `"from-top"`, `"from-bottom"`
|
||||||
|
|
||||||
|
## Timing Options
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {linearTiming, springTiming} from '@remotion/transitions';
|
||||||
|
|
||||||
|
// Linear timing - constant speed
|
||||||
|
linearTiming({durationInFrames: 20});
|
||||||
|
|
||||||
|
// Spring timing - organic motion
|
||||||
|
springTiming({config: {damping: 200}, durationInFrames: 25});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Duration calculation
|
||||||
|
|
||||||
|
Transitions overlap adjacent scenes, so the total composition length is **shorter** than the sum of all sequence durations.
|
||||||
|
|
||||||
|
For example, with two 60-frame sequences and a 15-frame transition:
|
||||||
|
|
||||||
|
- Without transitions: `60 + 60 = 120` frames
|
||||||
|
- With transition: `60 + 60 - 15 = 105` frames
|
||||||
|
|
||||||
|
The transition duration is subtracted because both scenes play simultaneously during the transition.
|
||||||
|
|
||||||
|
### Getting the duration of a transition
|
||||||
|
|
||||||
|
Use the `getDurationInFrames()` method on the timing object:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {linearTiming, springTiming} from '@remotion/transitions';
|
||||||
|
|
||||||
|
const linearDuration = linearTiming({durationInFrames: 20}).getDurationInFrames({fps: 30});
|
||||||
|
// Returns 20
|
||||||
|
|
||||||
|
const springDuration = springTiming({config: {damping: 200}}).getDurationInFrames({fps: 30});
|
||||||
|
// Returns calculated duration based on spring physics
|
||||||
|
```
|
||||||
|
|
||||||
|
For `springTiming` without an explicit `durationInFrames`, the duration depends on `fps` because it calculates when the spring animation settles.
|
||||||
|
|
||||||
|
### Calculating total composition duration
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {linearTiming} from '@remotion/transitions';
|
||||||
|
|
||||||
|
const scene1Duration = 60;
|
||||||
|
const scene2Duration = 60;
|
||||||
|
const scene3Duration = 60;
|
||||||
|
|
||||||
|
const timing1 = linearTiming({durationInFrames: 15});
|
||||||
|
const timing2 = linearTiming({durationInFrames: 20});
|
||||||
|
|
||||||
|
const transition1Duration = timing1.getDurationInFrames({fps: 30});
|
||||||
|
const transition2Duration = timing2.getDurationInFrames({fps: 30});
|
||||||
|
|
||||||
|
const totalDuration = scene1Duration + scene2Duration + scene3Duration - transition1Duration - transition2Duration;
|
||||||
|
// 60 + 60 + 60 - 15 - 20 = 145 frames
|
||||||
|
```
|
||||||
53
.claude/skills/remotion-best-practices/rules/trimming.md
Normal file
53
.claude/skills/remotion-best-practices/rules/trimming.md
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
---
|
||||||
|
name: trimming
|
||||||
|
description: Trimming patterns for Remotion - cut the beginning or end of animations
|
||||||
|
metadata:
|
||||||
|
tags: sequence, trim, clip, cut, offset
|
||||||
|
---
|
||||||
|
|
||||||
|
Use `<Sequence>` with a negative `from` value to trim the start of an animation.
|
||||||
|
|
||||||
|
## Trim the Beginning
|
||||||
|
|
||||||
|
A negative `from` value shifts time backwards, making the animation start partway through:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Sequence, useVideoConfig } from "remotion";
|
||||||
|
|
||||||
|
const fps = useVideoConfig();
|
||||||
|
|
||||||
|
<Sequence from={-0.5 * fps}>
|
||||||
|
<MyAnimation />
|
||||||
|
</Sequence>
|
||||||
|
```
|
||||||
|
|
||||||
|
The animation appears 15 frames into its progress - the first 15 frames are trimmed off.
|
||||||
|
Inside `<MyAnimation>`, `useCurrentFrame()` starts at 15 instead of 0.
|
||||||
|
|
||||||
|
## Trim the End
|
||||||
|
|
||||||
|
Use `durationInFrames` to unmount content after a specified duration:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
|
||||||
|
<Sequence durationInFrames={1.5 * fps}>
|
||||||
|
<MyAnimation />
|
||||||
|
</Sequence>
|
||||||
|
```
|
||||||
|
|
||||||
|
The animation plays for 45 frames, then the component unmounts.
|
||||||
|
|
||||||
|
## Trim and Delay
|
||||||
|
|
||||||
|
Nest sequences to both trim the beginning and delay when it appears:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Sequence from={30}>
|
||||||
|
<Sequence from={-15}>
|
||||||
|
<MyAnimation />
|
||||||
|
</Sequence>
|
||||||
|
</Sequence>
|
||||||
|
```
|
||||||
|
|
||||||
|
The inner sequence trims 15 frames from the start, and the outer sequence delays the result by 30 frames.
|
||||||
|
|
||||||
171
.claude/skills/remotion-best-practices/rules/videos.md
Normal file
171
.claude/skills/remotion-best-practices/rules/videos.md
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
---
|
||||||
|
name: videos
|
||||||
|
description: Embedding videos in Remotion - trimming, volume, speed, looping, pitch
|
||||||
|
metadata:
|
||||||
|
tags: video, media, trim, volume, speed, loop, pitch
|
||||||
|
---
|
||||||
|
|
||||||
|
# Using videos in Remotion
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
First, the @remotion/media package needs to be installed.
|
||||||
|
If it is not, use the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx remotion add @remotion/media # If project uses npm
|
||||||
|
bunx remotion add @remotion/media # If project uses bun
|
||||||
|
yarn remotion add @remotion/media # If project uses yarn
|
||||||
|
pnpm exec remotion add @remotion/media # If project uses pnpm
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `<Video>` from `@remotion/media` to embed videos into your composition.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Video } from "@remotion/media";
|
||||||
|
import { staticFile } from "remotion";
|
||||||
|
|
||||||
|
export const MyComposition = () => {
|
||||||
|
return <Video src={staticFile("video.mp4")} />;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Remote URLs are also supported:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Video src="https://remotion.media/video.mp4" />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Trimming
|
||||||
|
|
||||||
|
Use `trimBefore` and `trimAfter` to remove portions of the video. Values are in seconds.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const { fps } = useVideoConfig();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Video
|
||||||
|
src={staticFile("video.mp4")}
|
||||||
|
trimBefore={2 * fps} // Skip the first 2 seconds
|
||||||
|
trimAfter={10 * fps} // End at the 10 second mark
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Delaying
|
||||||
|
|
||||||
|
Wrap the video in a `<Sequence>` to delay when it appears:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Sequence, staticFile } from "remotion";
|
||||||
|
import { Video } from "@remotion/media";
|
||||||
|
|
||||||
|
const { fps } = useVideoConfig();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sequence from={1 * fps}>
|
||||||
|
<Video src={staticFile("video.mp4")} />
|
||||||
|
</Sequence>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
The video will appear after 1 second.
|
||||||
|
|
||||||
|
## Sizing and Position
|
||||||
|
|
||||||
|
Use the `style` prop to control size and position:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Video
|
||||||
|
src={staticFile("video.mp4")}
|
||||||
|
style={{
|
||||||
|
width: 500,
|
||||||
|
height: 300,
|
||||||
|
position: "absolute",
|
||||||
|
top: 100,
|
||||||
|
left: 50,
|
||||||
|
objectFit: "cover",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Volume
|
||||||
|
|
||||||
|
Set a static volume (0 to 1):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Video src={staticFile("video.mp4")} volume={0.5} />
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use a callback for dynamic volume based on the current frame:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { interpolate } from "remotion";
|
||||||
|
|
||||||
|
const { fps } = useVideoConfig();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Video
|
||||||
|
src={staticFile("video.mp4")}
|
||||||
|
volume={(f) =>
|
||||||
|
interpolate(f, [0, 1 * fps], [0, 1], { extrapolateRight: "clamp" })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `muted` to silence the video entirely:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Video src={staticFile("video.mp4")} muted />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Speed
|
||||||
|
|
||||||
|
Use `playbackRate` to change the playback speed:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Video src={staticFile("video.mp4")} playbackRate={2} /> {/* 2x speed */}
|
||||||
|
<Video src={staticFile("video.mp4")} playbackRate={0.5} /> {/* Half speed */}
|
||||||
|
```
|
||||||
|
|
||||||
|
Reverse playback is not supported.
|
||||||
|
|
||||||
|
## Looping
|
||||||
|
|
||||||
|
Use `loop` to loop the video indefinitely:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Video src={staticFile("video.mp4")} loop />
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `loopVolumeCurveBehavior` to control how the frame count behaves when looping:
|
||||||
|
|
||||||
|
- `"repeat"`: Frame count resets to 0 each loop (for `volume` callback)
|
||||||
|
- `"extend"`: Frame count continues incrementing
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Video
|
||||||
|
src={staticFile("video.mp4")}
|
||||||
|
loop
|
||||||
|
loopVolumeCurveBehavior="extend"
|
||||||
|
volume={(f) => interpolate(f, [0, 300], [1, 0])} // Fade out over multiple loops
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pitch
|
||||||
|
|
||||||
|
Use `toneFrequency` to adjust the pitch without affecting speed. Values range from 0.01 to 2:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Video
|
||||||
|
src={staticFile("video.mp4")}
|
||||||
|
toneFrequency={1.5} // Higher pitch
|
||||||
|
/>
|
||||||
|
<Video
|
||||||
|
src={staticFile("video.mp4")}
|
||||||
|
toneFrequency={0.8} // Lower pitch
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
Pitch shifting only works during server-side rendering, not in the Remotion Studio preview or in the `<Player />`.
|
||||||
296
.claude/skills/tpmjs-tool-creator/SKILL.md
Normal file
296
.claude/skills/tpmjs-tool-creator/SKILL.md
Normal file
|
|
@ -0,0 +1,296 @@
|
||||||
|
---
|
||||||
|
name: tpmjs-tool-creator
|
||||||
|
description: Guide for creating official TPMJS tools using the blocks CLI. Use when a user wants to create a new tool for the TPMJS registry, add a tool to packages/tools/official/, implement an AI SDK v6 tool, define a block in blocks.yml, validate a tool with `pnpm blocks run`, or publish a tool to npm with the tpmjs keyword.
|
||||||
|
---
|
||||||
|
|
||||||
|
# TPMJS Tool Creator
|
||||||
|
|
||||||
|
Create production-ready tools for the TPMJS registry using the blocks CLI. Tools are npm packages following the AI SDK v6 pattern, validated by blocks, and automatically synced to tpmjs.com.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Define the tool block in `packages/tools/official/blocks.yml`
|
||||||
|
2. Create the tool package directory
|
||||||
|
3. Implement the tool using AI SDK v6 `tool()` + `jsonSchema()`
|
||||||
|
4. Validate with `pnpm blocks run <tool-name>`
|
||||||
|
5. Build and publish to npm
|
||||||
|
|
||||||
|
## Step 1: Define in blocks.yml
|
||||||
|
|
||||||
|
Add to the `blocks:` section of `packages/tools/official/blocks.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
blocks:
|
||||||
|
category.toolName:
|
||||||
|
type: utility
|
||||||
|
description: "LLM-friendly description of what the tool does"
|
||||||
|
path: "tool-directory-name"
|
||||||
|
domain_rules:
|
||||||
|
- id: rule_name
|
||||||
|
description: "What this implementation must do"
|
||||||
|
inputs:
|
||||||
|
- name: inputName
|
||||||
|
type: string
|
||||||
|
description: "Description for LLMs"
|
||||||
|
- name: optionalInput
|
||||||
|
type: number
|
||||||
|
optional: true
|
||||||
|
description: "Optional parameter"
|
||||||
|
outputs:
|
||||||
|
- name: result
|
||||||
|
type: ResultType
|
||||||
|
description: "What the tool returns"
|
||||||
|
measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Category prefix** (before the dot): `research`, `web`, `data`, `documentation`, `engineering`, `security`, `statistics`, `ops`, `agent`, `sandbox`, `utilities`, `html`, `compliance`, `finance`, `legal`, `hr`, `marketing`, `cx`, `edu`, `sales`.
|
||||||
|
|
||||||
|
For domain entities and quality measures, see [references/domain.md](references/domain.md).
|
||||||
|
|
||||||
|
## Step 2: Create Package Directory
|
||||||
|
|
||||||
|
Create `packages/tools/official/<tool-name>/`:
|
||||||
|
|
||||||
|
```
|
||||||
|
<tool-name>/
|
||||||
|
├── package.json
|
||||||
|
├── tsconfig.json
|
||||||
|
├── tsup.config.ts
|
||||||
|
├── README.md
|
||||||
|
└── src/
|
||||||
|
└── index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
**package.json:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "@tpmjs/official-<tool-name>",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Short description",
|
||||||
|
"type": "module",
|
||||||
|
"keywords": ["tpmjs", "<category>", "ai"],
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"default": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": ["dist"],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsup",
|
||||||
|
"dev": "tsup --watch",
|
||||||
|
"type-check": "tsc --noEmit",
|
||||||
|
"clean": "rm -rf dist .turbo"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tpmjs/tsconfig": "workspace:*",
|
||||||
|
"tsup": "^8.5.1",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"ai": "6.0.49"
|
||||||
|
},
|
||||||
|
"publishConfig": { "access": "public" },
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/tpmjs/tpmjs.git",
|
||||||
|
"directory": "packages/tools/official/<tool-name>"
|
||||||
|
},
|
||||||
|
"homepage": "https://tpmjs.com",
|
||||||
|
"license": "MIT",
|
||||||
|
"tpmjs": {
|
||||||
|
"category": "<category>",
|
||||||
|
"frameworks": ["vercel-ai"],
|
||||||
|
"tools": [
|
||||||
|
{
|
||||||
|
"name": "toolName",
|
||||||
|
"description": "Clear description (20+ chars)."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**tsconfig.json:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"extends": "@tpmjs/tsconfig/react-library.json",
|
||||||
|
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**tsup.config.ts:**
|
||||||
|
```typescript
|
||||||
|
import { defineConfig } from 'tsup';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
entry: ['src/index.ts'],
|
||||||
|
format: ['esm'],
|
||||||
|
dts: true,
|
||||||
|
clean: true,
|
||||||
|
sourcemap: true,
|
||||||
|
target: 'es2022',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 3: Implement the Tool
|
||||||
|
|
||||||
|
Every tool follows this AI SDK v6 pattern in `src/index.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { jsonSchema, tool } from 'ai';
|
||||||
|
|
||||||
|
interface MyToolInput {
|
||||||
|
param1: string;
|
||||||
|
param2?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MyToolResult {
|
||||||
|
data: string;
|
||||||
|
metadata: { processedAt: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const myTool = tool({
|
||||||
|
description: 'Clear LLM-friendly description of what this tool does.',
|
||||||
|
parameters: jsonSchema<MyToolInput>({
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
param1: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'What param1 is for',
|
||||||
|
},
|
||||||
|
param2: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Optional: what param2 is for',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['param1'],
|
||||||
|
additionalProperties: false,
|
||||||
|
}),
|
||||||
|
execute: async (input): Promise<MyToolResult> => {
|
||||||
|
if (!input.param1) {
|
||||||
|
throw new Error('param1 is required and must be non-empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await processData(input.param1);
|
||||||
|
return {
|
||||||
|
data: result,
|
||||||
|
metadata: { processedAt: new Date().toISOString() },
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to process: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default myTool;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hard rules:**
|
||||||
|
- No stubs, TODOs, or placeholders — every tool must be fully working
|
||||||
|
- Single-shot: one call in, one structured result out
|
||||||
|
- Validate inputs before processing
|
||||||
|
- Try-catch with descriptive errors including context
|
||||||
|
- `additionalProperties: false` on jsonSchema
|
||||||
|
- Description on every schema property
|
||||||
|
- Export as both named and default export
|
||||||
|
- Output interface must be exported
|
||||||
|
|
||||||
|
### Multi-Tool Packages
|
||||||
|
|
||||||
|
For packages with multiple tools, add root-level files:
|
||||||
|
|
||||||
|
**block.ts:**
|
||||||
|
```typescript
|
||||||
|
import { toolA, toolB } from './src/index.js';
|
||||||
|
export const block = { name: 'package-name', tools: { toolA, toolB } };
|
||||||
|
export default block;
|
||||||
|
```
|
||||||
|
|
||||||
|
**index.ts (root):**
|
||||||
|
```typescript
|
||||||
|
export * from './src/index.js';
|
||||||
|
export { default } from './src/index.js';
|
||||||
|
```
|
||||||
|
|
||||||
|
Each tool gets its own entry in blocks.yml (same `path`) and in `tpmjs.tools` array.
|
||||||
|
|
||||||
|
## Step 4: Validate
|
||||||
|
|
||||||
|
The blocks CLI domain validator requires an OpenAI API key. Source it from `.env.local` before running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packages/tools/official
|
||||||
|
|
||||||
|
# Load the OpenAI API key for domain validation
|
||||||
|
source ../../../.env.local
|
||||||
|
export OPENAI_API_KEY
|
||||||
|
|
||||||
|
pnpm blocks run <tool-name> # Validate (schema → shape → domain)
|
||||||
|
pnpm blocks run <tool-name> --force # Force full validation (skip cache)
|
||||||
|
pnpm blocks run <tool-name> --json # JSON output for debugging
|
||||||
|
pnpm blocks run --all # Validate all tools
|
||||||
|
```
|
||||||
|
|
||||||
|
**Common errors:**
|
||||||
|
- `Tool "X" not found in exports` → Export name must match blocks.yml
|
||||||
|
- `Required file not found` → Check package root has all required files
|
||||||
|
- `invalid tpmjs field` → Category must be valid, tools array required
|
||||||
|
|
||||||
|
## Step 5: Build and Publish
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter=@tpmjs/official-<tool-name> build
|
||||||
|
cd packages/tools/official/<tool-name> && npm publish --access public
|
||||||
|
```
|
||||||
|
|
||||||
|
The tool syncs to tpmjs.com automatically via the changes feed (every 2 min) and keyword search (every 15 min). To trigger immediately:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source apps/web/.env.local
|
||||||
|
curl -X POST https://tpmjs.com/api/sync/keyword \
|
||||||
|
-H "Authorization: Bearer $CRON_SECRET"
|
||||||
|
```
|
||||||
|
|
||||||
|
## README Template
|
||||||
|
|
||||||
|
Every tool needs a README:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# @tpmjs/official-<tool-name>
|
||||||
|
|
||||||
|
Short description.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
npm install @tpmjs/official-<tool-name>
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
\`\`\`typescript
|
||||||
|
import { myTool } from '@tpmjs/official-<tool-name>';
|
||||||
|
|
||||||
|
const result = await myTool.execute({ param1: 'example' });
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
|
||||||
|
| Name | Type | Required | Description |
|
||||||
|
|--------|--------|----------|--------------------|
|
||||||
|
| param1 | string | Yes | What param1 is for |
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|--------|----------------------|
|
||||||
|
| data | string | The processed result |
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
```
|
||||||
58
.claude/skills/tpmjs-tool-creator/references/domain.md
Normal file
58
.claude/skills/tpmjs-tool-creator/references/domain.md
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
# Domain Reference
|
||||||
|
|
||||||
|
## Entities
|
||||||
|
|
||||||
|
Reusable output types defined in blocks.yml. Reference these in your tool's output `type` field.
|
||||||
|
|
||||||
|
| Entity | Fields |
|
||||||
|
|--------|--------|
|
||||||
|
| url | href, domain, protocol, path, query, fragment |
|
||||||
|
| webpage | url, title, html, text, metadata |
|
||||||
|
| text_content | raw, sentences, paragraphs, wordCount |
|
||||||
|
| claim | statement, confidence, needsCitation, category |
|
||||||
|
| timeline | events, dateRange, gaps, eventCount |
|
||||||
|
| evidence | source, type, strength, relevance |
|
||||||
|
| summary | text, keyPoints, length, compressionRatio |
|
||||||
|
| sentiment | score, label, confidence, aspects |
|
||||||
|
| entity | name, type, mentions, context |
|
||||||
|
| relationship | source, target, type, strength |
|
||||||
|
| pattern | name, frequency, examples, significance |
|
||||||
|
| anomaly | description, severity, context, recommendation |
|
||||||
|
| metric | name, value, unit, trend |
|
||||||
|
| comparison | items, criteria, rankings, analysis |
|
||||||
|
| recommendation | action, priority, rationale, impact |
|
||||||
|
| risk | description, likelihood, impact, mitigation |
|
||||||
|
| code_snippet | language, code, explanation, complexity |
|
||||||
|
| api_endpoint | method, path, parameters, response |
|
||||||
|
| data_schema | fields, types, constraints, relationships |
|
||||||
|
| workflow_step | action, input, output, conditions |
|
||||||
|
|
||||||
|
## Quality Measures
|
||||||
|
|
||||||
|
Reference these in your output's `measures` array.
|
||||||
|
|
||||||
|
| Measure | Severity | What it checks |
|
||||||
|
|---------|----------|---------------|
|
||||||
|
| working_implementation | error | No TODOs, stubs, or placeholders. Returns actual computed values. |
|
||||||
|
| valid_output_structure | error | Returns object matching declared interface. All required fields present. Arrays never undefined. |
|
||||||
|
| proper_error_handling | error | Throws descriptive Error with context. Validates inputs. Catches external API errors. |
|
||||||
|
| ai_sdk_compliance | error | Uses `tool()` + `jsonSchema()` from 'ai'. Clear description. Every property has description. |
|
||||||
|
| npm_publishable | error | Valid package.json with tpmjs field. Named + default exports. Proper types. Semver version. |
|
||||||
|
| readme_documentation | error | README exists. Describes tool. Usage example. Documents inputs/outputs. |
|
||||||
|
| deterministic_output | warning | Same input produces same output (where applicable). |
|
||||||
|
| minimal_dependencies | warning | Uses stable, well-maintained packages. Avoids unnecessary deps. |
|
||||||
|
|
||||||
|
## Domain Rules
|
||||||
|
|
||||||
|
Common domain rule categories for the `domain_rules` field in blocks.yml:
|
||||||
|
|
||||||
|
- **Core implementation**: working code, proper types, error handling
|
||||||
|
- **Web & fetch**: URL validation, content extraction, timeout handling
|
||||||
|
- **Document generation**: format compliance, template rendering
|
||||||
|
- **Data transformation**: schema validation, type coercion, encoding
|
||||||
|
- **Engineering/code analysis**: AST parsing, complexity metrics
|
||||||
|
- **Security & compliance**: input sanitization, safe execution
|
||||||
|
- **Statistical rigor**: numerical accuracy, proper rounding
|
||||||
|
- **Workflow/recipe**: step sequencing, state management
|
||||||
|
|
||||||
|
Define custom rules specific to your tool's requirements. Each rule needs an `id` and `description`.
|
||||||
80
.env.example
Normal file
80
.env.example
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
# =============================================================================
|
||||||
|
# TPMJS Environment Variables
|
||||||
|
# =============================================================================
|
||||||
|
# Copy this file to .env.local and fill in the values.
|
||||||
|
# NEVER commit .env files with real secrets!
|
||||||
|
#
|
||||||
|
# Required variables are marked with [REQUIRED]
|
||||||
|
# Optional variables are marked with [OPTIONAL]
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Database [REQUIRED]
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Neon PostgreSQL connection string (get from https://console.neon.tech)
|
||||||
|
DATABASE_URL="postgresql://user:password@host/database?sslmode=require"
|
||||||
|
DATABASE_URL_UNPOOLED="postgresql://user:password@host/database?sslmode=require"
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Authentication [REQUIRED for auth features]
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Better Auth secret - generate with: openssl rand -base64 32
|
||||||
|
BETTER_AUTH_SECRET="your-32-char-minimum-secret-here"
|
||||||
|
# Base URL for auth callbacks (optional, auto-detected in most cases)
|
||||||
|
BETTER_AUTH_URL="http://localhost:3000"
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Cron Jobs [REQUIRED for sync endpoints]
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Secret for authenticating Vercel Cron requests - generate with: openssl rand -hex 32
|
||||||
|
CRON_SECRET="your-64-char-hex-secret-here"
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# API Key Encryption [REQUIRED for API key features]
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Secret for encrypting user API keys - generate with: openssl rand -base64 32
|
||||||
|
API_KEY_ENCRYPTION_SECRET="your-encryption-secret-here"
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# External Services [OPTIONAL]
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Resend - for sending emails (https://resend.com)
|
||||||
|
RESEND_API_KEY="re_your_resend_api_key"
|
||||||
|
|
||||||
|
# OpenAI - for AI features (https://platform.openai.com)
|
||||||
|
OPENAI_API_KEY="sk-your-openai-api-key"
|
||||||
|
|
||||||
|
# Vercel KV - for rate limiting (auto-configured on Vercel)
|
||||||
|
KV_REST_API_URL="https://your-kv-instance.kv.vercel-storage.com"
|
||||||
|
KV_REST_API_TOKEN="your-kv-token"
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Executor Services [OPTIONAL]
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Railway executor for tool execution
|
||||||
|
RAILWAY_EXECUTOR_URL="https://your-railway-service.up.railway.app"
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Discord Integration [OPTIONAL]
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
DISCORD_SUMMARY_AGENT_ID="your-agent-id"
|
||||||
|
DISCORD_GUILD_ID="your-guild-id"
|
||||||
|
DISCORD_SUMMARY_CHANNEL_ID="your-channel-id"
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Public Variables (safe to expose to browser)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
||||||
|
NEXT_PUBLIC_API_URL="http://localhost:3000/api"
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Development/Testing [OPTIONAL]
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
NODE_ENV="development"
|
||||||
|
|
||||||
|
# Integration test credentials (only for test environment)
|
||||||
|
# INTEGRATION_TEST_SESSION_TOKEN="test-session-token"
|
||||||
|
# INTEGRATION_TEST_API_KEY="test-api-key"
|
||||||
|
# INTEGRATION_TEST_USER_ID="test-user-id"
|
||||||
|
# INTEGRATION_TEST_USERNAME="test-username"
|
||||||
|
# TEST_BASE_URL="http://localhost:3000"
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
# Created by Vercel CLI
|
|
||||||
CRON_SECRET="CRON_SECRET=6c806d35cf6212f489c76414d38d2b6acbc44590ac78bb08aadea28dd04a29d0\n"
|
|
||||||
DATABASE_URL="postgresql://neondb_owner:npg_euvYo4OTi1lX@ep-broad-darkness-a4lml85k-pooler.us-east-1.aws.neon.tech/neondb?sslmode=require"
|
|
||||||
DATABASE_URL_UNPOOLED="postgresql://neondb_owner:npg_euvYo4OTi1lX@ep-broad-darkness-a4lml85k.us-east-1.aws.neon.tech/neondb?sslmode=require"
|
|
||||||
NEXT_PUBLIC_STACK_PROJECT_ID="d786bd3a-a31d-4c6b-9497-5d6803dd9d86"
|
|
||||||
NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY="pck_hafmpkaj047z331x5azv8bk5zggfnbgdedbj9pfqh1rn0"
|
|
||||||
NX_DAEMON="false"
|
|
||||||
PGDATABASE="neondb"
|
|
||||||
PGHOST="ep-broad-darkness-a4lml85k-pooler.us-east-1.aws.neon.tech"
|
|
||||||
PGHOST_UNPOOLED="ep-broad-darkness-a4lml85k.us-east-1.aws.neon.tech"
|
|
||||||
PGPASSWORD="npg_euvYo4OTi1lX"
|
|
||||||
PGUSER="neondb_owner"
|
|
||||||
POSTGRES_DATABASE="neondb"
|
|
||||||
POSTGRES_HOST="ep-broad-darkness-a4lml85k-pooler.us-east-1.aws.neon.tech"
|
|
||||||
POSTGRES_PASSWORD="npg_euvYo4OTi1lX"
|
|
||||||
POSTGRES_PRISMA_URL="postgresql://neondb_owner:npg_euvYo4OTi1lX@ep-broad-darkness-a4lml85k-pooler.us-east-1.aws.neon.tech/neondb?connect_timeout=15&sslmode=require"
|
|
||||||
POSTGRES_URL="postgresql://neondb_owner:npg_euvYo4OTi1lX@ep-broad-darkness-a4lml85k-pooler.us-east-1.aws.neon.tech/neondb?sslmode=require"
|
|
||||||
POSTGRES_URL_NON_POOLING="postgresql://neondb_owner:npg_euvYo4OTi1lX@ep-broad-darkness-a4lml85k.us-east-1.aws.neon.tech/neondb?sslmode=require"
|
|
||||||
POSTGRES_URL_NO_SSL="postgresql://neondb_owner:npg_euvYo4OTi1lX@ep-broad-darkness-a4lml85k-pooler.us-east-1.aws.neon.tech/neondb"
|
|
||||||
POSTGRES_USER="neondb_owner"
|
|
||||||
STACK_SECRET_SERVER_KEY="ssk_p05kwe938wx13rpera9xf1fewc816dwkbq658xcsbwj1g"
|
|
||||||
TURBO_CACHE="remote:rw"
|
|
||||||
TURBO_DOWNLOAD_LOCAL_ENABLED="true"
|
|
||||||
TURBO_REMOTE_ONLY="true"
|
|
||||||
TURBO_RUN_SUMMARY="true"
|
|
||||||
VERCEL="1"
|
|
||||||
VERCEL_ENV="production"
|
|
||||||
VERCEL_GIT_COMMIT_AUTHOR_LOGIN=""
|
|
||||||
VERCEL_GIT_COMMIT_AUTHOR_NAME=""
|
|
||||||
VERCEL_GIT_COMMIT_MESSAGE=""
|
|
||||||
VERCEL_GIT_COMMIT_REF=""
|
|
||||||
VERCEL_GIT_COMMIT_SHA=""
|
|
||||||
VERCEL_GIT_PREVIOUS_SHA=""
|
|
||||||
VERCEL_GIT_PROVIDER=""
|
|
||||||
VERCEL_GIT_PULL_REQUEST_ID=""
|
|
||||||
VERCEL_GIT_REPO_ID=""
|
|
||||||
VERCEL_GIT_REPO_OWNER=""
|
|
||||||
VERCEL_GIT_REPO_SLUG=""
|
|
||||||
VERCEL_OIDC_TOKEN="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Im1yay00MzAyZWMxYjY3MGY0OGE5OGFkNjFkYWRlNGEyM2JlNyJ9.eyJpc3MiOiJodHRwczovL29pZGMudmVyY2VsLmNvbS90cG1qcyIsInN1YiI6Im93bmVyOnRwbWpzOnByb2plY3Q6dHBtanMtd2ViOmVudmlyb25tZW50OmRldmVsb3BtZW50Iiwic2NvcGUiOiJvd25lcjp0cG1qczpwcm9qZWN0OnRwbWpzLXdlYjplbnZpcm9ubWVudDpkZXZlbG9wbWVudCIsImF1ZCI6Imh0dHBzOi8vdmVyY2VsLmNvbS90cG1qcyIsIm93bmVyIjoidHBtanMiLCJvd25lcl9pZCI6InRlYW1femtHV0NXYjdWakhvbmk2VmJ5ZmQyc3c4IiwicHJvamVjdCI6InRwbWpzLXdlYiIsInByb2plY3RfaWQiOiJwcmpfNWd1MEkwVzFjUFhkQ3ozd1RjQ0ZIejQzNUJ0MCIsImVudmlyb25tZW50IjoiZGV2ZWxvcG1lbnQiLCJwbGFuIjoicHJvIiwidXNlcl9pZCI6IkxKZk05VzdIdlljb2gyclVCaXRWd283ViIsIm5iZiI6MTc2NDM4OTAxMiwiaWF0IjoxNzY0Mzg5MDEyLCJleHAiOjE3NjQ0MzIyMTJ9.OF4IHrcmteA2lU1tkqHO1a9ITGGrCjCo29G8jI991q8_SQjgHZHVqcBj3AYVKZJDh6BjHib4HyNKdjO8nwUblF2dCFbYDv6y4hwB6jHNpsz32BE1JDKcXEJOKPtg_tBOFUDKtzMkPk7VOPWDVYw8Tz4_HZ_MR3SNoy1Pk9AFL-hEl3E-zR3bAYMDB8tKrIm9y9K4sZF6efMU7BR_J6Bf-i3IsbbrH-Axgq5dewlpogf-xHWmWaTXoUp6UFejNKhSMXqg3sAWTnizYeSGc2Ut6zNuAYPumUPBdQ37Kk7vuRNwS1h7RJz3vtEg6aOuw0-Ld0LdF-tWkfDGsVqanR_sxw"
|
|
||||||
VERCEL_TARGET_ENV="production"
|
|
||||||
VERCEL_URL=""
|
|
||||||
12
.gitallowed
Normal file
12
.gitallowed
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
# Allowed patterns for git-secrets (false positive exclusions)
|
||||||
|
# These are documentation examples, not real secrets
|
||||||
|
|
||||||
|
# Example API keys in documentation
|
||||||
|
tpmjs_sk_your_api_key_here
|
||||||
|
tpmjs_sk_your_api_key
|
||||||
|
tpmjs_sk_xxx
|
||||||
|
tpmjs_sk_abc123
|
||||||
|
tpmjs_sk_xxxxxxxxxxxxxxxxxxxx
|
||||||
|
|
||||||
|
# Ellipsized examples in docs (e.g., "tpmjs_sk_abc1...")
|
||||||
|
tpmjs_sk_[a-z0-9]+\.\.\.
|
||||||
66
.github/workflows/auto-close-published.yml
vendored
Normal file
66
.github/workflows/auto-close-published.yml
vendored
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
name: Auto-Close Published Issues
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# Run every hour to check for issues to close
|
||||||
|
- cron: '0 * * * *'
|
||||||
|
workflow_dispatch: # Allow manual trigger
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
auto-close:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Close published issues older than 24h
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const { data: issues } = await github.rest.issues.listForRepo({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
labels: 'published',
|
||||||
|
state: 'open',
|
||||||
|
per_page: 100
|
||||||
|
});
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const twentyFourHoursAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
for (const issue of issues) {
|
||||||
|
// Find when 'published' label was added
|
||||||
|
const { data: events } = await github.rest.issues.listEvents({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: issue.number,
|
||||||
|
per_page: 100
|
||||||
|
});
|
||||||
|
|
||||||
|
const publishedEvent = events
|
||||||
|
.filter(e => e.event === 'labeled' && e.label?.name === 'published')
|
||||||
|
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at))[0];
|
||||||
|
|
||||||
|
if (publishedEvent) {
|
||||||
|
const labeledAt = new Date(publishedEvent.created_at);
|
||||||
|
|
||||||
|
if (labeledAt < twentyFourHoursAgo) {
|
||||||
|
console.log(`Closing issue #${issue.number} - published ${labeledAt.toISOString()}`);
|
||||||
|
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: issue.number,
|
||||||
|
body: 'Auto-closing after 24 hours. The tool has been published successfully. Reopen if you encounter any issues.'
|
||||||
|
});
|
||||||
|
|
||||||
|
await github.rest.issues.update({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: issue.number,
|
||||||
|
state: 'closed',
|
||||||
|
state_reason: 'completed'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
72
.github/workflows/build-omega-mac.yml
vendored
Normal file
72
.github/workflows/build-omega-mac.yml
vendored
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
name: Build Omega Mac
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: macos-15
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Select Xcode
|
||||||
|
run: sudo xcode-select -s /Applications/Xcode_16.2.app/Contents/Developer
|
||||||
|
|
||||||
|
- name: Resolve dependencies
|
||||||
|
working-directory: apps/omega-mac
|
||||||
|
run: swift package resolve
|
||||||
|
|
||||||
|
- name: Build release
|
||||||
|
working-directory: apps/omega-mac
|
||||||
|
run: swift build -c release
|
||||||
|
|
||||||
|
- name: Package .app bundle
|
||||||
|
working-directory: apps/omega-mac
|
||||||
|
run: |
|
||||||
|
mkdir -p OmegaMac.app/Contents/MacOS
|
||||||
|
mkdir -p OmegaMac.app/Contents/Resources
|
||||||
|
cp .build/release/OmegaMac OmegaMac.app/Contents/MacOS/OmegaMac
|
||||||
|
|
||||||
|
cat > OmegaMac.app/Contents/Info.plist << 'PLIST'
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>OmegaMac</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>com.tpmjs.omega-mac</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>Omega</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>Omega</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0.0</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>14.0</string>
|
||||||
|
<key>NSHighResolutionCapable</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.app-sandbox</key>
|
||||||
|
<false/>
|
||||||
|
<key>com.apple.security.network.client</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
PLIST
|
||||||
|
|
||||||
|
codesign --force --sign - OmegaMac.app
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: OmegaMac
|
||||||
|
path: apps/omega-mac/OmegaMac.app
|
||||||
44
.github/workflows/claude-code-review.yml
vendored
Normal file
44
.github/workflows/claude-code-review.yml
vendored
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
name: Claude Code Review
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, ready_for_review, reopened]
|
||||||
|
# Optional: Only run on specific file changes
|
||||||
|
# paths:
|
||||||
|
# - "src/**/*.ts"
|
||||||
|
# - "src/**/*.tsx"
|
||||||
|
# - "src/**/*.js"
|
||||||
|
# - "src/**/*.jsx"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
claude-review:
|
||||||
|
# Optional: Filter by PR author
|
||||||
|
# if: |
|
||||||
|
# github.event.pull_request.user.login == 'external-contributor' ||
|
||||||
|
# github.event.pull_request.user.login == 'new-developer' ||
|
||||||
|
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
||||||
|
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: read
|
||||||
|
issues: read
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Run Claude Code Review
|
||||||
|
id: claude-review
|
||||||
|
uses: anthropics/claude-code-action@v1
|
||||||
|
with:
|
||||||
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
|
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
||||||
|
plugins: 'code-review@claude-code-plugins'
|
||||||
|
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
|
||||||
|
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||||
|
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||||
|
|
||||||
96
.github/workflows/claude.yml
vendored
Normal file
96
.github/workflows/claude.yml
vendored
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
name: Claude Code
|
||||||
|
|
||||||
|
on:
|
||||||
|
issue_comment:
|
||||||
|
types: [created]
|
||||||
|
pull_request_review_comment:
|
||||||
|
types: [created]
|
||||||
|
issues:
|
||||||
|
types: [opened, assigned, labeled]
|
||||||
|
pull_request_review:
|
||||||
|
types: [submitted]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# Standard Claude trigger - responds to @claude mentions
|
||||||
|
claude:
|
||||||
|
if: |
|
||||||
|
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||||
|
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||||
|
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||||
|
(github.event_name == 'issues' && (github.event.action == 'opened' || github.event.action == 'assigned') && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write # Push branches, create commits
|
||||||
|
pull-requests: write # Create and manage PRs
|
||||||
|
issues: write # Manage labels, close issues
|
||||||
|
id-token: write
|
||||||
|
actions: read # Read CI results on PRs
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Run Claude Code
|
||||||
|
id: claude
|
||||||
|
uses: anthropics/claude-code-action@v1
|
||||||
|
env:
|
||||||
|
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
CRON_SECRET: ${{ secrets.CRON_SECRET }}
|
||||||
|
with:
|
||||||
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
|
additional_permissions: |
|
||||||
|
actions: read
|
||||||
|
|
||||||
|
# Label-triggered Claude - for tool-request pipeline
|
||||||
|
claude-label-trigger:
|
||||||
|
if: github.event_name == 'issues' && github.event.action == 'labeled' && github.event.label.name == 'claude-working'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
issues: write
|
||||||
|
id-token: write
|
||||||
|
actions: read
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Get prompt from issue comments
|
||||||
|
id: get-prompt
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const comments = await github.rest.issues.listComments({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
per_page: 100
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find the most recent comment with @claude
|
||||||
|
const claudeComments = comments.data.filter(c => c.body.includes('@claude'));
|
||||||
|
if (claudeComments.length > 0) {
|
||||||
|
const latestComment = claudeComments[claudeComments.length - 1];
|
||||||
|
core.setOutput('prompt', latestComment.body);
|
||||||
|
core.setOutput('found', 'true');
|
||||||
|
} else {
|
||||||
|
core.setOutput('found', 'false');
|
||||||
|
core.setFailed('No @claude comment found in issue');
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: Run Claude Code
|
||||||
|
if: steps.get-prompt.outputs.found == 'true'
|
||||||
|
uses: anthropics/claude-code-action@v1
|
||||||
|
env:
|
||||||
|
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
CRON_SECRET: ${{ secrets.CRON_SECRET }}
|
||||||
|
with:
|
||||||
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
|
additional_permissions: |
|
||||||
|
actions: read
|
||||||
|
prompt: ${{ steps.get-prompt.outputs.prompt }}
|
||||||
|
# Allow gh CLI for issue management, npm for publishing
|
||||||
|
claude_args: '--allowedTools "Bash(gh:*)" "Bash(npm:*)" "Bash(pnpm:*)" "Bash(git:*)"'
|
||||||
48
.github/workflows/discord-summary.yml
vendored
Normal file
48
.github/workflows/discord-summary.yml
vendored
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
name: Discord Daily Summary
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# Run daily at 9 AM UTC
|
||||||
|
- cron: '0 9 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
# Allow manual trigger
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
post-summary:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Generate conversation ID with date
|
||||||
|
id: conv-id
|
||||||
|
run: |
|
||||||
|
# Create a date-based conversation ID like "discord-summary-2026-01-11"
|
||||||
|
CONV_ID="discord-summary-$(date -u +%Y-%m-%d)"
|
||||||
|
echo "conv_id=$CONV_ID" >> $GITHUB_OUTPUT
|
||||||
|
echo "Generated conversation ID: $CONV_ID"
|
||||||
|
|
||||||
|
- name: Trigger Discord Summary Agent
|
||||||
|
env:
|
||||||
|
TPMJS_API_KEY: ${{ secrets.TPMJS_API_KEY }}
|
||||||
|
run: |
|
||||||
|
echo "Triggering agent with conversation: ${{ steps.conv-id.outputs.conv_id }}"
|
||||||
|
|
||||||
|
# POST to the agent conversation endpoint
|
||||||
|
# Uses username/agent-slug URL format: /api/{username}/agents/{agent-slug}/conversation/{conv-id}
|
||||||
|
# Agent: ajax/tpmjs-discord
|
||||||
|
# Requires API key with agent:chat scope
|
||||||
|
RESPONSE=$(curl -s -X POST \
|
||||||
|
"https://tpmjs.com/api/ajax/agents/tpmjs-discord/conversation/${{ steps.conv-id.outputs.conv_id }}" \
|
||||||
|
-H "Authorization: Bearer $TPMJS_API_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"message": "Read the Discord server (guild ID 1349727923434815519) for the past 24 hours, excluding bots. Then post a detailed summary with an embed to channel 1442666515425132644. Include key discussions, announcements, and any action items."
|
||||||
|
}' \
|
||||||
|
--max-time 300)
|
||||||
|
|
||||||
|
echo "Response received"
|
||||||
|
# The response is SSE, so we just check if we got something back
|
||||||
|
if [ -z "$RESPONSE" ]; then
|
||||||
|
echo "Error: No response from agent"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Summary triggered successfully"
|
||||||
279
.github/workflows/endpoint-health-check.yml
vendored
Normal file
279
.github/workflows/endpoint-health-check.yml
vendored
Normal file
|
|
@ -0,0 +1,279 @@
|
||||||
|
name: Endpoint Health Check
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# Run every 5 minutes
|
||||||
|
- cron: '*/5 * * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
verbose:
|
||||||
|
description: 'Enable verbose output'
|
||||||
|
required: false
|
||||||
|
default: 'false'
|
||||||
|
type: boolean
|
||||||
|
|
||||||
|
env:
|
||||||
|
BASE_URL: ${{ secrets.VERCEL_PRODUCTION_URL || 'https://tpmjs.com' }}
|
||||||
|
# Test data
|
||||||
|
TEST_USERNAME: ajax
|
||||||
|
TEST_COLLECTION_SLUG: ajax-collection-tbc
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
health-check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Setup
|
||||||
|
run: |
|
||||||
|
echo "Starting health checks at $(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||||
|
echo "Base URL: $BASE_URL"
|
||||||
|
|
||||||
|
- name: Check Basic Health Endpoint
|
||||||
|
id: basic-health
|
||||||
|
run: |
|
||||||
|
echo "Testing: GET /api/health"
|
||||||
|
RESPONSE=$(curl -s -w "\n%{http_code}" "$BASE_URL/api/health" --connect-timeout 10 --max-time 30)
|
||||||
|
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
||||||
|
BODY=$(echo "$RESPONSE" | sed '$d')
|
||||||
|
|
||||||
|
echo "HTTP Status: $HTTP_CODE"
|
||||||
|
if [ "${{ inputs.verbose }}" = "true" ]; then
|
||||||
|
echo "Response: $BODY"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" -eq 200 ]; then
|
||||||
|
echo "status=pass" >> $GITHUB_OUTPUT
|
||||||
|
echo "✅ Basic health check passed"
|
||||||
|
else
|
||||||
|
echo "status=fail" >> $GITHUB_OUTPUT
|
||||||
|
echo "❌ Basic health check failed with status $HTTP_CODE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Check Database Health
|
||||||
|
id: db-health
|
||||||
|
run: |
|
||||||
|
echo "Testing: GET /api/tools (database connectivity)"
|
||||||
|
RESPONSE=$(curl -s -w "\n%{http_code}" "$BASE_URL/api/tools?limit=1" --connect-timeout 10 --max-time 30)
|
||||||
|
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
||||||
|
BODY=$(echo "$RESPONSE" | sed '$d')
|
||||||
|
|
||||||
|
echo "HTTP Status: $HTTP_CODE"
|
||||||
|
if [ "${{ inputs.verbose }}" = "true" ]; then
|
||||||
|
echo "Response: $BODY"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" -eq 200 ]; then
|
||||||
|
echo "status=pass" >> $GITHUB_OUTPUT
|
||||||
|
echo "✅ Database health check passed"
|
||||||
|
else
|
||||||
|
echo "status=fail" >> $GITHUB_OUTPUT
|
||||||
|
echo "❌ Database health check failed with status $HTTP_CODE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Check Platform Stats API
|
||||||
|
id: stats-api
|
||||||
|
run: |
|
||||||
|
echo "Testing: GET /api/stats"
|
||||||
|
RESPONSE=$(curl -s -w "\n%{http_code}" "$BASE_URL/api/stats" --connect-timeout 10 --max-time 30)
|
||||||
|
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
||||||
|
BODY=$(echo "$RESPONSE" | sed '$d')
|
||||||
|
|
||||||
|
echo "HTTP Status: $HTTP_CODE"
|
||||||
|
if [ "${{ inputs.verbose }}" = "true" ]; then
|
||||||
|
echo "Response: $BODY" | head -c 500
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" -eq 200 ] && echo "$BODY" | grep -q '"success":true'; then
|
||||||
|
echo "status=pass" >> $GITHUB_OUTPUT
|
||||||
|
echo "✅ Platform stats API check passed"
|
||||||
|
else
|
||||||
|
echo "status=fail" >> $GITHUB_OUTPUT
|
||||||
|
echo "❌ Platform stats API check failed with status $HTTP_CODE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Check MCP HTTP Transport - Initialize
|
||||||
|
id: mcp-http-init
|
||||||
|
run: |
|
||||||
|
echo "Testing: POST /api/mcp/$TEST_USERNAME/$TEST_COLLECTION_SLUG/http (initialize)"
|
||||||
|
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
|
||||||
|
"$BASE_URL/api/mcp/$TEST_USERNAME/$TEST_COLLECTION_SLUG/http" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer ${{ secrets.INTEGRATION_TEST_API_KEY }}" \
|
||||||
|
-d '{"jsonrpc":"2.0","id":1,"method":"initialize"}' \
|
||||||
|
--connect-timeout 15 --max-time 30)
|
||||||
|
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
||||||
|
BODY=$(echo "$RESPONSE" | sed '$d')
|
||||||
|
|
||||||
|
echo "HTTP Status: $HTTP_CODE"
|
||||||
|
if [ "${{ inputs.verbose }}" = "true" ]; then
|
||||||
|
echo "Response: $BODY"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for successful JSON-RPC response
|
||||||
|
if [ "$HTTP_CODE" -eq 200 ] && echo "$BODY" | grep -q '"result"'; then
|
||||||
|
echo "status=pass" >> $GITHUB_OUTPUT
|
||||||
|
echo "✅ MCP HTTP initialize check passed"
|
||||||
|
else
|
||||||
|
echo "status=fail" >> $GITHUB_OUTPUT
|
||||||
|
echo "❌ MCP HTTP initialize check failed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Check MCP HTTP Transport - Tools List
|
||||||
|
id: mcp-http-tools
|
||||||
|
run: |
|
||||||
|
echo "Testing: POST /api/mcp/$TEST_USERNAME/$TEST_COLLECTION_SLUG/http (tools/list)"
|
||||||
|
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
|
||||||
|
"$BASE_URL/api/mcp/$TEST_USERNAME/$TEST_COLLECTION_SLUG/http" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer ${{ secrets.INTEGRATION_TEST_API_KEY }}" \
|
||||||
|
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
|
||||||
|
--connect-timeout 15 --max-time 30)
|
||||||
|
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
||||||
|
BODY=$(echo "$RESPONSE" | sed '$d')
|
||||||
|
|
||||||
|
echo "HTTP Status: $HTTP_CODE"
|
||||||
|
if [ "${{ inputs.verbose }}" = "true" ]; then
|
||||||
|
echo "Response: $BODY" | head -c 500
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for successful JSON-RPC response with tools
|
||||||
|
if [ "$HTTP_CODE" -eq 200 ] && echo "$BODY" | grep -q '"tools"'; then
|
||||||
|
echo "status=pass" >> $GITHUB_OUTPUT
|
||||||
|
echo "✅ MCP HTTP tools/list check passed"
|
||||||
|
else
|
||||||
|
echo "status=fail" >> $GITHUB_OUTPUT
|
||||||
|
echo "❌ MCP HTTP tools/list check failed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Check MCP SSE Transport
|
||||||
|
id: mcp-sse
|
||||||
|
run: |
|
||||||
|
echo "Testing: POST /api/mcp/$TEST_USERNAME/$TEST_COLLECTION_SLUG/sse (initialize)"
|
||||||
|
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
|
||||||
|
"$BASE_URL/api/mcp/$TEST_USERNAME/$TEST_COLLECTION_SLUG/sse" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer ${{ secrets.INTEGRATION_TEST_API_KEY }}" \
|
||||||
|
-d '{"jsonrpc":"2.0","id":1,"method":"initialize"}' \
|
||||||
|
--connect-timeout 15 --max-time 30)
|
||||||
|
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
||||||
|
BODY=$(echo "$RESPONSE" | sed '$d')
|
||||||
|
|
||||||
|
echo "HTTP Status: $HTTP_CODE"
|
||||||
|
if [ "${{ inputs.verbose }}" = "true" ]; then
|
||||||
|
echo "Response: $BODY"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for SSE response with data prefix
|
||||||
|
if [ "$HTTP_CODE" -eq 200 ] && echo "$BODY" | grep -q 'data:'; then
|
||||||
|
echo "status=pass" >> $GITHUB_OUTPUT
|
||||||
|
echo "✅ MCP SSE check passed"
|
||||||
|
else
|
||||||
|
echo "status=fail" >> $GITHUB_OUTPUT
|
||||||
|
echo "❌ MCP SSE check failed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Check MCP Server Info (GET)
|
||||||
|
id: mcp-info
|
||||||
|
run: |
|
||||||
|
echo "Testing: GET /api/mcp/$TEST_USERNAME/$TEST_COLLECTION_SLUG/http"
|
||||||
|
RESPONSE=$(curl -s -w "\n%{http_code}" \
|
||||||
|
"$BASE_URL/api/mcp/$TEST_USERNAME/$TEST_COLLECTION_SLUG/http" \
|
||||||
|
--connect-timeout 10 --max-time 20)
|
||||||
|
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
||||||
|
BODY=$(echo "$RESPONSE" | sed '$d')
|
||||||
|
|
||||||
|
echo "HTTP Status: $HTTP_CODE"
|
||||||
|
if [ "${{ inputs.verbose }}" = "true" ]; then
|
||||||
|
echo "Response: $BODY"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" -eq 200 ] && echo "$BODY" | grep -q '"protocol":"mcp"'; then
|
||||||
|
echo "status=pass" >> $GITHUB_OUTPUT
|
||||||
|
echo "✅ MCP server info check passed"
|
||||||
|
else
|
||||||
|
echo "status=fail" >> $GITHUB_OUTPUT
|
||||||
|
echo "❌ MCP server info check failed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Check Tool Health Stats
|
||||||
|
id: tool-health-stats
|
||||||
|
run: |
|
||||||
|
echo "Testing: GET /api/stats/health"
|
||||||
|
RESPONSE=$(curl -s -w "\n%{http_code}" "$BASE_URL/api/stats/health" --connect-timeout 10 --max-time 30)
|
||||||
|
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
||||||
|
BODY=$(echo "$RESPONSE" | sed '$d')
|
||||||
|
|
||||||
|
echo "HTTP Status: $HTTP_CODE"
|
||||||
|
if [ "${{ inputs.verbose }}" = "true" ]; then
|
||||||
|
echo "Response: $BODY" | head -c 500
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" -eq 200 ] && echo "$BODY" | grep -q '"success":true'; then
|
||||||
|
echo "status=pass" >> $GITHUB_OUTPUT
|
||||||
|
echo "✅ Tool health stats check passed"
|
||||||
|
else
|
||||||
|
echo "status=fail" >> $GITHUB_OUTPUT
|
||||||
|
echo "❌ Tool health stats check failed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Report Health Status to API
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
# Collect all results
|
||||||
|
RESULTS=$(cat << EOF
|
||||||
|
{
|
||||||
|
"timestamp": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")",
|
||||||
|
"source": "github-actions",
|
||||||
|
"runId": "${{ github.run_id }}",
|
||||||
|
"checks": {
|
||||||
|
"basic_health": "${{ steps.basic-health.outputs.status }}",
|
||||||
|
"database": "${{ steps.db-health.outputs.status }}",
|
||||||
|
"stats_api": "${{ steps.stats-api.outputs.status }}",
|
||||||
|
"mcp_http_init": "${{ steps.mcp-http-init.outputs.status }}",
|
||||||
|
"mcp_http_tools": "${{ steps.mcp-http-tools.outputs.status }}",
|
||||||
|
"mcp_sse": "${{ steps.mcp-sse.outputs.status }}",
|
||||||
|
"mcp_info": "${{ steps.mcp-info.outputs.status }}",
|
||||||
|
"tool_health_stats": "${{ steps.tool-health-stats.outputs.status }}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
|
||||||
|
echo "Health Check Results:"
|
||||||
|
echo "$RESULTS" | jq .
|
||||||
|
|
||||||
|
# Report to the health status API if secret is available
|
||||||
|
if [ -n "${{ secrets.CRON_SECRET }}" ]; then
|
||||||
|
curl -s -X POST "$BASE_URL/api/health/report" \
|
||||||
|
-H "Authorization: Bearer ${{ secrets.CRON_SECRET }}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$RESULTS" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Summary
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
echo "## Health Check Summary" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| Endpoint | Status |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "|----------|--------|" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| Basic Health | ${{ steps.basic-health.outputs.status == 'pass' && '✅ Pass' || '❌ Fail' }} |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| Database | ${{ steps.db-health.outputs.status == 'pass' && '✅ Pass' || '❌ Fail' }} |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| Platform Stats | ${{ steps.stats-api.outputs.status == 'pass' && '✅ Pass' || '❌ Fail' }} |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| MCP HTTP Init | ${{ steps.mcp-http-init.outputs.status == 'pass' && '✅ Pass' || '❌ Fail' }} |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| MCP HTTP Tools | ${{ steps.mcp-http-tools.outputs.status == 'pass' && '✅ Pass' || '❌ Fail' }} |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| MCP SSE | ${{ steps.mcp-sse.outputs.status == 'pass' && '✅ Pass' || '❌ Fail' }} |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| MCP Server Info | ${{ steps.mcp-info.outputs.status == 'pass' && '✅ Pass' || '❌ Fail' }} |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| Tool Health Stats | ${{ steps.tool-health-stats.outputs.status == 'pass' && '✅ Pass' || '❌ Fail' }} |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
- name: Fail if any check failed
|
||||||
|
if: |
|
||||||
|
steps.basic-health.outputs.status == 'fail' ||
|
||||||
|
steps.db-health.outputs.status == 'fail' ||
|
||||||
|
steps.mcp-http-init.outputs.status == 'fail' ||
|
||||||
|
steps.mcp-http-tools.outputs.status == 'fail' ||
|
||||||
|
steps.mcp-sse.outputs.status == 'fail'
|
||||||
|
run: |
|
||||||
|
echo "One or more critical health checks failed!"
|
||||||
|
exit 1
|
||||||
135
.github/workflows/integration-tests.yml
vendored
Normal file
135
.github/workflows/integration-tests.yml
vendored
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
name: Integration Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
verbose:
|
||||||
|
description: 'Run tests in verbose mode'
|
||||||
|
required: false
|
||||||
|
default: 'false'
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- 'true'
|
||||||
|
- 'false'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: integration-tests-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
integration-tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 10.14.0
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: 'pnpm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build packages
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
- name: Cleanup orphaned test data (pre-test)
|
||||||
|
env:
|
||||||
|
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||||
|
INTEGRATION_TEST_USER_ID: ${{ secrets.INTEGRATION_TEST_USER_ID }}
|
||||||
|
run: pnpm --filter=@tpmjs/web test:cleanup-orphans
|
||||||
|
|
||||||
|
- name: Setup OpenAI key for test user
|
||||||
|
env:
|
||||||
|
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||||
|
API_KEY_ENCRYPTION_SECRET: ${{ secrets.API_KEY_ENCRYPTION_SECRET }}
|
||||||
|
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||||
|
INTEGRATION_TEST_USER_ID: ${{ secrets.INTEGRATION_TEST_USER_ID }}
|
||||||
|
run: pnpm --filter=@tpmjs/web test:setup-openai-key
|
||||||
|
|
||||||
|
- name: Wait for API
|
||||||
|
run: |
|
||||||
|
echo "Checking if API is available at $TEST_BASE_URL..."
|
||||||
|
for i in {1..30}; do
|
||||||
|
if curl -sf "$TEST_BASE_URL/api/health" > /dev/null 2>&1; then
|
||||||
|
echo "✅ API is available"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "Attempt $i/30: API not ready yet, waiting..."
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
echo "❌ API is not available after 60 seconds"
|
||||||
|
exit 1
|
||||||
|
env:
|
||||||
|
TEST_BASE_URL: ${{ secrets.TEST_BASE_URL }}
|
||||||
|
|
||||||
|
- name: Run integration tests
|
||||||
|
env:
|
||||||
|
INTEGRATION_TESTS: 'true'
|
||||||
|
TEST_BASE_URL: ${{ secrets.TEST_BASE_URL }}
|
||||||
|
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||||
|
INTEGRATION_TEST_USER_ID: ${{ secrets.INTEGRATION_TEST_USER_ID }}
|
||||||
|
INTEGRATION_TEST_USERNAME: ${{ secrets.INTEGRATION_TEST_USERNAME }}
|
||||||
|
INTEGRATION_TEST_SESSION_TOKEN: ${{ secrets.INTEGRATION_TEST_SESSION_TOKEN }}
|
||||||
|
INTEGRATION_TEST_API_KEY: ${{ secrets.INTEGRATION_TEST_API_KEY }}
|
||||||
|
CRON_SECRET: ${{ secrets.CRON_SECRET }}
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.event.inputs.verbose }}" = "true" ]; then
|
||||||
|
pnpm --filter=@tpmjs/web test:integration -- --reporter=verbose
|
||||||
|
else
|
||||||
|
pnpm --filter=@tpmjs/web test:integration
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Upload test results
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: integration-test-results
|
||||||
|
path: |
|
||||||
|
apps/web/test-results/
|
||||||
|
apps/web/coverage/
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
cleanup:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: integration-tests
|
||||||
|
if: always()
|
||||||
|
timeout-minutes: 5
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 10.14.0
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: 'pnpm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build @tpmjs/db
|
||||||
|
run: pnpm --filter=@tpmjs/db build
|
||||||
|
|
||||||
|
- name: Cleanup orphaned test data
|
||||||
|
env:
|
||||||
|
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||||
|
INTEGRATION_TEST_USER_ID: ${{ secrets.INTEGRATION_TEST_USER_ID }}
|
||||||
|
run: pnpm --filter=@tpmjs/web test:cleanup-orphans
|
||||||
114
.github/workflows/sync-enrich.yml
vendored
Normal file
114
.github/workflows/sync-enrich.yml
vendored
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
name: Sync Tool Enrichment
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# Run every 2 minutes
|
||||||
|
- cron: '*/2 * * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sync-enrich:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Trigger enrichment sync
|
||||||
|
id: sync
|
||||||
|
run: |
|
||||||
|
# Call the sync API and capture response
|
||||||
|
response=$(curl -X POST "${{ secrets.VERCEL_PRODUCTION_URL }}/api/sync/enrich" \
|
||||||
|
-H "Authorization: Bearer ${{ secrets.CRON_SECRET }}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-f -s -S)
|
||||||
|
|
||||||
|
echo "Response: $response"
|
||||||
|
|
||||||
|
# Extract data using jq
|
||||||
|
enriched=$(echo "$response" | jq -r '.data.enriched')
|
||||||
|
discovered=$(echo "$response" | jq -r '.data.discovered')
|
||||||
|
skipped=$(echo "$response" | jq -r '.data.skipped')
|
||||||
|
errors=$(echo "$response" | jq -r '.data.errors')
|
||||||
|
durationMs=$(echo "$response" | jq -r '.data.durationMs')
|
||||||
|
|
||||||
|
# Extract and display error messages
|
||||||
|
errorMessages=$(echo "$response" | jq -r '.data.errorMessages[]?' 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
if [ -n "$errorMessages" ]; then
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo "⚠️ ENRICHMENT ERRORS ($errors total):"
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo "$response" | jq -r '.data.errorMessages[]?' | while IFS= read -r error; do
|
||||||
|
echo " • $error"
|
||||||
|
done
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Set outputs for Discord notification
|
||||||
|
echo "enriched=$enriched" >> $GITHUB_OUTPUT
|
||||||
|
echo "discovered=$discovered" >> $GITHUB_OUTPUT
|
||||||
|
echo "skipped=$skipped" >> $GITHUB_OUTPUT
|
||||||
|
echo "errors=$errors" >> $GITHUB_OUTPUT
|
||||||
|
echo "durationMs=$durationMs" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
# Store error messages for Discord (first 3, truncated)
|
||||||
|
if [ "$errors" -gt 0 ]; then
|
||||||
|
errorSummary=$(echo "$response" | jq -r '.data.errorMessages[0:3]? | join("\n• ")' 2>/dev/null || echo "")
|
||||||
|
if [ -n "$errorSummary" ]; then
|
||||||
|
echo "• $errorSummary" > /tmp/error_summary.txt
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Determine status emoji
|
||||||
|
if [ "$errors" -gt 0 ]; then
|
||||||
|
echo "status_emoji=⚠️" >> $GITHUB_OUTPUT
|
||||||
|
echo "status_color=16776960" >> $GITHUB_OUTPUT # Yellow
|
||||||
|
else
|
||||||
|
echo "status_emoji=✅" >> $GITHUB_OUTPUT
|
||||||
|
echo "status_color=5763719" >> $GITHUB_OUTPUT # Green
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Send Discord notification
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
# Format duration
|
||||||
|
duration_sec=$(echo "scale=2; ${{ steps.sync.outputs.durationMs }} / 1000" | bc)
|
||||||
|
|
||||||
|
# Build Discord payload using jq for proper JSON escaping
|
||||||
|
error_text=""
|
||||||
|
|
||||||
|
if [ -f /tmp/error_summary.txt ] && [ ${{ steps.sync.outputs.errors }} -gt 0 ]; then
|
||||||
|
error_text=$(cat /tmp/error_summary.txt | head -c 800)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build fields array dynamically
|
||||||
|
base_fields='[
|
||||||
|
{ "name": "🔧 Enriched", "value": "${{ steps.sync.outputs.enriched }}", "inline": true },
|
||||||
|
{ "name": "🔍 Discovered", "value": "${{ steps.sync.outputs.discovered }}", "inline": true },
|
||||||
|
{ "name": "⏭️ Skipped", "value": "${{ steps.sync.outputs.skipped }}", "inline": true },
|
||||||
|
{ "name": "❌ Errors", "value": "${{ steps.sync.outputs.errors }}", "inline": true },
|
||||||
|
{ "name": "⏱️ Duration", "value": "'"${duration_sec}s"'", "inline": true },
|
||||||
|
{ "name": "🔗 Run", "value": "[View Logs](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})", "inline": true }
|
||||||
|
]'
|
||||||
|
|
||||||
|
# Create payload with dynamic fields
|
||||||
|
payload=$(jq -n \
|
||||||
|
--arg title "${{ steps.sync.outputs.status_emoji }} Tool Enrichment Sync" \
|
||||||
|
--argjson color ${{ steps.sync.outputs.status_color }} \
|
||||||
|
--argjson baseFields "$base_fields" \
|
||||||
|
--arg error_text "$error_text" \
|
||||||
|
--arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" \
|
||||||
|
'
|
||||||
|
{
|
||||||
|
embeds: [{
|
||||||
|
title: $title,
|
||||||
|
color: $color,
|
||||||
|
fields: (
|
||||||
|
$baseFields +
|
||||||
|
(if $error_text != "" then [{ name: "🔍 Error Details", value: ("```\n" + $error_text + "\n```"), inline: false }] else [] end)
|
||||||
|
),
|
||||||
|
timestamp: $timestamp
|
||||||
|
}]
|
||||||
|
}')
|
||||||
|
|
||||||
|
# Send to Discord
|
||||||
|
curl -X POST "${{ secrets.DISCORD_WEBHOOK }}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$payload"
|
||||||
4
.github/workflows/sync-manual.yml
vendored
4
.github/workflows/sync-manual.yml
vendored
|
|
@ -23,12 +23,12 @@ jobs:
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v2
|
uses: pnpm/action-setup@v2
|
||||||
with:
|
with:
|
||||||
version: 8
|
version: 9
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: '22'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
|
|
||||||
95
.github/workflows/sync-package.yml
vendored
Normal file
95
.github/workflows/sync-package.yml
vendored
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
name: Sync Single Package
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
packageName:
|
||||||
|
description: 'NPM package name to sync (e.g., fbx2vrma-converter)'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sync-package:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Sync package
|
||||||
|
id: sync
|
||||||
|
run: |
|
||||||
|
echo "Syncing package: ${{ inputs.packageName }}"
|
||||||
|
|
||||||
|
# Call the sync API and capture response
|
||||||
|
response=$(curl -X POST "${{ secrets.VERCEL_PRODUCTION_URL }}/api/sync/package" \
|
||||||
|
-H "Authorization: Bearer ${{ secrets.CRON_SECRET }}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"packageName": "${{ inputs.packageName }}"}' \
|
||||||
|
-s -S)
|
||||||
|
|
||||||
|
echo "Response: $response"
|
||||||
|
|
||||||
|
# Check if sync was successful
|
||||||
|
success=$(echo "$response" | jq -r '.success')
|
||||||
|
|
||||||
|
if [ "$success" = "true" ]; then
|
||||||
|
echo "status_emoji=✅" >> $GITHUB_OUTPUT
|
||||||
|
echo "status_color=5763719" >> $GITHUB_OUTPUT
|
||||||
|
echo "status_text=Success" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
# Extract data
|
||||||
|
packageId=$(echo "$response" | jq -r '.data.packageId')
|
||||||
|
version=$(echo "$response" | jq -r '.data.version')
|
||||||
|
toolCount=$(echo "$response" | jq -r '.data.toolCount')
|
||||||
|
tools=$(echo "$response" | jq -r '.data.tools | join(", ")')
|
||||||
|
author=$(echo "$response" | jq -r '.data.author')
|
||||||
|
|
||||||
|
echo "packageId=$packageId" >> $GITHUB_OUTPUT
|
||||||
|
echo "version=$version" >> $GITHUB_OUTPUT
|
||||||
|
echo "toolCount=$toolCount" >> $GITHUB_OUTPUT
|
||||||
|
echo "tools=$tools" >> $GITHUB_OUTPUT
|
||||||
|
echo "author=$author" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "status_emoji=❌" >> $GITHUB_OUTPUT
|
||||||
|
echo "status_color=15158332" >> $GITHUB_OUTPUT
|
||||||
|
echo "status_text=Failed" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
error=$(echo "$response" | jq -r '.error // "Unknown error"')
|
||||||
|
echo "error=$error" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Send Discord notification
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
if [ "${{ steps.sync.outputs.status_text }}" = "Success" ]; then
|
||||||
|
fields='[
|
||||||
|
{ "name": "📦 Package", "value": "${{ inputs.packageName }}", "inline": true },
|
||||||
|
{ "name": "🏷️ Version", "value": "${{ steps.sync.outputs.version }}", "inline": true },
|
||||||
|
{ "name": "👤 Author", "value": "${{ steps.sync.outputs.author }}", "inline": true },
|
||||||
|
{ "name": "🔧 Tools", "value": "${{ steps.sync.outputs.toolCount }}", "inline": true },
|
||||||
|
{ "name": "📋 Tool Names", "value": "${{ steps.sync.outputs.tools }}", "inline": false },
|
||||||
|
{ "name": "🔗 Run", "value": "[View Logs](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})", "inline": true }
|
||||||
|
]'
|
||||||
|
else
|
||||||
|
fields='[
|
||||||
|
{ "name": "📦 Package", "value": "${{ inputs.packageName }}", "inline": true },
|
||||||
|
{ "name": "❌ Error", "value": "${{ steps.sync.outputs.error }}", "inline": false },
|
||||||
|
{ "name": "🔗 Run", "value": "[View Logs](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})", "inline": true }
|
||||||
|
]'
|
||||||
|
fi
|
||||||
|
|
||||||
|
payload=$(jq -n \
|
||||||
|
--arg title "${{ steps.sync.outputs.status_emoji }} Package Sync: ${{ inputs.packageName }}" \
|
||||||
|
--argjson color ${{ steps.sync.outputs.status_color }} \
|
||||||
|
--argjson fields "$fields" \
|
||||||
|
--arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" \
|
||||||
|
'
|
||||||
|
{
|
||||||
|
embeds: [{
|
||||||
|
title: $title,
|
||||||
|
color: $color,
|
||||||
|
fields: $fields,
|
||||||
|
timestamp: $timestamp
|
||||||
|
}]
|
||||||
|
}')
|
||||||
|
|
||||||
|
curl -X POST "${{ secrets.DISCORD_WEBHOOK }}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$payload"
|
||||||
89
.github/workflows/tool-request.yml
vendored
Normal file
89
.github/workflows/tool-request.yml
vendored
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
name: Tool Request Pipeline
|
||||||
|
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types: [labeled]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
issue_number:
|
||||||
|
description: 'Issue number to process'
|
||||||
|
required: true
|
||||||
|
type: number
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
trigger-claude:
|
||||||
|
# Only run when 'tool-request' label is added or manual dispatch
|
||||||
|
if: github.event.label.name == 'tool-request' || github.event_name == 'workflow_dispatch'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Add working label
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const issueNumber = context.issue?.number || ${{ inputs.issue_number || 0 }};
|
||||||
|
await github.rest.issues.addLabels({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: issueNumber,
|
||||||
|
labels: ['claude-working']
|
||||||
|
});
|
||||||
|
|
||||||
|
- name: Comment to trigger Claude
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const issueNumber = context.issue?.number || ${{ inputs.issue_number || 0 }};
|
||||||
|
const { data: issue } = await github.rest.issues.get({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: issueNumber
|
||||||
|
});
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
'@claude Please implement this tool request.',
|
||||||
|
'',
|
||||||
|
'## Instructions',
|
||||||
|
'1. Read the pipeline specification at `.claude/pipelines/tool-request.md`',
|
||||||
|
'2. Follow all steps: analyze, design, implement, validate, test, publish',
|
||||||
|
'3. Update labels as you progress (remove `claude-working`, add `published` or `validation-failed`)',
|
||||||
|
'4. Post full changelog when complete',
|
||||||
|
'5. This issue will auto-close 24h after successful publish',
|
||||||
|
'',
|
||||||
|
'## Issue Context',
|
||||||
|
`- Issue #${issueNumber}`,
|
||||||
|
`- Author: @${issue.user.login}`,
|
||||||
|
`- Created: ${issue.created_at}`,
|
||||||
|
'',
|
||||||
|
'Begin implementation.'
|
||||||
|
];
|
||||||
|
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: issueNumber,
|
||||||
|
body: lines.join('\n')
|
||||||
|
});
|
||||||
|
|
||||||
|
# Auto-close published issues after 24 hours
|
||||||
|
auto-close:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event.label.name == 'published'
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
steps:
|
||||||
|
- name: Schedule auto-close
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
body: 'This issue will auto-close in 24 hours. Reply if you have feedback or issues with the published tool.'
|
||||||
|
});
|
||||||
|
|
||||||
|
# Separate workflow handles the actual auto-close via scheduled job
|
||||||
|
# See: .github/workflows/auto-close-published.yml
|
||||||
69
.github/workflows/update-docs.yml
vendored
Normal file
69
.github/workflows/update-docs.yml
vendored
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
name: Update Documentation
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- 'packages/**'
|
||||||
|
- 'apps/**'
|
||||||
|
- 'templates/**'
|
||||||
|
- '!**/*.md'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
update-docs:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Get changed files
|
||||||
|
id: changed
|
||||||
|
run: |
|
||||||
|
echo "files=$(git diff --name-only HEAD~1 HEAD | tr '\n' ' ')" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Run Claude Code
|
||||||
|
uses: anthropics/claude-code-action@beta
|
||||||
|
continue-on-error: true
|
||||||
|
with:
|
||||||
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
|
prompt: |
|
||||||
|
Analyze the recent code changes and update any relevant documentation.
|
||||||
|
|
||||||
|
Changed files: ${{ steps.changed.outputs.files }}
|
||||||
|
|
||||||
|
Tasks:
|
||||||
|
1. Read the changed files to understand what was modified
|
||||||
|
2. Check if any README files, doc pages, or code comments need updating
|
||||||
|
3. Update documentation to reflect the code changes
|
||||||
|
4. Keep docs concise and accurate
|
||||||
|
|
||||||
|
Focus on:
|
||||||
|
- API changes that affect usage examples
|
||||||
|
- New features that need documentation
|
||||||
|
- Changed behavior that affects existing docs
|
||||||
|
- Executor template documentation (templates/vercel-executor/README.md)
|
||||||
|
- Package READMEs in packages/
|
||||||
|
|
||||||
|
Only make changes if documentation is actually out of sync with code.
|
||||||
|
If no documentation updates are needed, do nothing.
|
||||||
|
|
||||||
|
- name: Create Pull Request
|
||||||
|
uses: peter-evans/create-pull-request@v5
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
commit-message: 'docs: auto-update documentation based on code changes'
|
||||||
|
title: 'docs: Auto-update documentation'
|
||||||
|
body: |
|
||||||
|
This PR was automatically generated by Claude Code to update documentation based on recent code changes.
|
||||||
|
|
||||||
|
Please review the changes before merging.
|
||||||
|
branch: auto-docs-update
|
||||||
|
delete-branch: true
|
||||||
52
.gitignore
vendored
52
.gitignore
vendored
|
|
@ -15,7 +15,12 @@ dist
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
|
||||||
|
# video files
|
||||||
|
*.mp4
|
||||||
|
*.webm
|
||||||
|
*.mov
|
||||||
|
*.avi
|
||||||
|
|
||||||
# debug
|
# debug
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
|
@ -23,18 +28,57 @@ yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
|
|
||||||
# local env files
|
# environment files - NEVER commit secrets
|
||||||
.env*.local
|
|
||||||
.env
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
.env.local
|
||||||
|
.env.development
|
||||||
|
.env.development.local
|
||||||
|
.env.test
|
||||||
|
.env.test.local
|
||||||
|
.env.production
|
||||||
|
.env.production.local
|
||||||
|
.env.staging
|
||||||
|
.env.vercel*
|
||||||
|
|
||||||
|
# secret files
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.p12
|
||||||
|
*.pfx
|
||||||
|
credentials.json
|
||||||
|
secrets.json
|
||||||
|
*_secret*
|
||||||
|
*_credentials*
|
||||||
|
|
||||||
# turbo
|
# turbo
|
||||||
.turbo
|
.turbo
|
||||||
|
|
||||||
|
# ai sdk devtools
|
||||||
|
.devtools
|
||||||
|
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
# ide
|
# ide
|
||||||
.idea
|
.idea
|
||||||
|
.agent/
|
||||||
|
.agents/
|
||||||
|
.continue/
|
||||||
|
.cursor/
|
||||||
|
.windsurf/
|
||||||
|
|
||||||
|
# temporary analysis docs
|
||||||
|
COMPREHENSIVE_ANALYSIS.md
|
||||||
|
PLAN.md
|
||||||
|
REGISTRY_TOOLS_ANALYSIS.md
|
||||||
|
USER_ACCOUNT_ANALYSIS_*.md
|
||||||
|
*.skill
|
||||||
|
|
||||||
|
# symlinked skill dirs (source is .agents/ which is gitignored)
|
||||||
|
/skills/
|
||||||
|
.claude/skills/skill-creator
|
||||||
|
|
||||||
# storybook
|
# storybook
|
||||||
storybook-static
|
storybook-static
|
||||||
|
|
@ -43,3 +87,5 @@ storybook-static
|
||||||
.changeset/*.md
|
.changeset/*.md
|
||||||
!.changeset/README.md
|
!.changeset/README.md
|
||||||
.vercel
|
.vercel
|
||||||
|
packages/tool-ideas/data/tools-export.json
|
||||||
|
.env*.local
|
||||||
|
|
|
||||||
49
.gitsecrets
Normal file
49
.gitsecrets
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
# Secret patterns for git-secrets
|
||||||
|
# Run `git secrets --add-provider -- cat .gitsecrets` to load these patterns
|
||||||
|
# Or manually add with `git secrets --add '<pattern>'`
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# TPMJS-specific patterns
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# TPMJS API keys (format: tpmjs_sk_<base64>)
|
||||||
|
tpmjs_sk_[A-Za-z0-9_-]+
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Database credentials
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Neon database passwords (format: npg_<alphanumeric>)
|
||||||
|
npg_[A-Za-z0-9]+
|
||||||
|
|
||||||
|
# PostgreSQL connection strings with embedded passwords
|
||||||
|
postgresql://[^:]+:[^@]+@.*neon
|
||||||
|
|
||||||
|
# Generic database URLs with passwords
|
||||||
|
DATABASE_URL=.*://[^:]+:[^@]+@
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Generic secret patterns
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Long hex strings (API keys, tokens) - 64 chars like CRON_SECRET
|
||||||
|
[a-f0-9]{64}
|
||||||
|
|
||||||
|
# JWT tokens (common format)
|
||||||
|
eyJ[A-Za-z0-9_-]*\.eyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]*
|
||||||
|
|
||||||
|
# Generic API key patterns
|
||||||
|
[Aa][Pp][Ii][-_]?[Kk][Ee][Yy].*['"][A-Za-z0-9_-]{20,}['"]
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Cloud provider patterns (via --register-aws)
|
||||||
|
# =============================================================================
|
||||||
|
# AWS patterns are automatically registered with `git secrets --register-aws`
|
||||||
|
# - AWS Access Key IDs: AKIA[0-9A-Z]{16}
|
||||||
|
# - AWS Secret Access Keys
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Allowed patterns (false positive exclusions)
|
||||||
|
# =============================================================================
|
||||||
|
# Add allowed patterns with: git secrets --add --allowed '<pattern>'
|
||||||
|
# Example: git secrets --add --allowed 'example\.com'
|
||||||
31
.ignore
Normal file
31
.ignore
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
# Ignore patterns for OpenCode
|
||||||
|
# These directories are excluded from search to reduce noise and improve relevance
|
||||||
|
|
||||||
|
# Build outputs and caches
|
||||||
|
**/dist/**
|
||||||
|
**/.next/**
|
||||||
|
**/.turbo/**
|
||||||
|
**/coverage/**
|
||||||
|
**/.cache/**
|
||||||
|
**/node_modules/**
|
||||||
|
|
||||||
|
# Generated files
|
||||||
|
**/.DS_Store/**
|
||||||
|
**/*.log
|
||||||
|
**/tmp/**
|
||||||
|
|
||||||
|
# Lock files (unless explicitly requested)
|
||||||
|
**/pnpm-lock.yaml
|
||||||
|
**/package-lock.json
|
||||||
|
**/yarn.lock
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
**/.env*
|
||||||
|
**/.envrc
|
||||||
|
|
||||||
|
# IDE files
|
||||||
|
**/.vscode/**
|
||||||
|
**/.idea/**
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
**/Thumbs.db
|
||||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
|
|
@ -2,8 +2,7 @@
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
"editor.defaultFormatter": "biomejs.biome",
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
"quickfix.biome": "explicit",
|
"source.fixAll.biome": "explicit"
|
||||||
"source.organizeImports.biome": "explicit"
|
|
||||||
},
|
},
|
||||||
"typescript.tsdk": "node_modules/typescript/lib",
|
"typescript.tsdk": "node_modules/typescript/lib",
|
||||||
"typescript.enablePromptUseWorkspaceTsdk": true
|
"typescript.enablePromptUseWorkspaceTsdk": true
|
||||||
|
|
|
||||||
210
AGENTS.md
Normal file
210
AGENTS.md
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
# TPMJS OpenCode Configuration
|
||||||
|
|
||||||
|
This file contains project-specific rules and guidance for OpenCode agents working in the TPMJS monorepo.
|
||||||
|
|
||||||
|
## Repository Overview
|
||||||
|
|
||||||
|
TPMJS is a Turborepo monorepo for AI tool discovery and registry. Key characteristics:
|
||||||
|
- **Package Manager**: pnpm with workspace configuration
|
||||||
|
- **Build System**: Turborepo for task orchestration
|
||||||
|
- **Main App**: Next.js 16 App Router (`apps/web`)
|
||||||
|
- **Component Library**: `.ts`-only React components (`packages/ui`)
|
||||||
|
- **Database**: Prisma with PostgreSQL (`packages/db`)
|
||||||
|
- **Tool Registry**: npm package discovery and metadata sync
|
||||||
|
|
||||||
|
## Core Commands (Always Use These)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development
|
||||||
|
pnpm dev # Start all dev servers
|
||||||
|
pnpm --filter=@tpmjs/web dev # Start web app only
|
||||||
|
|
||||||
|
# Building (Respects Dependencies)
|
||||||
|
pnpm build # Build all packages
|
||||||
|
pnpm --filter=@tpmjs/ui build # Build specific package
|
||||||
|
pnpm --filter=@tpmjs/web... build # Build web + all dependencies
|
||||||
|
|
||||||
|
# Testing & Quality
|
||||||
|
pnpm test # Run all tests
|
||||||
|
pnpm lint # Lint all packages
|
||||||
|
pnpm format # Format with Biome
|
||||||
|
pnpm type-check # TypeScript checking
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture Rules (Critical)
|
||||||
|
|
||||||
|
### Module Boundaries
|
||||||
|
- **Apps** (`apps/*`) can only import from published packages (`@tpmjs/*`)
|
||||||
|
- **Packages** (`packages/*`) cannot import from apps
|
||||||
|
- **UI Package** (`packages/ui`) cannot import from utils (stays dependency-free)
|
||||||
|
- **No barrel exports** - always import directly: `@tpmjs/ui/Button/Button`
|
||||||
|
|
||||||
|
### Component Usage
|
||||||
|
**ALWAYS use `@tpmjs/ui` components instead of raw HTML:**
|
||||||
|
```typescript
|
||||||
|
// Good
|
||||||
|
import { Button } from '@tpmjs/ui/Button/Button';
|
||||||
|
import { Input } from '@tpmjs/ui/Input/Input';
|
||||||
|
|
||||||
|
// Bad
|
||||||
|
<button onClick={handleClick}>Submit</button>
|
||||||
|
<input value={value} onChange={onChange} />
|
||||||
|
```
|
||||||
|
|
||||||
|
### TypeScript Configuration
|
||||||
|
- All packages extend from `@tpmjs/tsconfig`
|
||||||
|
- Strict mode enabled
|
||||||
|
- Composite projects for proper dependency resolution
|
||||||
|
|
||||||
|
## Package Structure
|
||||||
|
|
||||||
|
### Published Packages (@tpmjs scope)
|
||||||
|
- `@tpmjs/ui` - React component library (.ts-only, createElement)
|
||||||
|
- `@tpmjs/utils` - Utility functions (cn, format, etc.)
|
||||||
|
- `@tpmjs/types` - Shared TypeScript types and Zod schemas
|
||||||
|
- `@tpmjs/env` - Environment variable validation with Zod
|
||||||
|
|
||||||
|
### Internal Tooling (Private)
|
||||||
|
- `@tpmjs/config` - Shared configurations (Biome, ESLint, Tailwind, TypeScript)
|
||||||
|
- `@tpmjs/test` - Vitest shared configuration
|
||||||
|
- `@tpmjs/mocks` - MSW mock server for testing
|
||||||
|
- `@tpmjs/storybook` - Component documentation
|
||||||
|
|
||||||
|
### Applications
|
||||||
|
- `@tpmjs/web` - Next.js 16 App Router (main website)
|
||||||
|
- `@tpmjs/playground` - Tool testing playground
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
### Before Making Changes
|
||||||
|
1. Run `pnpm type-check` to ensure clean state
|
||||||
|
2. Check existing patterns in similar files
|
||||||
|
3. Use `@tpmjs/ui` components for any UI changes
|
||||||
|
|
||||||
|
### After Making Changes
|
||||||
|
1. `pnpm lint` - Check linting
|
||||||
|
2. `pnpm type-check` - Verify TypeScript
|
||||||
|
3. `pnpm test` - Run tests if applicable
|
||||||
|
4. `pnpm format` - Auto-format with Biome
|
||||||
|
|
||||||
|
### Database Changes
|
||||||
|
If modifying Prisma schema:
|
||||||
|
```bash
|
||||||
|
pnpm --filter=@tpmjs/db db:generate # Regenerate client
|
||||||
|
pnpm --filter=@tpmjs/db db:push # Apply changes (dev)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tool Development
|
||||||
|
|
||||||
|
### Tool Package Structure
|
||||||
|
Tools live in `packages/tools/*` with this pattern:
|
||||||
|
```
|
||||||
|
packages/tools/tool-name/
|
||||||
|
├── package.json
|
||||||
|
├── tsconfig.json
|
||||||
|
├── src/
|
||||||
|
│ ├── index.ts # Main export
|
||||||
|
│ ├── tool.ts # Tool definition
|
||||||
|
│ └── implementation.ts # Actual logic
|
||||||
|
├── README.md
|
||||||
|
└── examples/
|
||||||
|
└── basic.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tool Metadata
|
||||||
|
Tools must have proper `tpmjs` field in package.json:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tpmjs": {
|
||||||
|
"category": "text-analysis",
|
||||||
|
"tier": "rich",
|
||||||
|
"description": "Tool description"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quality Standards
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
- No `any` types or `@ts-ignore`
|
||||||
|
- Strict TypeScript compliance
|
||||||
|
- Proper error handling with try/catch
|
||||||
|
- Meaningful variable names
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- Unit tests for utilities
|
||||||
|
- Integration tests for API routes
|
||||||
|
- Component tests for UI changes
|
||||||
|
- Use Vitest + Testing Library
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- README for all packages
|
||||||
|
- JSDoc for public APIs
|
||||||
|
- Examples for tool usage
|
||||||
|
- Type definitions for all public interfaces
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### API Routes
|
||||||
|
```typescript
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@tpmjs/db';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
export const maxDuration = 60;
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
// Implementation
|
||||||
|
return NextResponse.json({ success: true, data });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: error.message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component Pattern
|
||||||
|
```typescript
|
||||||
|
import { createElement } from 'react';
|
||||||
|
import { cn } from '@tpmjs/utils';
|
||||||
|
|
||||||
|
interface ButtonProps {
|
||||||
|
onClick?: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Button({ onClick, children, className }: ButtonProps) {
|
||||||
|
return createElement('button', {
|
||||||
|
onClick,
|
||||||
|
className: cn('default-styles', className),
|
||||||
|
}, children);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## What NOT to Do
|
||||||
|
|
||||||
|
- **Never edit lockfiles** unless explicitly requested
|
||||||
|
- **Never use barrel exports** (`index.ts` files)
|
||||||
|
- **Never suppress TypeScript errors** with `as any` or `@ts-ignore`
|
||||||
|
- **Never use raw HTML elements** when `@tpmjs/ui` components exist
|
||||||
|
- **Never import from apps** in packages
|
||||||
|
- **Never commit without running** `pnpm lint` and `pnpm type-check`
|
||||||
|
|
||||||
|
## Deployment & CI
|
||||||
|
|
||||||
|
- Vercel deployment requires all CI checks to pass
|
||||||
|
- Pre-commit hooks run `format`, `lint`, and `type-check`
|
||||||
|
- Use `vercel inspect` to debug deployments
|
||||||
|
- Check `/api/health` to verify production deployments
|
||||||
|
|
||||||
|
## Getting Help
|
||||||
|
|
||||||
|
- Check existing implementations in similar packages
|
||||||
|
- Use `pnpm --filter=<package> dev` for package-specific development
|
||||||
|
- Refer to `CLAUDE.md` for detailed architectural decisions
|
||||||
|
- Look at `packages/tools/*` for tool development examples
|
||||||
844
ARCHITECTURE.md
Normal file
844
ARCHITECTURE.md
Normal file
|
|
@ -0,0 +1,844 @@
|
||||||
|
# TPMJS Architecture Documentation
|
||||||
|
|
||||||
|
A comprehensive guide to the TPMJS platform architecture - from tool discovery to sandboxed execution, collections, agents, and custom executors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Platform Overview](#1-platform-overview)
|
||||||
|
2. [Monorepo Structure](#2-monorepo-structure)
|
||||||
|
3. [Database Layer](#3-database-layer)
|
||||||
|
4. [Tool Execution System](#4-tool-execution-system)
|
||||||
|
5. [MCP Protocol Implementation](#5-mcp-protocol-implementation)
|
||||||
|
6. [Agent System](#6-agent-system)
|
||||||
|
7. [Collection System](#7-collection-system)
|
||||||
|
8. [NPM Sync System](#8-npm-sync-system)
|
||||||
|
9. [API Layer](#9-api-layer)
|
||||||
|
10. [SDK Packages](#10-sdk-packages)
|
||||||
|
11. [UI & Frontend](#11-ui--frontend)
|
||||||
|
12. [Security & Authentication](#12-security--authentication)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Platform Overview
|
||||||
|
|
||||||
|
TPMJS is a **tool registry platform** that automatically discovers, validates, and executes npm packages as AI agent tools. The platform supports multiple AI providers (OpenAI, Anthropic, Google, Groq, Mistral) and exposes tools via MCP (Model Context Protocol) for use with Claude Desktop, Cursor, and other MCP clients.
|
||||||
|
|
||||||
|
### High-Level Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ USER PRODUCTS │
|
||||||
|
├─────────────────────┬─────────────────────┬─────────────────────────────────┤
|
||||||
|
│ tpmjs.com │ SDK Packages │ MCP Protocol │
|
||||||
|
│ ───────────────── │ ───────────────── │ ───────────────────────────── │
|
||||||
|
│ • Dashboard │ • @tpmjs/types │ • Claude Desktop │
|
||||||
|
│ • Tool Browser │ • registry-search │ • Cursor │
|
||||||
|
│ • Collection Editor│ • registry-execute │ • Claude Code │
|
||||||
|
│ • Agent Builder │ │ • Any MCP Client │
|
||||||
|
│ • Playground │ │ │
|
||||||
|
└─────────────────────┴─────────────────────┴─────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ API LAYER (Next.js 16) │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ /api/tools /api/agents /api/collections /api/mcp/* /api/sync/* │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ INFRASTRUCTURE │
|
||||||
|
├───────────────────────┬───────────────────────┬─────────────────────────────┤
|
||||||
|
│ Database │ Execution │ External │
|
||||||
|
│ ─────────────────── │ ─────────────────── │ ───────────────────────── │
|
||||||
|
│ • PostgreSQL (Neon) │ • Vercel Sandbox │ • npm Registry │
|
||||||
|
│ • Prisma ORM │ • Custom Executors │ • esm.sh CDN │
|
||||||
|
│ │ │ • GitHub API │
|
||||||
|
└───────────────────────┴───────────────────────┴─────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Concepts
|
||||||
|
|
||||||
|
| Concept | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| **Tool** | A single executable function from an npm package |
|
||||||
|
| **Package** | An npm package containing one or more tools |
|
||||||
|
| **Collection** | A user-curated bundle of tools exposed via MCP |
|
||||||
|
| **Agent** | An AI assistant with access to tools and collections |
|
||||||
|
| **Executor** | A sandboxed environment for running tool code |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Monorepo Structure
|
||||||
|
|
||||||
|
TPMJS uses **Turborepo** with **pnpm** workspaces. The codebase is organized into packages and applications.
|
||||||
|
|
||||||
|
### Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
tpmjs/
|
||||||
|
├── apps/
|
||||||
|
│ ├── web/ # Main Next.js 16 application
|
||||||
|
│ ├── playground/ # Interactive tool testing
|
||||||
|
│ ├── tutorial/ # Tutorial application
|
||||||
|
│ └── railway-executor/ # Deno executor service
|
||||||
|
│
|
||||||
|
├── packages/
|
||||||
|
│ ├── ui/ # React component library (@tpmjs/ui)
|
||||||
|
│ ├── types/ # TypeScript types & Zod schemas (@tpmjs/types)
|
||||||
|
│ ├── utils/ # Utility functions (@tpmjs/utils)
|
||||||
|
│ ├── env/ # Environment validation (@tpmjs/env)
|
||||||
|
│ ├── db/ # Prisma database client (@tpmjs/db)
|
||||||
|
│ ├── npm-client/ # NPM Registry API client
|
||||||
|
│ ├── package-executor/ # Tool execution client
|
||||||
|
│ ├── config/ # Shared configs (Biome, ESLint, Tailwind, TS)
|
||||||
|
│ └── tools/ # 150+ official TPMJS tools
|
||||||
|
│ └── official/ # @tpmjs/tools-* packages
|
||||||
|
│
|
||||||
|
├── turbo.json # Turborepo task configuration
|
||||||
|
├── pnpm-workspace.yaml # Workspace definitions
|
||||||
|
└── vercel.json # Deployment & cron configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
### Published Packages (npm @tpmjs scope)
|
||||||
|
|
||||||
|
| Package | Version | Purpose |
|
||||||
|
|---------|---------|---------|
|
||||||
|
| `@tpmjs/types` | 0.2.0 | TypeScript types and Zod validation schemas |
|
||||||
|
| `@tpmjs/utils` | 0.1.1 | Utility functions (cn, format helpers) |
|
||||||
|
| `@tpmjs/ui` | 0.1.3 | React component library (30+ components) |
|
||||||
|
| `@tpmjs/env` | 0.1.1 | Environment variable validation |
|
||||||
|
|
||||||
|
### Internal Packages
|
||||||
|
|
||||||
|
| Package | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| `@tpmjs/db` | Prisma client and database schema |
|
||||||
|
| `@tpmjs/npm-client` | NPM Registry API client for syncing |
|
||||||
|
| `@tpmjs/package-executor` | Remote executor HTTP client |
|
||||||
|
| `@tpmjs/config` | Shared Biome, ESLint, Tailwind, TypeScript configs |
|
||||||
|
|
||||||
|
### Key Architecture Principles
|
||||||
|
|
||||||
|
1. **No Barrel Exports**: Components imported directly (`@tpmjs/ui/Button/Button`)
|
||||||
|
2. **Strict Module Boundaries**: Apps import from packages, not vice versa
|
||||||
|
3. **TypeScript Everywhere**: Strict mode with composite projects
|
||||||
|
4. **Shared Configurations**: Centralized in `packages/config/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Database Layer
|
||||||
|
|
||||||
|
The database layer uses **Prisma ORM** with **PostgreSQL** (Neon) as the data store.
|
||||||
|
|
||||||
|
### Core Models
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ TOOL REGISTRY │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Package (1) ──────────────────────► (N) Tool │
|
||||||
|
│ ├── npmPackageName (unique) ├── id (PK) │
|
||||||
|
│ ├── npmVersion ├── name │
|
||||||
|
│ ├── category ├── description │
|
||||||
|
│ ├── tier (minimal|rich) ├── inputSchema (JSON) │
|
||||||
|
│ ├── npmDownloadsLastMonth ├── qualityScore │
|
||||||
|
│ └── githubStars ├── importHealth │
|
||||||
|
│ └── executionHealth │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ USER & SOCIAL │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ User (1) ──────► (N) Agent ──────► (N) Conversation ──────► (N) Message │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ └──────► (N) AgentTool │
|
||||||
|
│ │ └──────► (N) AgentCollection │
|
||||||
|
│ │ │
|
||||||
|
│ └──────► (N) Collection ──────► (N) CollectionTool │
|
||||||
|
│ │ │
|
||||||
|
│ └──────► (N) ToolLike, CollectionLike, AgentLike │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ SYNC & MONITORING │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ SyncCheckpoint SyncLog HealthCheck │
|
||||||
|
│ ├── source (unique) ├── source ├── toolId │
|
||||||
|
│ └── checkpoint (JSON) ├── status ├── importStatus │
|
||||||
|
│ ├── processed ├── executionStatus │
|
||||||
|
│ └── errors └── checkType │
|
||||||
|
│ │
|
||||||
|
│ Simulation TokenUsage StatsSnapshot │
|
||||||
|
│ ├── toolId ├── simulationId ├── date (unique) │
|
||||||
|
│ ├── status ├── inputTokens ├── totalTools │
|
||||||
|
│ └── output └── totalTokens └── healthStats │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Query Patterns
|
||||||
|
|
||||||
|
**1. Pagination without COUNT (limit+1 technique):**
|
||||||
|
```typescript
|
||||||
|
const tools = await prisma.tool.findMany({
|
||||||
|
take: limit + 1, // Fetch one extra to check hasMore
|
||||||
|
skip: offset,
|
||||||
|
});
|
||||||
|
const hasMore = tools.length > limit;
|
||||||
|
const actualTools = hasMore ? tools.slice(0, limit) : tools;
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Atomic Like/Unlike with Transactions:**
|
||||||
|
```typescript
|
||||||
|
const [like, updatedTool] = await prisma.$transaction([
|
||||||
|
prisma.toolLike.create({ data: { userId, toolId } }),
|
||||||
|
prisma.tool.update({
|
||||||
|
where: { id: toolId },
|
||||||
|
data: { likeCount: { increment: 1 } }
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Upsert for Idempotent Sync Operations:**
|
||||||
|
```typescript
|
||||||
|
await prisma.package.upsert({
|
||||||
|
where: { npmPackageName: pkg.name },
|
||||||
|
create: { /* ... */ },
|
||||||
|
update: { /* ... */ }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Tool Execution System
|
||||||
|
|
||||||
|
The execution system provides sandboxed environments for safely running npm package tools.
|
||||||
|
|
||||||
|
### Execution Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
┌───────────────┐ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐
|
||||||
|
│ 1. REQUEST │────►│ 2. RESOLVE │────►│ 3. EXECUTE │────►│ 4. RESPONSE │
|
||||||
|
├───────────────┤ ├───────────────┤ ├───────────────┤ ├───────────────┤
|
||||||
|
│ SDK: │ │ Lookup tool │ │ npm install │ │ output: any │
|
||||||
|
│ registryExec │ │ by ID │ │ pkg │ │ │
|
||||||
|
│ │ │ │ │ │ │ executionTime │
|
||||||
|
│ MCP: │ │ Resolve │ │ tool.execute │ │ Ms │
|
||||||
|
│ tools/call │ │ executor │ │ (params) │ │ │
|
||||||
|
│ │ │ config │ │ │ │ success: │
|
||||||
|
│ Agent: │ │ │ │ Return │ │ boolean │
|
||||||
|
│ tool_call │ │ Build import │ │ result │ │ │
|
||||||
|
│ │ │ URL │ │ │ │ │
|
||||||
|
└───────────────┘ └───────────────┘ └───────────────┘ └───────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Executor Types
|
||||||
|
|
||||||
|
**1. Default Executor (Vercel Sandbox)**
|
||||||
|
- Pre-configured sandbox environment
|
||||||
|
- Node.js 22, 2 vCPUs, 2 minute timeout
|
||||||
|
- Network isolated, per-request env injection
|
||||||
|
- Automatic npm install
|
||||||
|
|
||||||
|
**2. Custom URL Executor**
|
||||||
|
- User-deployed executor service
|
||||||
|
- Deploy to Vercel, Railway, AWS Lambda, or self-host
|
||||||
|
- Custom dependencies pre-installed
|
||||||
|
- Your own API keys built-in
|
||||||
|
|
||||||
|
### Executor Config Cascade
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ System Default │ ◄─── Vercel Sandbox
|
||||||
|
│ (lowest priority) │
|
||||||
|
└─────────┬───────────┘
|
||||||
|
│ overridden by
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ Collection Config │ ◄─── executorConfig on Collection
|
||||||
|
│ │
|
||||||
|
└─────────┬───────────┘
|
||||||
|
│ overridden by
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ Agent Config │ ◄─── executorConfig on Agent
|
||||||
|
│ (highest priority) │
|
||||||
|
└─────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Executor API Contract
|
||||||
|
|
||||||
|
All executors must implement:
|
||||||
|
|
||||||
|
**POST /execute-tool**
|
||||||
|
```typescript
|
||||||
|
interface ExecuteToolRequest {
|
||||||
|
packageName: string; // "@tpmjs/hello"
|
||||||
|
name: string; // "helloWorldTool"
|
||||||
|
version?: string; // "1.0.0" or "latest"
|
||||||
|
params: Record<string, unknown>;
|
||||||
|
env?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExecuteToolResponse {
|
||||||
|
success: boolean;
|
||||||
|
output?: unknown;
|
||||||
|
error?: string;
|
||||||
|
executionTimeMs: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**GET /health**
|
||||||
|
```typescript
|
||||||
|
interface HealthResponse {
|
||||||
|
status: 'ok' | 'degraded' | 'error';
|
||||||
|
version?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. MCP Protocol Implementation
|
||||||
|
|
||||||
|
TPMJS implements the **Model Context Protocol (MCP)** to expose collections as tool servers for AI clients.
|
||||||
|
|
||||||
|
### MCP Endpoints
|
||||||
|
|
||||||
|
| Transport | Endpoint | Purpose |
|
||||||
|
|-----------|----------|---------|
|
||||||
|
| HTTP | `/api/mcp/{username}/{slug}/http` | Request-response |
|
||||||
|
| SSE | `/api/mcp/{username}/{slug}/sse` | Streaming |
|
||||||
|
|
||||||
|
### JSON-RPC Methods
|
||||||
|
|
||||||
|
**initialize** - Returns server capabilities
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"protocolVersion": "2024-11-05",
|
||||||
|
"serverInfo": { "name": "TPMJS: My Collection", "version": "1.0.0" },
|
||||||
|
"capabilities": { "tools": {} }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**tools/list** - Returns available tools in collection
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": [{
|
||||||
|
"name": "tpmjs-hello--helloWorldTool",
|
||||||
|
"description": "A simple hello world tool",
|
||||||
|
"inputSchema": { "type": "object", "properties": { ... } }
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**tools/call** - Executes a tool
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"content": [{ "type": "text", "text": "Hello World!" }]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tool Name Format
|
||||||
|
|
||||||
|
MCP tool names are sanitized from npm package names:
|
||||||
|
|
||||||
|
```
|
||||||
|
@tpmjs/hello + helloWorldTool → tpmjs-hello--helloWorldTool
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Agent System
|
||||||
|
|
||||||
|
Agents are AI-powered assistants with multi-turn conversations and tool access.
|
||||||
|
|
||||||
|
### Agent Configuration
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Agent {
|
||||||
|
// Identity
|
||||||
|
id: string;
|
||||||
|
uid: string; // URL-friendly ID
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
// Model Configuration
|
||||||
|
provider: 'OPENAI' | 'ANTHROPIC' | 'GOOGLE' | 'GROQ' | 'MISTRAL';
|
||||||
|
modelId: string; // e.g., "gpt-4o", "claude-3-5-sonnet"
|
||||||
|
systemPrompt?: string;
|
||||||
|
temperature: number; // 0-2, default 0.7
|
||||||
|
|
||||||
|
// Behavior
|
||||||
|
maxToolCallsPerTurn: number; // 1-100, default 20
|
||||||
|
maxMessagesInContext: number; // 1-100, default 10
|
||||||
|
|
||||||
|
// Visibility
|
||||||
|
isPublic: boolean;
|
||||||
|
|
||||||
|
// Executor Override
|
||||||
|
executorType?: 'default' | 'custom_url';
|
||||||
|
executorConfig?: { url: string; apiKey?: string };
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
collections: AgentCollection[];
|
||||||
|
tools: AgentTool[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Conversation Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User Message
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌────────────────────────────────────┐
|
||||||
|
│ Save MESSAGE (role=USER) │
|
||||||
|
└────────────────┬───────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌────────────────────────────────────┐
|
||||||
|
│ Fetch message history │
|
||||||
|
│ (maxMessagesInContext) │
|
||||||
|
└────────────────┬───────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌────────────────────────────────────┐
|
||||||
|
│ Build AI SDK messages + tools │
|
||||||
|
│ • System prompt │
|
||||||
|
│ • Conversation history │
|
||||||
|
│ • Tool definitions │
|
||||||
|
└────────────────┬───────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌────────────────────────────────────┐
|
||||||
|
│ streamText() with tool use │
|
||||||
|
│ • SSE chunks to client │
|
||||||
|
│ • Tool calls executed │
|
||||||
|
│ • Results fed back to model │
|
||||||
|
└────────────────┬───────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌────────────────────────────────────┐
|
||||||
|
│ Save MESSAGE (role=ASSISTANT) │
|
||||||
|
│ Save MESSAGE (role=TOOL) for each │
|
||||||
|
│ tool call result │
|
||||||
|
└────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### SSE Event Types
|
||||||
|
|
||||||
|
| Event | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `chunk` | Text token from AI |
|
||||||
|
| `tool_call` | AI decided to call a tool |
|
||||||
|
| `tool_result` | Tool execution completed |
|
||||||
|
| `tokens` | Token usage statistics |
|
||||||
|
| `complete` | Conversation finished |
|
||||||
|
| `error` | Error occurred |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Collection System
|
||||||
|
|
||||||
|
Collections are user-curated bundles of tools that can be shared and exposed via MCP.
|
||||||
|
|
||||||
|
### Collection Structure
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Collection {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string; // URL-friendly, unique per user
|
||||||
|
description?: string;
|
||||||
|
isPublic: boolean;
|
||||||
|
|
||||||
|
// Executor Override (applies to all tools)
|
||||||
|
executorType?: 'default' | 'custom_url';
|
||||||
|
executorConfig?: { url: string; apiKey?: string };
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
tools: CollectionTool[]; // Junction table with position, notes
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CollectionTool {
|
||||||
|
toolId: string;
|
||||||
|
position: number; // User-defined ordering
|
||||||
|
note?: string; // User notes about the tool
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Collection Limits
|
||||||
|
|
||||||
|
| Limit | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| Max collections per user | 50 |
|
||||||
|
| Max tools per collection | 100 |
|
||||||
|
| Max name length | 100 chars |
|
||||||
|
| Max description length | 500 chars |
|
||||||
|
|
||||||
|
### MCP Access URLs
|
||||||
|
|
||||||
|
Public collections can be accessed via MCP:
|
||||||
|
|
||||||
|
```
|
||||||
|
HTTP: https://tpmjs.com/api/mcp/{username}/{slug}/http
|
||||||
|
SSE: https://tpmjs.com/api/mcp/{username}/{slug}/sse
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. NPM Sync System
|
||||||
|
|
||||||
|
TPMJS automatically discovers tools from npm using multiple sync strategies.
|
||||||
|
|
||||||
|
### Sync Jobs
|
||||||
|
|
||||||
|
| Job | Schedule | Purpose |
|
||||||
|
|-----|----------|---------|
|
||||||
|
| Changes Feed | Every 2 min | Monitor npm real-time updates |
|
||||||
|
| Keyword Search | Every 15 min | Search for `tpmjs` keyword |
|
||||||
|
| Metrics | Every hour | Update downloads & quality scores |
|
||||||
|
| Health Check | Daily | Verify tool import/execution |
|
||||||
|
| Stats Snapshot | Daily | Capture historical statistics |
|
||||||
|
|
||||||
|
### Discovery Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
npm Registry
|
||||||
|
│
|
||||||
|
├──► Changes Feed (/api/sync/changes)
|
||||||
|
│ • Polls /_changes endpoint
|
||||||
|
│ • 30 packages per run
|
||||||
|
│ • Checkpoint-based (lastSeq)
|
||||||
|
│
|
||||||
|
└──► Keyword Search (/api/sync/keyword)
|
||||||
|
• Searches for keyword:tpmjs
|
||||||
|
• 250 packages per run
|
||||||
|
• Backup discovery
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌────────────────────────────────────┐
|
||||||
|
│ Validate tpmjs field │
|
||||||
|
│ • Multi-tool format (new) │
|
||||||
|
│ • Legacy rich format │
|
||||||
|
│ • Legacy minimal format │
|
||||||
|
└────────────────┬───────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌────────────────────────────────────┐
|
||||||
|
│ Auto-discover tools │
|
||||||
|
│ • If tools[] missing/empty │
|
||||||
|
│ • Call executor listToolExports │
|
||||||
|
│ • Extract JSON schemas │
|
||||||
|
└────────────────┬───────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌────────────────────────────────────┐
|
||||||
|
│ Update database │
|
||||||
|
│ • Upsert Package record │
|
||||||
|
│ • Upsert Tool records │
|
||||||
|
│ • Trigger health checks │
|
||||||
|
└────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Quality Score Calculation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
qualityScore = tierScore + downloadsScore + starsScore + richnessScore
|
||||||
|
|
||||||
|
// tierScore: 0.6 (rich) or 0.4 (minimal)
|
||||||
|
// downloadsScore: log10(downloads) / 15, max 0.2
|
||||||
|
// starsScore: log10(stars) / 10, max 0.1
|
||||||
|
// richnessScore: +0.04 (params) +0.03 (returns) +0.03 (aiAgent)
|
||||||
|
|
||||||
|
// Range: 0.00 - 1.00
|
||||||
|
```
|
||||||
|
|
||||||
|
### tpmjs Field Specification
|
||||||
|
|
||||||
|
**Multi-Tool Format (Recommended):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tpmjs": {
|
||||||
|
"category": "utilities",
|
||||||
|
"tools": [
|
||||||
|
{
|
||||||
|
"name": "helloWorld",
|
||||||
|
"description": "Greets a user by name"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "goodbye",
|
||||||
|
"description": "Says goodbye to a user"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"frameworks": ["vercel-ai"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Valid Categories:**
|
||||||
|
- Core: `research`, `web`, `data`, `documentation`, `engineering`, `security`, `statistics`, `ops`, `agent`, `utilities`, `html`, `compliance`
|
||||||
|
- Legacy: `web-scraping`, `data-processing`, `file-operations`, `communication`, `database`, `api-integration`, `image-processing`, `text-analysis`, `automation`, `ai-ml`, `monitoring`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. API Layer
|
||||||
|
|
||||||
|
The API is built on Next.js 16 App Router with standardized response formats.
|
||||||
|
|
||||||
|
### Response Format
|
||||||
|
|
||||||
|
**Success:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
data: T,
|
||||||
|
meta: {
|
||||||
|
version: "1.0.0",
|
||||||
|
timestamp: "2025-01-11T...",
|
||||||
|
requestId: "uuid"
|
||||||
|
},
|
||||||
|
pagination?: {
|
||||||
|
limit: number,
|
||||||
|
offset: number,
|
||||||
|
count: number,
|
||||||
|
hasMore: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: "VALIDATION_ERROR" | "NOT_FOUND" | "UNAUTHORIZED" | ...,
|
||||||
|
message: "Human-readable message",
|
||||||
|
details?: { ... }
|
||||||
|
},
|
||||||
|
meta: { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Endpoints
|
||||||
|
|
||||||
|
| Category | Endpoint | Purpose |
|
||||||
|
|----------|----------|---------|
|
||||||
|
| **Tools** | `GET /api/tools` | List/search tools |
|
||||||
|
| | `POST /api/tools/execute/[...slug]` | Execute tool (SSE) |
|
||||||
|
| **Agents** | `GET /api/agents` | List user agents |
|
||||||
|
| | `POST /api/{username}/agents/{uid}/conversation/{convId}` | Chat with agent (SSE) |
|
||||||
|
| **Collections** | `GET /api/collections` | List user collections |
|
||||||
|
| | `POST /api/collections/[id]/tools` | Add tool to collection |
|
||||||
|
| **MCP** | `POST /api/mcp/{username}/{slug}/{transport}` | MCP protocol |
|
||||||
|
| **Sync** | `POST /api/sync/changes` | Cron: npm changes |
|
||||||
|
| **Stats** | `GET /api/stats` | Registry statistics |
|
||||||
|
|
||||||
|
### Rate Limiting
|
||||||
|
|
||||||
|
| Endpoint Type | Limit | Window |
|
||||||
|
|---------------|-------|--------|
|
||||||
|
| Default | 100 requests | 1 minute |
|
||||||
|
| Strict | 20 requests | 1 minute |
|
||||||
|
| Tool Execute | 10 requests | 1 hour |
|
||||||
|
| Conversation | 30 requests | 1 minute |
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
- **Library:** `better-auth` with Prisma adapter
|
||||||
|
- **Session:** 7-day expiry, cookie-based
|
||||||
|
- **Email:** Verification required for login
|
||||||
|
- **Protected Routes:** Check `auth.api.getSession()`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. SDK Packages
|
||||||
|
|
||||||
|
### @tpmjs/types
|
||||||
|
|
||||||
|
Core TypeScript types and Zod validation schemas.
|
||||||
|
|
||||||
|
**Exports:**
|
||||||
|
- `./tool` - Tool and ToolParameter schemas
|
||||||
|
- `./registry` - Search result schemas
|
||||||
|
- `./tpmjs` - tpmjs field validation (validateTpmjsField)
|
||||||
|
- `./agent` - Agent configuration schemas
|
||||||
|
- `./collection` - Collection schemas
|
||||||
|
- `./user` - User profile schemas
|
||||||
|
- `./executor` - Executor request/response types
|
||||||
|
|
||||||
|
### @tpmjs/npm-client (Internal)
|
||||||
|
|
||||||
|
NPM Registry API client for sync operations.
|
||||||
|
|
||||||
|
**Functions:**
|
||||||
|
- `fetchChanges()` - Poll changes feed
|
||||||
|
- `searchByKeyword()` - Search packages
|
||||||
|
- `fetchLatestPackageWithMetadata()` - Get package info
|
||||||
|
- `fetchDownloadStats()` - Get npm downloads
|
||||||
|
- `fetchGitHubStars()` - Get GitHub stars
|
||||||
|
|
||||||
|
### @tpmjs/package-executor (Internal)
|
||||||
|
|
||||||
|
Remote executor client for tool execution.
|
||||||
|
|
||||||
|
**Functions:**
|
||||||
|
- `executePackage(packageName, functionName, params)` - Execute tool
|
||||||
|
- `clearCache()` - Clear executor cache
|
||||||
|
- `checkHealth()` - Check executor health
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. UI & Frontend
|
||||||
|
|
||||||
|
### Component Library (@tpmjs/ui)
|
||||||
|
|
||||||
|
30+ React components with no-barrel-exports architecture.
|
||||||
|
|
||||||
|
**Categories:**
|
||||||
|
- **Form:** Button, Input, Select, Checkbox, Radio, Switch, Textarea, Slider
|
||||||
|
- **Layout:** Card, Container, Section, GridContainer, Header
|
||||||
|
- **Display:** Badge, ProgressBar, Spinner, Icon, CodeBlock, Table
|
||||||
|
- **Advanced:** Tabs, AnimatedCounter, StatCard, ActivityStream, FlowDiagram
|
||||||
|
|
||||||
|
### Design System
|
||||||
|
|
||||||
|
**Color System (CSS Variables):**
|
||||||
|
```css
|
||||||
|
/* Backgrounds */
|
||||||
|
--background, --surface, --surface-secondary, --surface-elevated
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--foreground, --foreground-secondary, --foreground-tertiary, --foreground-muted
|
||||||
|
|
||||||
|
/* Interactive */
|
||||||
|
--primary, --secondary, --accent
|
||||||
|
|
||||||
|
/* Status */
|
||||||
|
--success, --error, --warning, --info
|
||||||
|
|
||||||
|
/* Borders */
|
||||||
|
--border, --border-strong
|
||||||
|
```
|
||||||
|
|
||||||
|
**Theme Support:**
|
||||||
|
- Light mode (default)
|
||||||
|
- Dark mode (Vercel/Cursor aesthetic)
|
||||||
|
- `next-themes` provider
|
||||||
|
|
||||||
|
### Dashboard Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
/dashboard
|
||||||
|
├── Overview # Quick actions, profile, activity
|
||||||
|
├── Agents # Create/manage AI agents
|
||||||
|
│ └── [id]/chat # Chat interface
|
||||||
|
├── Collections # Organize tools
|
||||||
|
├── Settings
|
||||||
|
│ └── api-keys # Manage API keys
|
||||||
|
└── Likes
|
||||||
|
├── tools
|
||||||
|
├── collections
|
||||||
|
└── agents
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Security & Authentication
|
||||||
|
|
||||||
|
### Authentication Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Sign Up → Email Verification → Sign In → Session Cookie → Protected Routes
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Key Storage
|
||||||
|
|
||||||
|
User API keys (OpenAI, Anthropic, etc.) are stored encrypted:
|
||||||
|
- AES-256-CBC encryption
|
||||||
|
- Unique IV per key
|
||||||
|
- Only hint (last 4 chars) visible in UI
|
||||||
|
|
||||||
|
### Rate Limiting
|
||||||
|
|
||||||
|
- **Distributed:** Vercel KV with in-memory fallback
|
||||||
|
- **Per-IP:** Based on `x-forwarded-for`, `x-real-ip`, or `cf-connecting-ip`
|
||||||
|
- **Headers:** `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `Retry-After`
|
||||||
|
|
||||||
|
### Cron Security
|
||||||
|
|
||||||
|
All sync endpoints require:
|
||||||
|
```
|
||||||
|
Authorization: Bearer {CRON_SECRET}
|
||||||
|
```
|
||||||
|
|
||||||
|
Vercel Cron automatically adds this header.
|
||||||
|
|
||||||
|
### Executor Verification
|
||||||
|
|
||||||
|
Custom executor URLs are verified:
|
||||||
|
1. HTTPS required in production
|
||||||
|
2. Private IP ranges blocked
|
||||||
|
3. Health endpoint checked
|
||||||
|
4. Test tool execution validated
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
| Variable | Required | Purpose |
|
||||||
|
|----------|----------|---------|
|
||||||
|
| `DATABASE_URL` | Yes | PostgreSQL connection |
|
||||||
|
| `BETTER_AUTH_SECRET` | Yes | Session encryption (32+ chars) |
|
||||||
|
| `CRON_SECRET` | Yes | Cron job auth (32+ chars) |
|
||||||
|
| `SANDBOX_EXECUTOR_URL` | No | Default executor URL |
|
||||||
|
| `GITHUB_TOKEN` | No | GitHub API for stars |
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development
|
||||||
|
pnpm dev # Run all dev servers
|
||||||
|
pnpm --filter=@tpmjs/web dev # Run web app only
|
||||||
|
|
||||||
|
# Database
|
||||||
|
pnpm --filter=@tpmjs/db db:generate # Generate Prisma client
|
||||||
|
pnpm --filter=@tpmjs/db db:push # Push schema changes
|
||||||
|
pnpm --filter=@tpmjs/db db:studio # Open Prisma Studio
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
pnpm test # Run all tests
|
||||||
|
pnpm type-check # Type-check all packages
|
||||||
|
pnpm lint # Lint all packages
|
||||||
|
|
||||||
|
# Building
|
||||||
|
pnpm build # Build all packages
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tech Stack
|
||||||
|
|
||||||
|
| Category | Technology |
|
||||||
|
|----------|------------|
|
||||||
|
| Framework | Next.js 16 (App Router) |
|
||||||
|
| Language | TypeScript 5.9 (strict) |
|
||||||
|
| Database | PostgreSQL + Prisma 6.19 |
|
||||||
|
| Auth | better-auth 1.4 |
|
||||||
|
| AI SDK | Vercel AI SDK 6.0 |
|
||||||
|
| Styling | Tailwind CSS 4.1 |
|
||||||
|
| Build | Turborepo + pnpm |
|
||||||
|
| Testing | Vitest + Testing Library |
|
||||||
|
| Deployment | Vercel |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*This documentation was auto-generated from codebase exploration. Last updated: January 2025*
|
||||||
559
DESIGN_SYSTEM.md
Normal file
559
DESIGN_SYSTEM.md
Normal file
|
|
@ -0,0 +1,559 @@
|
||||||
|
# TPMJS Design System Specification
|
||||||
|
|
||||||
|
> A technical, precise design system inspired by [turbopuffer.com](https://turbopuffer.com) - warm, monospace-driven, with generous whitespace and fieldset-style containers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Brand Direction
|
||||||
|
|
||||||
|
### Mood & Personality
|
||||||
|
- **Technical & Precise** - Engineering-focused, trustworthy, developer-first
|
||||||
|
- **Warm & Distinctive** - Not cold/corporate, the copper accent adds warmth
|
||||||
|
- **Confident & Minimal** - Let the content speak, reduce visual noise
|
||||||
|
|
||||||
|
### Reference Sites
|
||||||
|
- [turbopuffer.com](https://turbopuffer.com) - Primary inspiration
|
||||||
|
- Linear, Vercel - Secondary references for technical clarity
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Color Palette
|
||||||
|
|
||||||
|
### Primary Accent
|
||||||
|
```css
|
||||||
|
--color-accent: #A6592D; /* Copper/terracotta - primary brand color */
|
||||||
|
--color-accent-hover: #8B4A26; /* Darker copper for hover states */
|
||||||
|
--color-accent-light: #D4A574; /* Light copper for backgrounds/highlights */
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gradient Header
|
||||||
|
```css
|
||||||
|
/* Warm gradient for top bar/hero sections */
|
||||||
|
--gradient-header: linear-gradient(135deg, #D4732A 0%, #8B3D1A 50%, #2D1810 100%);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Neutral Palette
|
||||||
|
```css
|
||||||
|
/* Backgrounds */
|
||||||
|
--color-bg-primary: #FFFFFF; /* Main background */
|
||||||
|
--color-bg-secondary: #FAFAFA; /* Subtle sections */
|
||||||
|
--color-bg-elevated: #FFFFFF; /* Cards, elevated surfaces */
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--color-text-primary: #1A1A1A; /* Primary text - near black */
|
||||||
|
--color-text-secondary: #666666; /* Secondary/muted text */
|
||||||
|
--color-text-tertiary: #999999; /* Placeholder, hints */
|
||||||
|
|
||||||
|
/* Borders */
|
||||||
|
--color-border: #E5E5E5; /* Default borders */
|
||||||
|
--color-border-strong: #CCCCCC; /* Emphasized borders */
|
||||||
|
--color-border-focus: #A6592D; /* Focus state - uses accent */
|
||||||
|
```
|
||||||
|
|
||||||
|
### Semantic Colors
|
||||||
|
```css
|
||||||
|
--color-success: #22C55E;
|
||||||
|
--color-error: #EF4444;
|
||||||
|
--color-warning: #F59E0B;
|
||||||
|
--color-info: #3B82F6;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dark Mode (Future)
|
||||||
|
```css
|
||||||
|
/* Dark mode should invert while keeping the warm accent */
|
||||||
|
--color-bg-primary-dark: #0D0D0D;
|
||||||
|
--color-bg-secondary-dark: #1A1A1A;
|
||||||
|
--color-text-primary-dark: #F5F5F5;
|
||||||
|
--color-border-dark: #333333;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
### Font Stack
|
||||||
|
|
||||||
|
**Headings & Code: Monospace**
|
||||||
|
```css
|
||||||
|
--font-mono: 'JetBrains Mono', 'IBM Plex Mono', 'Fira Code', monospace;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Body Text: Sans-serif (for longer reading)**
|
||||||
|
```css
|
||||||
|
--font-sans: 'Inter', 'IBM Plex Sans', system-ui, sans-serif;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Type Scale
|
||||||
|
|
||||||
|
| Element | Font | Size | Weight | Line Height | Letter Spacing |
|
||||||
|
|---------|------|------|--------|-------------|----------------|
|
||||||
|
| H1 | Mono | 48px (3rem) | 600 | 1.1 | -0.02em |
|
||||||
|
| H2 | Mono | 36px (2.25rem) | 600 | 1.2 | -0.01em |
|
||||||
|
| H3 | Mono | 24px (1.5rem) | 600 | 1.3 | 0 |
|
||||||
|
| H4 | Mono | 20px (1.25rem) | 600 | 1.4 | 0 |
|
||||||
|
| Body Large | Sans | 18px (1.125rem) | 400 | 1.7 | 0 |
|
||||||
|
| Body | Sans | 16px (1rem) | 400 | 1.7 | 0 |
|
||||||
|
| Body Small | Sans | 14px (0.875rem) | 400 | 1.6 | 0 |
|
||||||
|
| Caption | Sans | 12px (0.75rem) | 400 | 1.5 | 0.01em |
|
||||||
|
| Code | Mono | 14px (0.875rem) | 400 | 1.6 | 0 |
|
||||||
|
|
||||||
|
### Typography Rules
|
||||||
|
1. **Headings are lowercase** - "pricing", "faq", "tools" (not "Pricing", "FAQ", "Tools")
|
||||||
|
2. **Generous line-height** - Minimum 1.6 for body text, 1.7 preferred
|
||||||
|
3. **Bold sparingly** - Use weight 600 for emphasis, not 700+
|
||||||
|
4. **Monospace for data** - Numbers, metrics, technical values always in mono
|
||||||
|
|
||||||
|
### CSS Variables
|
||||||
|
```css
|
||||||
|
/* Font families */
|
||||||
|
--font-heading: var(--font-mono);
|
||||||
|
--font-body: var(--font-sans);
|
||||||
|
--font-code: var(--font-mono);
|
||||||
|
|
||||||
|
/* Font sizes */
|
||||||
|
--text-xs: 0.75rem; /* 12px */
|
||||||
|
--text-sm: 0.875rem; /* 14px */
|
||||||
|
--text-base: 1rem; /* 16px */
|
||||||
|
--text-lg: 1.125rem; /* 18px */
|
||||||
|
--text-xl: 1.25rem; /* 20px */
|
||||||
|
--text-2xl: 1.5rem; /* 24px */
|
||||||
|
--text-3xl: 2.25rem; /* 36px */
|
||||||
|
--text-4xl: 3rem; /* 48px */
|
||||||
|
|
||||||
|
/* Line heights */
|
||||||
|
--leading-tight: 1.2;
|
||||||
|
--leading-normal: 1.5;
|
||||||
|
--leading-relaxed: 1.7;
|
||||||
|
|
||||||
|
/* Font weights */
|
||||||
|
--font-normal: 400;
|
||||||
|
--font-medium: 500;
|
||||||
|
--font-semibold: 600;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Spacing
|
||||||
|
|
||||||
|
### Spacing Scale
|
||||||
|
```css
|
||||||
|
--space-0: 0;
|
||||||
|
--space-1: 0.25rem; /* 4px */
|
||||||
|
--space-2: 0.5rem; /* 8px */
|
||||||
|
--space-3: 0.75rem; /* 12px */
|
||||||
|
--space-4: 1rem; /* 16px */
|
||||||
|
--space-5: 1.25rem; /* 20px */
|
||||||
|
--space-6: 1.5rem; /* 24px */
|
||||||
|
--space-8: 2rem; /* 32px */
|
||||||
|
--space-10: 2.5rem; /* 40px */
|
||||||
|
--space-12: 3rem; /* 48px */
|
||||||
|
--space-16: 4rem; /* 64px */
|
||||||
|
--space-20: 5rem; /* 80px */
|
||||||
|
--space-24: 6rem; /* 96px */
|
||||||
|
```
|
||||||
|
|
||||||
|
### Spacing Philosophy
|
||||||
|
- **Generous whitespace** - When in doubt, add more space
|
||||||
|
- **Vertical rhythm** - Use consistent spacing between sections (typically `--space-16` to `--space-24`)
|
||||||
|
- **Component padding** - Cards and containers use `--space-6` to `--space-8`
|
||||||
|
- **Text spacing** - Paragraphs separated by `--space-4` to `--space-6`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Borders & Containers
|
||||||
|
|
||||||
|
### Border Radius
|
||||||
|
```css
|
||||||
|
--radius-none: 0; /* DEFAULT - sharp corners */
|
||||||
|
--radius-sm: 2px; /* Use sparingly for special cases */
|
||||||
|
--radius-md: 4px; /* Use sparingly for special cases */
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rule: Default to 0 border-radius. Sharp corners are the brand.**
|
||||||
|
|
||||||
|
### Border Styles
|
||||||
|
|
||||||
|
**Dashed (Primary)**
|
||||||
|
```css
|
||||||
|
border: 1px dashed var(--color-border);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solid (Emphasis)**
|
||||||
|
```css
|
||||||
|
border: 2px solid var(--color-text-primary); /* Featured items */
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fieldset-Style Containers
|
||||||
|
|
||||||
|
The signature container style with a label that "cuts into" the border:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<fieldset class="fieldset-container">
|
||||||
|
<legend>section title</legend>
|
||||||
|
<!-- content -->
|
||||||
|
</fieldset>
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
.fieldset-container {
|
||||||
|
border: 1px dashed var(--color-border);
|
||||||
|
padding: var(--space-6);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldset-container legend {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
padding: 0 var(--space-2);
|
||||||
|
text-transform: lowercase;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Container Variants
|
||||||
|
|
||||||
|
| Variant | Border | Background | Use Case |
|
||||||
|
|---------|--------|------------|----------|
|
||||||
|
| Default | 1px dashed | transparent | Most containers |
|
||||||
|
| Elevated | 1px dashed | white | Cards on gray bg |
|
||||||
|
| Featured | 2px solid | white | Highlighted item |
|
||||||
|
| Ghost | none | transparent | Minimal grouping |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### Buttons
|
||||||
|
|
||||||
|
**Primary Button (Accent)**
|
||||||
|
```css
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: var(--space-3) var(--space-6);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--color-accent-hover);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Secondary Button (Outline)**
|
||||||
|
```css
|
||||||
|
.btn-secondary {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
padding: var(--space-3) var(--space-6);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
border-color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Button Sizes**
|
||||||
|
| Size | Padding | Font Size |
|
||||||
|
|------|---------|-----------|
|
||||||
|
| sm | `--space-2` `--space-4` | `--text-xs` |
|
||||||
|
| md | `--space-3` `--space-6` | `--text-sm` |
|
||||||
|
| lg | `--space-4` `--space-8` | `--text-base` |
|
||||||
|
|
||||||
|
### Links
|
||||||
|
|
||||||
|
```css
|
||||||
|
a {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
transition: opacity 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rule: Links are underlined, not colored.** Use underline as the primary affordance.
|
||||||
|
|
||||||
|
### Inputs
|
||||||
|
|
||||||
|
```css
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-none);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
background: white;
|
||||||
|
transition: border-color 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input::placeholder {
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cards
|
||||||
|
|
||||||
|
```css
|
||||||
|
.card {
|
||||||
|
border: 1px dashed var(--color-border);
|
||||||
|
padding: var(--space-6);
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card--featured {
|
||||||
|
border: 2px solid var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card__title {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
text-transform: lowercase;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card__description {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
line-height: var(--leading-relaxed);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Badges
|
||||||
|
|
||||||
|
```css
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-1) var(--space-3);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
text-transform: lowercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge--default { color: var(--color-text-secondary); }
|
||||||
|
.badge--success { color: var(--color-success); }
|
||||||
|
.badge--error { color: var(--color-error); }
|
||||||
|
.badge--warning { color: var(--color-warning); }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tables
|
||||||
|
|
||||||
|
```css
|
||||||
|
.table-container {
|
||||||
|
border: 1px dashed var(--color-border);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: var(--space-4);
|
||||||
|
border-bottom: 1px dashed var(--color-border);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
text-transform: lowercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table td {
|
||||||
|
padding: var(--space-4);
|
||||||
|
border-bottom: 1px dashed var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
### Container Widths
|
||||||
|
```css
|
||||||
|
--container-sm: 640px;
|
||||||
|
--container-md: 768px;
|
||||||
|
--container-lg: 1024px;
|
||||||
|
--container-xl: 1280px;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Page Structure
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ Gradient Header Bar (announcement) │
|
||||||
|
├─────────────────────────────────────────────┤
|
||||||
|
│ Navigation (sticky, white bg) │
|
||||||
|
├─────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Hero Section │
|
||||||
|
│ (generous padding: --space-24) │
|
||||||
|
│ │
|
||||||
|
├─────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Content Sections │
|
||||||
|
│ (separated by --space-16 to --space-24) │
|
||||||
|
│ │
|
||||||
|
│ ┌─ fieldset container ─────────────────┐ │
|
||||||
|
│ │ section title │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ Content with generous padding │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ └──────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Interactions
|
||||||
|
|
||||||
|
### Hover States
|
||||||
|
- **Buttons**: Background color change (accent → darker)
|
||||||
|
- **Links**: Opacity reduction to 0.7
|
||||||
|
- **Cards**: Border color change (border → border-strong)
|
||||||
|
- **No transforms** - Avoid scale/translate on hover (too playful)
|
||||||
|
|
||||||
|
### Focus States
|
||||||
|
```css
|
||||||
|
*:focus-visible {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Transitions
|
||||||
|
```css
|
||||||
|
--transition-fast: 150ms ease;
|
||||||
|
--transition-base: 200ms ease;
|
||||||
|
--transition-slow: 300ms ease;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rule: Keep transitions subtle and fast. No bouncy/spring animations.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Special Elements
|
||||||
|
|
||||||
|
### Gradient Header Bar
|
||||||
|
```css
|
||||||
|
.header-bar {
|
||||||
|
background: var(--gradient-header);
|
||||||
|
color: white;
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Technical Diagrams
|
||||||
|
Use ASCII-style box diagrams with monospace font:
|
||||||
|
```
|
||||||
|
┌─────────────┐ ┌─────────────┐
|
||||||
|
│ client │─────▶│ API │
|
||||||
|
└─────────────┘ └─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Code Blocks
|
||||||
|
```css
|
||||||
|
.code-block {
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
border: 1px dashed var(--color-border);
|
||||||
|
padding: var(--space-4);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sliders/Range Inputs
|
||||||
|
Custom styled with accent color, monospace tooltips showing values.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Do's and Don'ts
|
||||||
|
|
||||||
|
### Do
|
||||||
|
- Use lowercase for headings
|
||||||
|
- Use dashed borders for containers
|
||||||
|
- Use generous whitespace
|
||||||
|
- Use monospace for technical content
|
||||||
|
- Use underlines for links
|
||||||
|
- Keep interactions subtle and fast
|
||||||
|
- Use the copper accent sparingly but confidently
|
||||||
|
|
||||||
|
### Don't
|
||||||
|
- Don't use rounded corners (except for special cases)
|
||||||
|
- Don't use drop shadows
|
||||||
|
- Don't use gradients (except header bar)
|
||||||
|
- Don't use icons where text works
|
||||||
|
- Don't use colored links
|
||||||
|
- Don't use bouncy animations
|
||||||
|
- Don't use multiple accent colors
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Priority
|
||||||
|
|
||||||
|
### Phase 1: Foundation
|
||||||
|
1. Update CSS variables (colors, spacing, typography)
|
||||||
|
2. Install fonts (JetBrains Mono, Inter)
|
||||||
|
3. Update base styles (reset, typography)
|
||||||
|
|
||||||
|
### Phase 2: Core Components
|
||||||
|
1. Button variants
|
||||||
|
2. Input/Form elements
|
||||||
|
3. Card/Container styles
|
||||||
|
4. Badge variants
|
||||||
|
|
||||||
|
### Phase 3: Layout
|
||||||
|
1. Fieldset-style containers
|
||||||
|
2. Page layouts with generous spacing
|
||||||
|
3. Navigation updates
|
||||||
|
4. Gradient header bar
|
||||||
|
|
||||||
|
### Phase 4: Polish
|
||||||
|
1. Table styles
|
||||||
|
2. Code blocks
|
||||||
|
3. Interactive elements (sliders, toggles)
|
||||||
|
4. Transitions and hover states
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- **Turbopuffer**: https://turbopuffer.com - Primary design inspiration
|
||||||
|
- **JetBrains Mono**: https://www.jetbrains.com/lp/mono/
|
||||||
|
- **Inter**: https://rsms.me/inter/
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Last updated: January 2025*
|
||||||
|
*Version: 1.0*
|
||||||
269
EXECUTOR_COMPLIANCE.md
Normal file
269
EXECUTOR_COMPLIANCE.md
Normal file
|
|
@ -0,0 +1,269 @@
|
||||||
|
# TPMJS Executor Compliance Report
|
||||||
|
|
||||||
|
> **Generated:** 2026-02-04
|
||||||
|
> **Protocol Version:** 1.0
|
||||||
|
> **Test Suite Version:** 0.1.0
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document reports compliance testing results for the three reference TPMJS executor implementations against the Executor Protocol v1.0 specification.
|
||||||
|
|
||||||
|
## Compliance Summary
|
||||||
|
|
||||||
|
| Executor | Platform | Isolation | Core (L1) | Standard (L2) | Tests Passed |
|
||||||
|
|----------|----------|-----------|-----------|---------------|--------------|
|
||||||
|
| Railway Executor | Railway | Process | ✅ PASS | ✅ PASS | 15/15 |
|
||||||
|
| Unsandbox Executor | Unsandbox | Container | ✅ PASS | ✅ PASS | 15/15 |
|
||||||
|
| Vercel Executor | Vercel | VM | ✅ PASS* | ✅ PASS* | 15/15* |
|
||||||
|
|
||||||
|
\* Vercel Executor requires deployment to Vercel for full testing due to `@vercel/sandbox` dependency.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Railway Executor
|
||||||
|
|
||||||
|
**Location:** `templates/railway-executor/`
|
||||||
|
|
||||||
|
### Test Results
|
||||||
|
|
||||||
|
```
|
||||||
|
TPMJS Executor Compliance Test v0.1.0
|
||||||
|
Protocol Version: 1.0
|
||||||
|
Target: http://localhost:3456
|
||||||
|
|
||||||
|
Core Core Requirements:
|
||||||
|
✓ GET /health returns 200 (65ms)
|
||||||
|
✓ GET /health includes protocolVersion (5ms)
|
||||||
|
✓ GET /health includes implementationVersion (5ms)
|
||||||
|
✓ POST /execute-tool accepts valid request (4425ms)
|
||||||
|
✓ POST /execute-tool returns structured response (2202ms)
|
||||||
|
✓ POST /execute-tool returns error for invalid tool (1556ms)
|
||||||
|
✓ CORS headers present (3ms)
|
||||||
|
✓ OPTIONS preflight works (2ms)
|
||||||
|
|
||||||
|
Standard Standard Requirements:
|
||||||
|
✓ GET /info returns 200 (6ms)
|
||||||
|
✓ GET /info includes capabilities (3ms)
|
||||||
|
✓ GET /info includes protocolVersion (3ms)
|
||||||
|
✓ capabilities.isolation is valid (2ms)
|
||||||
|
✓ Authentication enforced when configured (2181ms)
|
||||||
|
✓ Execution timeout enforcement (2ms)
|
||||||
|
✓ Structured error codes (2307ms)
|
||||||
|
|
||||||
|
Summary:
|
||||||
|
Tests: 15 passed, 0 failed, 15 total
|
||||||
|
Core Compliance: PASS
|
||||||
|
Standard Compliance: PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
### Capabilities
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Railway Executor",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"protocolVersion": "1.0",
|
||||||
|
"capabilities": {
|
||||||
|
"isolation": "process",
|
||||||
|
"executionModes": ["sync"],
|
||||||
|
"maxExecutionTimeMs": 120000,
|
||||||
|
"maxRequestBodyBytes": 10485760,
|
||||||
|
"supportsStreaming": false,
|
||||||
|
"supportsCallbacks": false,
|
||||||
|
"supportsCaching": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Deploy to Railway
|
||||||
|
railway init
|
||||||
|
railway up
|
||||||
|
|
||||||
|
# Or use the Docker image
|
||||||
|
docker build -t tpmjs-executor .
|
||||||
|
docker run -p 3000:3000 tpmjs-executor
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unsandbox Executor
|
||||||
|
|
||||||
|
**Location:** `templates/unsandbox-executor/`
|
||||||
|
|
||||||
|
### Test Results
|
||||||
|
|
||||||
|
```
|
||||||
|
TPMJS Executor Compliance Test v0.1.0
|
||||||
|
Protocol Version: 1.0
|
||||||
|
Target: http://localhost:3457
|
||||||
|
|
||||||
|
Core Core Requirements:
|
||||||
|
✓ GET /health returns 200 (44ms)
|
||||||
|
✓ GET /health includes protocolVersion (5ms)
|
||||||
|
✓ GET /health includes implementationVersion (2ms)
|
||||||
|
✓ POST /execute-tool accepts valid request (1747ms)
|
||||||
|
✓ POST /execute-tool returns structured response (1446ms)
|
||||||
|
✓ POST /execute-tool returns error for invalid tool (701ms)
|
||||||
|
✓ CORS headers present (2ms)
|
||||||
|
✓ OPTIONS preflight works (1ms)
|
||||||
|
|
||||||
|
Standard Standard Requirements:
|
||||||
|
✓ GET /info returns 200 (3ms)
|
||||||
|
✓ GET /info includes capabilities (1ms)
|
||||||
|
✓ GET /info includes protocolVersion (1ms)
|
||||||
|
✓ capabilities.isolation is valid (0ms)
|
||||||
|
✓ Authentication enforced when configured (1926ms)
|
||||||
|
✓ Execution timeout enforcement (1ms)
|
||||||
|
✓ Structured error codes (744ms)
|
||||||
|
|
||||||
|
Summary:
|
||||||
|
Tests: 15 passed, 0 failed, 15 total
|
||||||
|
Core Compliance: PASS
|
||||||
|
Standard Compliance: PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
### Capabilities
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Unsandbox Executor",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"protocolVersion": "1.0",
|
||||||
|
"capabilities": {
|
||||||
|
"isolation": "container",
|
||||||
|
"executionModes": ["sync"],
|
||||||
|
"maxExecutionTimeMs": 120000,
|
||||||
|
"maxRequestBodyBytes": 10485760,
|
||||||
|
"supportsStreaming": false,
|
||||||
|
"supportsCallbacks": false,
|
||||||
|
"supportsCaching": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deployment
|
||||||
|
|
||||||
|
See `templates/unsandbox-executor/README.md` for Unsandbox deployment instructions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vercel Executor
|
||||||
|
|
||||||
|
**Location:** `templates/vercel-executor/`
|
||||||
|
|
||||||
|
### Capabilities
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Vercel Sandbox Executor",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"protocolVersion": "1.0",
|
||||||
|
"capabilities": {
|
||||||
|
"isolation": "vm",
|
||||||
|
"executionModes": ["sync"],
|
||||||
|
"maxExecutionTimeMs": 120000,
|
||||||
|
"maxRequestBodyBytes": 10485760,
|
||||||
|
"supportsStreaming": false,
|
||||||
|
"supportsCallbacks": false,
|
||||||
|
"supportsCaching": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Deploy to Vercel
|
||||||
|
vercel
|
||||||
|
|
||||||
|
# Or link and deploy
|
||||||
|
vercel link
|
||||||
|
vercel deploy --prod
|
||||||
|
```
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
The Vercel Executor uses `@vercel/sandbox` which provides VM-level isolation (strongest isolation level). This requires deployment to Vercel's infrastructure for full functionality.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Categories
|
||||||
|
|
||||||
|
### Core Requirements (Level 1) - 8 Tests
|
||||||
|
|
||||||
|
| Test | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| GET /health returns 200 | Health endpoint responds with 200 OK |
|
||||||
|
| GET /health includes protocolVersion | Response contains `protocolVersion` field |
|
||||||
|
| GET /health includes implementationVersion | Response contains `implementationVersion` field |
|
||||||
|
| POST /execute-tool accepts valid request | Execute endpoint accepts well-formed requests |
|
||||||
|
| POST /execute-tool returns structured response | Response includes `success`, `output`/`error`, `executionTimeMs` |
|
||||||
|
| POST /execute-tool returns error for invalid tool | Returns error with code for nonexistent package |
|
||||||
|
| CORS headers present | `Access-Control-Allow-Origin` header included |
|
||||||
|
| OPTIONS preflight works | OPTIONS request returns CORS headers |
|
||||||
|
|
||||||
|
### Standard Requirements (Level 2) - 7 Tests
|
||||||
|
|
||||||
|
| Test | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| GET /info returns 200 | Info endpoint responds with 200 OK |
|
||||||
|
| GET /info includes capabilities | Response contains `capabilities` object |
|
||||||
|
| GET /info includes protocolVersion | Response contains `protocolVersion` field |
|
||||||
|
| capabilities.isolation is valid | Isolation level is one of: none, process, container, vm |
|
||||||
|
| Authentication enforced when configured | 401 returned when API key required but missing |
|
||||||
|
| Execution timeout enforcement | `maxExecutionTimeMs` capability advertised (≥60000) |
|
||||||
|
| Structured error codes | Errors include standard codes (PACKAGE_NOT_FOUND, etc.) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Running Compliance Tests
|
||||||
|
|
||||||
|
### Using npx (Published)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx @tpmjs/executor-test https://your-executor.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Local Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packages/executor-test
|
||||||
|
pnpm build
|
||||||
|
node bin/run.js https://your-executor.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Authentication
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx @tpmjs/executor-test https://your-executor.example.com --api-key sk-xxx
|
||||||
|
```
|
||||||
|
|
||||||
|
### JSON Output
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx @tpmjs/executor-test https://your-executor.example.com --json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Specification Reference
|
||||||
|
|
||||||
|
- **EXECUTOR_SPECIFICATION.md** - Full protocol specification
|
||||||
|
- **executor-openapi.yaml** - OpenAPI 3.0 specification
|
||||||
|
- **packages/executor-test/** - Compliance test suite source
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
### 2026-02-04
|
||||||
|
|
||||||
|
- Initial compliance testing
|
||||||
|
- All 3 executors updated to v1.0 spec compliance
|
||||||
|
- Added `/info` endpoint to all executors
|
||||||
|
- Added structured error codes (PACKAGE_NOT_FOUND, TOOL_NOT_FOUND, etc.)
|
||||||
|
- Added `protocolVersion` and `implementationVersion` to health responses
|
||||||
|
- Added `X-TPMJS-Protocol-Version` header support
|
||||||
496
EXECUTOR_SPECIFICATION.md
Normal file
496
EXECUTOR_SPECIFICATION.md
Normal file
|
|
@ -0,0 +1,496 @@
|
||||||
|
# TPMJS Executor Protocol Specification v1.0
|
||||||
|
|
||||||
|
> **Status:** Draft
|
||||||
|
> **Version:** 1.0.0
|
||||||
|
> **Last Updated:** 2026-02-03
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The TPMJS Executor Protocol defines a standard HTTP interface for executing TPMJS tools. Executors are **compute adapters** that provide a consistent API for running npm-packaged tools regardless of the underlying infrastructure.
|
||||||
|
|
||||||
|
### Design Philosophy
|
||||||
|
|
||||||
|
- **HTTP-First:** No SDK lock-in, deployable anywhere
|
||||||
|
- **Minimal Surface:** Small core, optional extensions
|
||||||
|
- **Executor ≠ Sandbox:** Standardize coordination, not security
|
||||||
|
- **Declare, Don't Enforce:** Executors report capabilities, TPMJS decides policy
|
||||||
|
|
||||||
|
### Relationship to Other Specs
|
||||||
|
|
||||||
|
| Spec | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| **MCP** | Model ↔ Tool interface |
|
||||||
|
| **TPMJS Executor** | Tool ↔ Compute interface |
|
||||||
|
| **TPMJS Tools** | Tool contract (separate spec) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Protocol Versioning
|
||||||
|
|
||||||
|
### Version Header
|
||||||
|
|
||||||
|
All requests SHOULD include:
|
||||||
|
|
||||||
|
```http
|
||||||
|
X-TPMJS-Protocol-Version: 1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Executors MUST respond with their supported protocol version in `/health` and `/info` responses.
|
||||||
|
|
||||||
|
**Rationale:** Header-based versioning enables graceful evolution without URL fragmentation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Specification Levels
|
||||||
|
|
||||||
|
### Level 1: Core (REQUIRED)
|
||||||
|
|
||||||
|
Every executor MUST implement:
|
||||||
|
|
||||||
|
| Endpoint | Method | Purpose |
|
||||||
|
|----------|--------|---------|
|
||||||
|
| `/health` | GET | Liveness + protocol discovery |
|
||||||
|
| `/execute-tool` | POST | Synchronous tool execution |
|
||||||
|
|
||||||
|
### Level 2: Standard (RECOMMENDED)
|
||||||
|
|
||||||
|
Executors SHOULD implement:
|
||||||
|
|
||||||
|
| Endpoint | Method | Purpose |
|
||||||
|
|----------|--------|---------|
|
||||||
|
| `/info` | GET | Capability advertisement |
|
||||||
|
|
||||||
|
Plus:
|
||||||
|
- API key authentication
|
||||||
|
- Structured error responses
|
||||||
|
- Execution timeout enforcement
|
||||||
|
- CORS headers
|
||||||
|
|
||||||
|
### Level 3: Extended (OPTIONAL)
|
||||||
|
|
||||||
|
Reserved for future versions:
|
||||||
|
|
||||||
|
- `POST /execute-tool` with `Accept: text/event-stream` (streaming)
|
||||||
|
- `POST /execute-async` (webhook callbacks)
|
||||||
|
- `POST /validate-tool` (dry-run validation)
|
||||||
|
- `POST /execute-batch` (multiple tools)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Endpoints
|
||||||
|
|
||||||
|
### GET /health
|
||||||
|
|
||||||
|
**Purpose:** Verify executor is running and discover protocol version.
|
||||||
|
|
||||||
|
**Response (200 OK):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"protocolVersion": "1.0",
|
||||||
|
"implementationVersion": "1.0.0",
|
||||||
|
"runtime": "node",
|
||||||
|
"timestamp": "2026-02-03T12:00:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fields:**
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `status` | string | Yes | Always `"ok"` if healthy |
|
||||||
|
| `protocolVersion` | string | Yes | TPMJS protocol version (e.g., `"1.0"`) |
|
||||||
|
| `implementationVersion` | string | Yes | Executor software version |
|
||||||
|
| `runtime` | string | No | Runtime identifier (e.g., `"node"`, `"deno"`, `"bun"`) |
|
||||||
|
| `timestamp` | string | No | ISO 8601 timestamp |
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
- MUST respond within 1 second
|
||||||
|
- MUST return 200 OK if healthy
|
||||||
|
- MUST include `protocolVersion`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### POST /execute-tool
|
||||||
|
|
||||||
|
**Purpose:** Execute a single TPMJS tool synchronously.
|
||||||
|
|
||||||
|
**Request Headers:**
|
||||||
|
|
||||||
|
```http
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Bearer <api-key> (if auth enabled)
|
||||||
|
X-TPMJS-Protocol-Version: 1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"packageName": "@tpmjs/hello",
|
||||||
|
"version": "latest",
|
||||||
|
"name": "helloWorldTool",
|
||||||
|
"params": {
|
||||||
|
"greeting": "Hello"
|
||||||
|
},
|
||||||
|
"env": {
|
||||||
|
"OPENAI_API_KEY": "sk-..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Fields:**
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `packageName` | string | Yes | npm package name |
|
||||||
|
| `version` | string | No | Package version (default: `"latest"`) |
|
||||||
|
| `name` | string | Yes | Tool export name |
|
||||||
|
| `params` | object | No | Parameters passed to `tool.execute()` |
|
||||||
|
| `env` | object | No | Environment variables for execution |
|
||||||
|
|
||||||
|
**Success Response (200 OK):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"output": {
|
||||||
|
"message": "Hello, World!"
|
||||||
|
},
|
||||||
|
"executionTimeMs": 1234
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Response (200 OK):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"error": {
|
||||||
|
"code": "TOOL_EXECUTION_ERROR",
|
||||||
|
"message": "Tool threw an error: Invalid input"
|
||||||
|
},
|
||||||
|
"executionTimeMs": 123
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response Fields:**
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `success` | boolean | Yes | Whether execution succeeded |
|
||||||
|
| `output` | any | If success | Return value from `tool.execute()` |
|
||||||
|
| `error` | object | If failed | Error details |
|
||||||
|
| `error.code` | string | If failed | Machine-readable error code |
|
||||||
|
| `error.message` | string | If failed | Human-readable error message |
|
||||||
|
| `executionTimeMs` | number | Yes | Total execution time in milliseconds |
|
||||||
|
|
||||||
|
**Error Codes:**
|
||||||
|
|
||||||
|
| Code | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `PACKAGE_NOT_FOUND` | npm package could not be installed |
|
||||||
|
| `TOOL_NOT_FOUND` | Named export not found in package |
|
||||||
|
| `TOOL_INVALID` | Export exists but has no `.execute()` method |
|
||||||
|
| `TOOL_EXECUTION_ERROR` | Tool threw during execution |
|
||||||
|
| `EXECUTION_TIMEOUT` | Execution exceeded time limit |
|
||||||
|
| `INTERNAL_ERROR` | Unexpected executor error |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Standard Endpoints
|
||||||
|
|
||||||
|
### GET /info
|
||||||
|
|
||||||
|
**Purpose:** Advertise executor capabilities for intelligent routing.
|
||||||
|
|
||||||
|
**Response (200 OK):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Railway Executor",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"protocolVersion": "1.0",
|
||||||
|
"capabilities": {
|
||||||
|
"isolation": "process",
|
||||||
|
"executionModes": ["sync"],
|
||||||
|
"maxExecutionTimeMs": 120000,
|
||||||
|
"maxRequestBodyBytes": 10485760,
|
||||||
|
"supportsStreaming": false,
|
||||||
|
"supportsCallbacks": false,
|
||||||
|
"supportsCaching": false
|
||||||
|
},
|
||||||
|
"runtime": {
|
||||||
|
"platform": "linux",
|
||||||
|
"nodeVersion": "20.10.0",
|
||||||
|
"region": "us-west-1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Capability Fields:**
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `isolation` | string | `"none"` \| `"process"` \| `"container"` \| `"vm"` |
|
||||||
|
| `executionModes` | array | `["sync"]` (future: `"stream"`, `"async"`) |
|
||||||
|
| `maxExecutionTimeMs` | number | Maximum execution time before timeout |
|
||||||
|
| `maxRequestBodyBytes` | number | Maximum request body size |
|
||||||
|
| `supportsStreaming` | boolean | Reserved for v1.1 |
|
||||||
|
| `supportsCallbacks` | boolean | Reserved for v1.1 |
|
||||||
|
| `supportsCaching` | boolean | Reserved for v1.1 |
|
||||||
|
|
||||||
|
**Isolation Levels:**
|
||||||
|
|
||||||
|
| Level | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `none` | Tools run in executor process (development only) |
|
||||||
|
| `process` | Tools run in separate OS process |
|
||||||
|
| `container` | Tools run in isolated container |
|
||||||
|
| `vm` | Tools run in isolated VM (strongest) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
### v1.0: API Key Only
|
||||||
|
|
||||||
|
Executors MAY require authentication via Bearer token.
|
||||||
|
|
||||||
|
**Request Header:**
|
||||||
|
|
||||||
|
```http
|
||||||
|
Authorization: Bearer <api-key>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
|
||||||
|
Executors SHOULD use `EXECUTOR_API_KEY` environment variable:
|
||||||
|
- If set: All requests MUST include valid Bearer token
|
||||||
|
- If unset: No authentication required
|
||||||
|
|
||||||
|
**Unauthorized Response (401):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"error": {
|
||||||
|
"code": "UNAUTHORIZED",
|
||||||
|
"message": "Invalid or missing API key"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Future Versions:** JWT, OAuth, and per-tool authentication are deferred to v1.1+.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CORS Requirements
|
||||||
|
|
||||||
|
All executors MUST support CORS for browser-based clients.
|
||||||
|
|
||||||
|
**Required Headers:**
|
||||||
|
|
||||||
|
```http
|
||||||
|
Access-Control-Allow-Origin: *
|
||||||
|
Access-Control-Allow-Methods: GET, POST, OPTIONS
|
||||||
|
Access-Control-Allow-Headers: Content-Type, Authorization, X-TPMJS-Protocol-Version
|
||||||
|
```
|
||||||
|
|
||||||
|
**OPTIONS Preflight:**
|
||||||
|
|
||||||
|
All endpoints MUST handle OPTIONS requests and return CORS headers with 200 OK.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution Lifecycle
|
||||||
|
|
||||||
|
### Standard Flow
|
||||||
|
|
||||||
|
1. **Receive Request:** Parse JSON body, validate required fields
|
||||||
|
2. **Check Auth:** Verify API key if configured
|
||||||
|
3. **Create Isolation:** Create temporary execution environment
|
||||||
|
4. **Install Package:** Run `npm install <package>@<version>`
|
||||||
|
5. **Load Tool:** Import package, resolve named export
|
||||||
|
6. **Execute:** Call `tool.execute(params)` with environment
|
||||||
|
7. **Capture Result:** Collect output or error
|
||||||
|
8. **Cleanup:** Remove temporary files/processes
|
||||||
|
9. **Respond:** Return JSON response
|
||||||
|
|
||||||
|
### Tool Resolution
|
||||||
|
|
||||||
|
Executors MUST resolve a callable tool with an `.execute()` method.
|
||||||
|
|
||||||
|
**Recommended Resolution Order:**
|
||||||
|
|
||||||
|
1. `pkg[name]` - Direct named export
|
||||||
|
2. `pkg.default?.[name]` - Named property on default export
|
||||||
|
3. `pkg.default` - Default export itself (if `name` matches)
|
||||||
|
|
||||||
|
**Factory Functions:**
|
||||||
|
|
||||||
|
If export is a function without `.execute()`:
|
||||||
|
1. Try calling `tool()` with no arguments
|
||||||
|
2. Check if result has `.execute()` method
|
||||||
|
|
||||||
|
**Note:** Tool export patterns are intentionally not fully standardized in v1.0 to allow ecosystem evolution.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Timeouts
|
||||||
|
|
||||||
|
### Required Timeouts
|
||||||
|
|
||||||
|
| Phase | Minimum | Recommended |
|
||||||
|
|-------|---------|-------------|
|
||||||
|
| npm install | 30s | 60s |
|
||||||
|
| Tool execution | 60s | 120s |
|
||||||
|
| Total request | 90s | 180s |
|
||||||
|
|
||||||
|
Executors MUST:
|
||||||
|
- Enforce execution timeouts
|
||||||
|
- Return `EXECUTION_TIMEOUT` error code when exceeded
|
||||||
|
- Clean up resources on timeout
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### HTTP Status Codes
|
||||||
|
|
||||||
|
| Code | Usage |
|
||||||
|
|------|-------|
|
||||||
|
| 200 | Successful execution OR tool error (with `success: false`) |
|
||||||
|
| 400 | Invalid request (missing fields, malformed JSON) |
|
||||||
|
| 401 | Authentication required but missing/invalid |
|
||||||
|
| 404 | Unknown endpoint |
|
||||||
|
| 500 | Internal executor error |
|
||||||
|
|
||||||
|
### Structured Errors
|
||||||
|
|
||||||
|
All error responses MUST include:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"error": {
|
||||||
|
"code": "ERROR_CODE",
|
||||||
|
"message": "Human-readable description"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Checklist
|
||||||
|
|
||||||
|
### Core (Required for Compliance)
|
||||||
|
|
||||||
|
- [ ] `GET /health` returns status and protocol version
|
||||||
|
- [ ] `POST /execute-tool` accepts standard request format
|
||||||
|
- [ ] Returns `{ success, output/error, executionTimeMs }`
|
||||||
|
- [ ] Handles missing/invalid request body (400)
|
||||||
|
- [ ] CORS headers on all responses
|
||||||
|
- [ ] OPTIONS preflight handling
|
||||||
|
|
||||||
|
### Standard (Recommended)
|
||||||
|
|
||||||
|
- [ ] `GET /info` with capabilities
|
||||||
|
- [ ] `EXECUTOR_API_KEY` environment variable support
|
||||||
|
- [ ] Bearer token validation (401 on failure)
|
||||||
|
- [ ] Execution timeout enforcement
|
||||||
|
- [ ] npm install timeout (60s recommended)
|
||||||
|
- [ ] Temporary file cleanup
|
||||||
|
- [ ] Structured error codes
|
||||||
|
|
||||||
|
### Extended (Optional)
|
||||||
|
|
||||||
|
- [ ] Package caching
|
||||||
|
- [ ] Concurrent execution limiting
|
||||||
|
- [ ] Support for both `/path` and `/api/path` routes
|
||||||
|
- [ ] Region/metadata in `/info` response
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Compliance Testing
|
||||||
|
|
||||||
|
Use the official compliance test suite:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx @tpmjs/executor-test https://my-executor.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Output:
|
||||||
|
|
||||||
|
```
|
||||||
|
TPMJS Executor Compliance Test v1.0.0
|
||||||
|
Target: https://my-executor.example.com
|
||||||
|
|
||||||
|
Core Requirements:
|
||||||
|
✓ GET /health returns 200
|
||||||
|
✓ GET /health includes protocolVersion
|
||||||
|
✓ POST /execute-tool accepts valid request
|
||||||
|
✓ POST /execute-tool returns success response
|
||||||
|
✓ POST /execute-tool returns error for invalid tool
|
||||||
|
✓ CORS headers present
|
||||||
|
✓ OPTIONS preflight works
|
||||||
|
|
||||||
|
Standard Requirements:
|
||||||
|
✓ GET /info returns capabilities
|
||||||
|
✓ Authentication enforced when configured
|
||||||
|
✓ Execution timeout enforced
|
||||||
|
✗ Missing: maxExecutionTimeMs in capabilities
|
||||||
|
|
||||||
|
Result: 10/11 tests passed (Core: PASS, Standard: PARTIAL)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reference Implementations
|
||||||
|
|
||||||
|
| Name | Platform | Isolation | Source |
|
||||||
|
|------|----------|-----------|--------|
|
||||||
|
| Railway Executor | Railway | Process | `templates/railway-executor/` |
|
||||||
|
| Vercel Executor | Vercel | VM (Sandbox) | `templates/vercel-executor/` |
|
||||||
|
| Unsandbox Executor | Unsandbox | Container | `templates/unsandbox-executor/` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Roadmap
|
||||||
|
|
||||||
|
### v1.1 (Planned)
|
||||||
|
|
||||||
|
- Streaming responses (`Accept: text/event-stream`)
|
||||||
|
- Async execution with webhooks
|
||||||
|
- Caching hints (`X-TPMJS-Cache-*` headers)
|
||||||
|
- Tool validation endpoint
|
||||||
|
|
||||||
|
### v2.0 (Exploration)
|
||||||
|
|
||||||
|
- Multi-tool batch execution
|
||||||
|
- Persistent execution contexts
|
||||||
|
- Resource quotas and billing hooks
|
||||||
|
- MCP bridge protocol
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
### v1.0.0 (2026-02-03)
|
||||||
|
|
||||||
|
- Initial formal specification
|
||||||
|
- Core: `/health`, `/execute-tool`
|
||||||
|
- Standard: `/info`, API key auth
|
||||||
|
- Capability negotiation
|
||||||
|
- Compliance test suite
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix: OpenAPI Specification
|
||||||
|
|
||||||
|
See `executor-openapi.yaml` for the formal OpenAPI 3.0 specification.
|
||||||
|
|
||||||
|
## Appendix: JSON Schemas
|
||||||
|
|
||||||
|
See `packages/types/src/executor.ts` for TypeScript types and Zod schemas.
|
||||||
|
|
@ -217,7 +217,11 @@ Your tool will be automatically discovered through:
|
||||||
|
|
||||||
After publishing, your tool should appear on https://tpmjs.com within 15 minutes!
|
After publishing, your tool should appear on https://tpmjs.com within 15 minutes!
|
||||||
|
|
||||||
You can verify by searching: https://tpmjs.com/api/tools?q=yourpackagename
|
You can verify by searching (requires API key):
|
||||||
|
```bash
|
||||||
|
curl "https://tpmjs.com/api/tools?q=yourpackagename" \
|
||||||
|
-H "Authorization: Bearer tpmjs_sk_your_api_key_here"
|
||||||
|
```
|
||||||
|
|
||||||
## Real Example: @tpmjs/createblogpost
|
## Real Example: @tpmjs/createblogpost
|
||||||
|
|
||||||
|
|
@ -411,14 +415,15 @@ Or manually check the structure matches the examples above.
|
||||||
- Add all Rich tier fields for maximum visibility
|
- Add all Rich tier fields for maximum visibility
|
||||||
|
|
||||||
**Want to force a sync?**
|
**Want to force a sync?**
|
||||||
You can manually trigger a sync (requires auth):
|
You can manually trigger a sync (requires CRON_SECRET, not a user API key):
|
||||||
```bash
|
```bash
|
||||||
curl -X POST "https://tpmjs.com/api/sync/keyword" \
|
curl -X POST "https://tpmjs.com/api/sync/keyword" \
|
||||||
-H "Authorization: Bearer YOUR_CRON_SECRET"
|
-H "Authorization: Bearer $CRON_SECRET"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Support
|
## Support
|
||||||
|
|
||||||
Questions or issues?
|
Questions or issues?
|
||||||
- File an issue: https://github.com/ajaxdavis/tpmjs/issues
|
- File an issue: https://github.com/ajaxdavis/tpmjs/issues
|
||||||
- Check the API: https://tpmjs.com/api/tools
|
- Check the API docs: https://tpmjs.com/docs/api
|
||||||
|
- Generate an API key: https://tpmjs.com/dashboard/settings/tpmjs-api-keys
|
||||||
|
|
|
||||||
|
|
@ -154,7 +154,7 @@ automation, ai-ml, security, monitoring
|
||||||
- NPM_MIRROR: Different formula entirely
|
- NPM_MIRROR: Different formula entirely
|
||||||
|
|
||||||
### Field Names Inconsistent
|
### Field Names Inconsistent
|
||||||
- `exportName` used in MANUAL_TOOLS but not in HOW_TO_PUBLISH
|
- `name` used in MANUAL_TOOLS but not in HOW_TO_PUBLISH
|
||||||
- Deprecated fields (`parameters`, `returns`) mentioned but unclear when deprecated
|
- Deprecated fields (`parameters`, `returns`) mentioned but unclear when deprecated
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ Edit `manual-tools.ts` and add a new entry:
|
||||||
npmPackageName: 'example-package',
|
npmPackageName: 'example-package',
|
||||||
category: 'search',
|
category: 'search',
|
||||||
frameworks: ['vercel-ai'],
|
frameworks: ['vercel-ai'],
|
||||||
exportName: 'exampleTool',
|
name: 'exampleTool',
|
||||||
description: 'A clear, concise description of what this tool does',
|
description: 'A clear, concise description of what this tool does',
|
||||||
|
|
||||||
// Optional but recommended for 'rich' tier
|
// Optional but recommended for 'rich' tier
|
||||||
|
|
@ -116,24 +116,24 @@ pnpm dev --filter=@tpmjs/web
|
||||||
|
|
||||||
## Multi-Tool Packages
|
## Multi-Tool Packages
|
||||||
|
|
||||||
If a package exports multiple tools, add multiple entries with the same `npmPackageName` but different `exportName`:
|
If a package exports multiple tools, add multiple entries with the same `npmPackageName` but different `name`:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
{
|
{
|
||||||
npmPackageName: 'firecrawl-aisdk',
|
npmPackageName: 'firecrawl-aisdk',
|
||||||
exportName: 'scrapeTool',
|
name: 'scrapeTool',
|
||||||
description: 'Scrape websites...',
|
description: 'Scrape websites...',
|
||||||
// ...
|
// ...
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
npmPackageName: 'firecrawl-aisdk',
|
npmPackageName: 'firecrawl-aisdk',
|
||||||
exportName: 'searchTool',
|
name: 'searchTool',
|
||||||
description: 'Search the web...',
|
description: 'Search the web...',
|
||||||
// ...
|
// ...
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
npmPackageName: 'firecrawl-aisdk',
|
npmPackageName: 'firecrawl-aisdk',
|
||||||
exportName: 'crawlTool',
|
name: 'crawlTool',
|
||||||
description: 'Crawl entire websites...',
|
description: 'Crawl entire websites...',
|
||||||
// ...
|
// ...
|
||||||
},
|
},
|
||||||
|
|
|
||||||
149
TOP_5_PRIORITIES.md
Normal file
149
TOP_5_PRIORITIES.md
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
# Top 5 Priorities for TPMJS to Become Production-Ready
|
||||||
|
|
||||||
|
> Based on comprehensive codebase analysis - January 2026
|
||||||
|
|
||||||
|
TPMJS is approximately 70-75% towards being production-ready for widespread use. The platform has solid fundamentals: a well-architected monorepo, robust npm sync system, working MCP integration, and professional code quality standards. However, five critical gaps need addressing before TPMJS can become the "npm for AI tools" it aspires to be.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Complete the Developer SDK Packages
|
||||||
|
|
||||||
|
**The Problem:**
|
||||||
|
Developers can't easily integrate TPMJS tools into their applications. The SDK packages `@tpmjs/registry-search` and `@tpmjs/registry-execute` are either incomplete or missing. This defeats the core value proposition - if AI agents can't programmatically discover and execute tools from TPMJS, the registry is just a website, not an ecosystem.
|
||||||
|
|
||||||
|
**What's Needed:**
|
||||||
|
- `@tpmjs/registry-search` - TypeScript client for searching tools with full type safety
|
||||||
|
- `@tpmjs/registry-execute` - Execute any TPMJS tool from any Node.js application
|
||||||
|
- `@tpmjs/agent-toolkit` - Pre-built integration for popular agent frameworks (LangChain, AutoGPT, CrewAI)
|
||||||
|
- Clear examples showing integration with Claude, GPT-4, and other LLMs
|
||||||
|
|
||||||
|
**Impact:**
|
||||||
|
Without these SDKs, TPMJS is just a directory. With them, every AI developer can instantly access 100+ tools with a single `npm install`. This is the difference between a catalog and a platform.
|
||||||
|
|
||||||
|
**Effort:** 2-4 weeks of focused development
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Add Social Proof and Discovery Features
|
||||||
|
|
||||||
|
**The Problem:**
|
||||||
|
Users have no way to evaluate tool quality beyond download counts. There's no star rating, no reviews, no "trending this week," and no recommendations. When browsing tools, users can't distinguish battle-tested tools from abandoned experiments.
|
||||||
|
|
||||||
|
**What's Needed:**
|
||||||
|
- **5-star rating system** with verified user ratings
|
||||||
|
- **User reviews** with upvoting and author responses
|
||||||
|
- **Trending tools** algorithm (based on recent usage, not just total downloads)
|
||||||
|
- **"Staff Picks"** or curated collections for common use cases
|
||||||
|
- **Similar tools** recommendations on each tool page
|
||||||
|
- **Usage statistics** - "Used in 50 agents" or "10,000 executions this month"
|
||||||
|
|
||||||
|
**Impact:**
|
||||||
|
Social proof is essential for adoption. GitHub has stars, npm has weekly downloads prominently displayed, Product Hunt has upvotes. TPMJS needs its own trust signals. Without them, users default to building their own tools or using alternatives they can evaluate.
|
||||||
|
|
||||||
|
**Effort:** 3-4 weeks including UI/UX design
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Build Comprehensive Documentation and Onboarding
|
||||||
|
|
||||||
|
**The Problem:**
|
||||||
|
The publishing guide exists but there's no interactive tutorial for new users. API documentation is schema-only with no examples. Developers looking to build tools, create agents, or integrate TPMJS into their workflow face a steep learning curve with limited guidance.
|
||||||
|
|
||||||
|
**What's Needed:**
|
||||||
|
- **Interactive onboarding flow** - Guided first-time experience creating an agent with tools
|
||||||
|
- **API documentation** with copy-paste examples for every endpoint
|
||||||
|
- **Video tutorials** - 5-minute quickstarts for common tasks
|
||||||
|
- **Example agents** - Pre-built agents demonstrating best practices (research agent, coding assistant, data analyst)
|
||||||
|
- **Tool development guide** - Step-by-step from `npm init` to published tool
|
||||||
|
- **Troubleshooting guide** - Common errors and solutions
|
||||||
|
|
||||||
|
**Impact:**
|
||||||
|
Documentation is a product feature. Every hour spent on docs saves thousands of hours of user frustration. LangChain succeeded partly because of excellent docs. TPMJS needs the same investment.
|
||||||
|
|
||||||
|
**Effort:** 4-6 weeks for comprehensive documentation overhaul
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Build Observability and Platform Trust
|
||||||
|
|
||||||
|
**The Problem:**
|
||||||
|
There's no public status page, no platform-wide health dashboard, and limited visibility into what's working. Users can't answer basic questions: "Is TPMJS up?", "How reliable is this tool?", "What's the average response time?"
|
||||||
|
|
||||||
|
**What's Needed:**
|
||||||
|
- **Public status page** (status.tpmjs.com) showing real-time platform health
|
||||||
|
- **Tool health dashboard** - Aggregate view of which tools are healthy/broken
|
||||||
|
- **Response time metrics** - P50/P95/P99 latency for tool executions
|
||||||
|
- **Uptime guarantees** - Published SLA (even informal "99.9% target")
|
||||||
|
- **Incident history** - Transparent communication about outages
|
||||||
|
- **Usage analytics dashboard** - For tool authors to see how their tools are used
|
||||||
|
|
||||||
|
**Impact:**
|
||||||
|
Trust is earned through transparency. AWS publishes their health dashboard. GitHub has status.github.com. Enterprises won't adopt platforms they can't monitor. Even individual developers want to know if their agent's failures are their code or the platform.
|
||||||
|
|
||||||
|
**Effort:** 2-3 weeks for MVP status page and health dashboard
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Add Team and Enterprise Features
|
||||||
|
|
||||||
|
**The Problem:**
|
||||||
|
TPMJS is individual-only. There's no way to share collections within a team, manage API keys across an organization, or implement approval workflows. This blocks enterprise adoption where multiple developers need to collaborate on agent tooling.
|
||||||
|
|
||||||
|
**What's Needed:**
|
||||||
|
- **Organizations** - Create teams with shared collections and agents
|
||||||
|
- **Role-based access control (RBAC)** - Admin, Developer, Viewer roles
|
||||||
|
- **Shared API keys** - Organization-scoped keys with usage attribution
|
||||||
|
- **Audit logging** - Who did what, when (required for compliance)
|
||||||
|
- **Private tools** - Organization-only tool publishing
|
||||||
|
- **SSO/SAML** - Enterprise identity provider integration
|
||||||
|
- **Usage quotas** - Set limits per team member or project
|
||||||
|
|
||||||
|
**Impact:**
|
||||||
|
Enterprise customers pay for tools. They also require these features for security and compliance. One enterprise contract can fund months of development. More importantly, enterprise adoption validates the platform and attracts more developers.
|
||||||
|
|
||||||
|
**Effort:** 6-8 weeks for core team features, 3-6 months for full enterprise suite
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Priority | Impact | Effort | Recommended Order |
|
||||||
|
|----------|--------|--------|-------------------|
|
||||||
|
| 1. Complete SDK Packages | Critical | 2-4 weeks | First |
|
||||||
|
| 2. Social Proof/Discovery | High | 3-4 weeks | Second |
|
||||||
|
| 3. Documentation | High | 4-6 weeks | Parallel with #2 |
|
||||||
|
| 4. Observability/Trust | Medium-High | 2-3 weeks | Third |
|
||||||
|
| 5. Enterprise Features | Medium | 6-8 weeks | Fourth |
|
||||||
|
|
||||||
|
**Recommended approach:**
|
||||||
|
1. **Weeks 1-4:** Complete SDK packages (unlocks programmatic adoption)
|
||||||
|
2. **Weeks 2-6:** Build ratings/reviews and documentation in parallel
|
||||||
|
3. **Weeks 7-9:** Add status page and health dashboard
|
||||||
|
4. **Weeks 10+:** Begin enterprise features based on customer demand
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Strengths to Leverage
|
||||||
|
|
||||||
|
TPMJS already has strong foundations:
|
||||||
|
- Robust npm sync system (tools auto-discovered)
|
||||||
|
- Working MCP protocol integration
|
||||||
|
- Clean monorepo architecture
|
||||||
|
- Good authentication system
|
||||||
|
- Solid database design
|
||||||
|
- Quality coding standards
|
||||||
|
|
||||||
|
These investments mean the platform can scale. The gaps identified above are about adoption and trust, not technical architecture.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Bottom Line
|
||||||
|
|
||||||
|
TPMJS has built a good tool registry. To become **the** AI tools platform, it needs to:
|
||||||
|
1. Make tools easy to use programmatically (SDKs)
|
||||||
|
2. Help users find good tools (social proof)
|
||||||
|
3. Help developers build tools (documentation)
|
||||||
|
4. Build platform confidence (observability)
|
||||||
|
5. Enable team adoption (enterprise features)
|
||||||
|
|
||||||
|
With focused effort on these five areas over the next 3-6 months, TPMJS can establish itself as the definitive platform for AI agent tooling.
|
||||||
698
TPMJS_FEATURES.md
Normal file
698
TPMJS_FEATURES.md
Normal file
|
|
@ -0,0 +1,698 @@
|
||||||
|
# TPMJS Platform - Complete Feature Documentation
|
||||||
|
|
||||||
|
A comprehensive overview of all TPMJS functionality for marketing, fundraising, and pet project ideation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Platform Overview](#platform-overview)
|
||||||
|
2. [Core Architecture](#core-architecture)
|
||||||
|
3. [Tool Registry & Discovery](#tool-registry--discovery)
|
||||||
|
4. [Tool Execution System](#tool-execution-system)
|
||||||
|
5. [MCP (Model Context Protocol) Implementation](#mcp-model-context-protocol-implementation)
|
||||||
|
6. [Collections System](#collections-system)
|
||||||
|
7. [Agent System](#agent-system)
|
||||||
|
8. [API Endpoints](#api-endpoints)
|
||||||
|
9. [SDK & Packages](#sdk--packages)
|
||||||
|
10. [Security & Privacy](#security--privacy)
|
||||||
|
11. [Infrastructure](#infrastructure)
|
||||||
|
12. [Use Cases](#use-cases)
|
||||||
|
13. [Competitive Advantages](#competitive-advantages)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Platform Overview
|
||||||
|
|
||||||
|
**TPMJS (Tool Package Manager for JavaScript)** is an open platform for discovering, sharing, and executing AI tools via the Model Context Protocol (MCP). Think of it as "npm for AI tools" - a registry where developers can publish tools that AI assistants can use.
|
||||||
|
|
||||||
|
### Key Value Propositions
|
||||||
|
|
||||||
|
1. **Unified Tool Registry** - One place to discover and use AI tools
|
||||||
|
2. **Instant MCP Servers** - Any collection becomes an MCP-compatible server
|
||||||
|
3. **Secure Execution** - Sandboxed tool execution with rate limiting
|
||||||
|
4. **AI Agent Infrastructure** - Build multi-turn conversational agents with tool access
|
||||||
|
5. **Developer-Friendly** - Publish tools via npm, use via standard protocols
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Architecture
|
||||||
|
|
||||||
|
### Tech Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|-------|------------|
|
||||||
|
| Frontend | Next.js 16 (App Router), React 19, Tailwind CSS |
|
||||||
|
| Backend | Next.js API Routes (Serverless) |
|
||||||
|
| Database | PostgreSQL (Neon) with Prisma ORM |
|
||||||
|
| Auth | NextAuth.js (GitHub OAuth) |
|
||||||
|
| Hosting | Vercel (Edge + Serverless) |
|
||||||
|
| Package Registry | npm (mirrored) |
|
||||||
|
| Build System | Turborepo + pnpm workspaces |
|
||||||
|
|
||||||
|
### Monorepo Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
tpmjs/
|
||||||
|
├── apps/
|
||||||
|
│ ├── web/ # Main Next.js application (tpmjs.com)
|
||||||
|
│ └── playground/ # Interactive tool testing environment
|
||||||
|
├── packages/
|
||||||
|
│ ├── @tpmjs/types # Shared TypeScript types & Zod schemas
|
||||||
|
│ ├── @tpmjs/ui # React component library
|
||||||
|
│ ├── @tpmjs/utils # Utility functions
|
||||||
|
│ ├── @tpmjs/env # Environment variable validation
|
||||||
|
│ ├── @tpmjs/db # Prisma database client
|
||||||
|
│ ├── @tpmjs/mocks # MSW mock server for testing
|
||||||
|
│ └── @tpmjs/config # Shared configs (ESLint, Tailwind, TypeScript)
|
||||||
|
└── templates/
|
||||||
|
└── vercel-executor/ # Template for deploying tool executors
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tool Registry & Discovery
|
||||||
|
|
||||||
|
### What is a TPMJS Tool?
|
||||||
|
|
||||||
|
A TPMJS tool is an npm package with:
|
||||||
|
1. The `tpmjs` keyword in package.json
|
||||||
|
2. A `tpmjs` field defining the tool's MCP schema
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "my-awesome-tool",
|
||||||
|
"keywords": ["tpmjs"],
|
||||||
|
"tpmjs": {
|
||||||
|
"name": "my-tool",
|
||||||
|
"description": "Does awesome things",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"query": { "type": "string" }
|
||||||
|
},
|
||||||
|
"required": ["query"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tool Tiers
|
||||||
|
|
||||||
|
| Tier | Description | Features |
|
||||||
|
|------|-------------|----------|
|
||||||
|
| **Minimal** | Basic tool definition | Name, description, input schema only |
|
||||||
|
| **Rich** | Full-featured tool | Executor URL, examples, categories, tags |
|
||||||
|
|
||||||
|
### Discovery Methods
|
||||||
|
|
||||||
|
1. **npm Changes Feed Sync** (every 2 minutes)
|
||||||
|
- Monitors npm's real-time changes feed
|
||||||
|
- Catches new packages and updates instantly
|
||||||
|
- Processes ~100 changes per run
|
||||||
|
|
||||||
|
2. **Keyword Search Sync** (every 15 minutes)
|
||||||
|
- Actively searches npm for `tpmjs` keyword
|
||||||
|
- Backfills any missed packages
|
||||||
|
- Processes up to 250 packages per run
|
||||||
|
|
||||||
|
3. **Metrics Sync** (hourly)
|
||||||
|
- Updates download statistics
|
||||||
|
- Calculates quality scores
|
||||||
|
- Refreshes ranking data
|
||||||
|
|
||||||
|
### Quality Scoring Algorithm
|
||||||
|
|
||||||
|
```
|
||||||
|
Quality Score = Tier Score + Downloads Score + Stars Score
|
||||||
|
|
||||||
|
Where:
|
||||||
|
- Tier Score: rich = 0.6, minimal = 0.4
|
||||||
|
- Downloads Score: min(0.3, log10(downloads + 1) / 10)
|
||||||
|
- Stars Score: min(0.1, log10(githubStars + 1) / 10)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tool Categories
|
||||||
|
|
||||||
|
- AI/ML
|
||||||
|
- Development Tools
|
||||||
|
- Data Processing
|
||||||
|
- Web Scraping
|
||||||
|
- APIs & Integrations
|
||||||
|
- Utilities
|
||||||
|
- And more...
|
||||||
|
|
||||||
|
### Current Registry Stats
|
||||||
|
|
||||||
|
- **170+ Official Tools** in the ajax-collection
|
||||||
|
- **Growing Community Tools** published by developers
|
||||||
|
- **Real-time Sync** with npm registry
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tool Execution System
|
||||||
|
|
||||||
|
### Execution Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User Request → TPMJS API → Executor Selection → Sandboxed Execution → Response
|
||||||
|
```
|
||||||
|
|
||||||
|
### Executor Types
|
||||||
|
|
||||||
|
1. **HTTP Executor** - Calls external HTTP endpoints
|
||||||
|
2. **Serverless Executor** - Runs in Vercel Edge/Serverless
|
||||||
|
3. **Code Executor** - Executes arbitrary code in sandbox
|
||||||
|
|
||||||
|
### Sandboxing Features
|
||||||
|
|
||||||
|
- **Network Isolation** - Zero-trust or semi-trusted modes
|
||||||
|
- **Timeout Limits** - Configurable per-tool (1-900 seconds)
|
||||||
|
- **Resource Limits** - Memory and CPU constraints
|
||||||
|
- **Input Validation** - Zod schema validation
|
||||||
|
|
||||||
|
### Executor Template
|
||||||
|
|
||||||
|
The `templates/vercel-executor/` provides a ready-to-deploy executor:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Example executor implementation
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const { tool, input } = await request.json();
|
||||||
|
|
||||||
|
// Validate input against schema
|
||||||
|
const validated = toolSchema.parse(input);
|
||||||
|
|
||||||
|
// Execute tool logic
|
||||||
|
const result = await executeTool(tool, validated);
|
||||||
|
|
||||||
|
return Response.json(result);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Code Execution (via MCP Tool)
|
||||||
|
|
||||||
|
The platform includes a powerful code execution tool:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Execute code in 42+ languages
|
||||||
|
{
|
||||||
|
"language": "python",
|
||||||
|
"code": "print('Hello, World!')",
|
||||||
|
"network_mode": "zerotrust", // or "semitrusted"
|
||||||
|
"ttl": 60 // timeout in seconds
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported languages include:
|
||||||
|
- Python, JavaScript, TypeScript
|
||||||
|
- Go, Rust, C, C++
|
||||||
|
- Ruby, PHP, Perl
|
||||||
|
- Java, Kotlin, Scala
|
||||||
|
- And 30+ more
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MCP (Model Context Protocol) Implementation
|
||||||
|
|
||||||
|
### What is MCP?
|
||||||
|
|
||||||
|
MCP is an open protocol for AI assistants to interact with tools. TPMJS provides:
|
||||||
|
- **MCP Server Hosting** - Every collection is an MCP server
|
||||||
|
- **Multiple Transports** - HTTP and SSE support
|
||||||
|
- **Standard Compliance** - Full MCP specification support
|
||||||
|
|
||||||
|
### Transport Options
|
||||||
|
|
||||||
|
#### HTTP Transport
|
||||||
|
```
|
||||||
|
POST /api/mcp/{username}/{collection-slug}/http
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### SSE Transport
|
||||||
|
```
|
||||||
|
POST /api/mcp/{username}/{collection-slug}/sse
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{"jsonrpc": "2.0", "id": 1, "method": "initialize"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### MCP Methods Supported
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `initialize` | Initialize MCP session |
|
||||||
|
| `tools/list` | List available tools |
|
||||||
|
| `tools/call` | Execute a tool |
|
||||||
|
| `resources/list` | List available resources |
|
||||||
|
| `resources/read` | Read a resource |
|
||||||
|
| `prompts/list` | List available prompts |
|
||||||
|
| `prompts/get` | Get a specific prompt |
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
- **API Key Auth** - Bearer token in Authorization header
|
||||||
|
- **Session Auth** - Cookie-based for web users
|
||||||
|
- **Scopes** - Granular permission control
|
||||||
|
- `mcp:access` - Access MCP endpoints
|
||||||
|
- `mcp:execute` - Execute tools
|
||||||
|
- `tools:read` - List tools
|
||||||
|
- `tools:execute` - Execute specific tools
|
||||||
|
- `collections:read` - Access collections
|
||||||
|
|
||||||
|
### Integration Examples
|
||||||
|
|
||||||
|
#### Claude Desktop
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"tpmjs": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@anthropic/mcp-remote",
|
||||||
|
"https://tpmjs.com/api/mcp/ajax/ajax-collection/sse"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Cursor IDE
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"tpmjs": {
|
||||||
|
"url": "https://tpmjs.com/api/mcp/ajax/ajax-collection/sse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Collections System
|
||||||
|
|
||||||
|
### What are Collections?
|
||||||
|
|
||||||
|
Collections are curated groups of tools that form an MCP server. Users can:
|
||||||
|
- Create public or private collections
|
||||||
|
- Add tools from the registry
|
||||||
|
- Share collections as MCP endpoints
|
||||||
|
|
||||||
|
### Collection Features
|
||||||
|
|
||||||
|
- **Custom Naming** - Unique slug per user
|
||||||
|
- **Tool Curation** - Add/remove tools
|
||||||
|
- **Access Control** - Public or private
|
||||||
|
- **MCP Endpoint** - Automatic server generation
|
||||||
|
|
||||||
|
### Collection API
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Create collection
|
||||||
|
POST /api/collections
|
||||||
|
{ "name": "My Tools", "slug": "my-tools", "isPublic": true }
|
||||||
|
|
||||||
|
// Add tool to collection
|
||||||
|
POST /api/collections/{id}/tools
|
||||||
|
{ "toolId": "tool-123" }
|
||||||
|
|
||||||
|
// Get collection's MCP endpoint
|
||||||
|
GET /api/mcp/{username}/{collection-slug}/http
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Agent System
|
||||||
|
|
||||||
|
### What are TPMJS Agents?
|
||||||
|
|
||||||
|
Agents are AI-powered conversational interfaces with access to TPMJS tools. They enable:
|
||||||
|
- Multi-turn conversations
|
||||||
|
- Tool execution within context
|
||||||
|
- Custom system prompts
|
||||||
|
- Provider flexibility (OpenAI, Anthropic, etc.)
|
||||||
|
|
||||||
|
### Agent Configuration
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Agent {
|
||||||
|
id: string;
|
||||||
|
uid: string; // Unique identifier
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
provider: "OPENAI" | "ANTHROPIC" | "GOOGLE";
|
||||||
|
modelId: string; // e.g., "gpt-4o-mini"
|
||||||
|
systemPrompt?: string;
|
||||||
|
isPublic: boolean;
|
||||||
|
tools: Tool[]; // Attached tools
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Agent Features
|
||||||
|
|
||||||
|
1. **Multi-Turn Conversations**
|
||||||
|
- Persistent chat history
|
||||||
|
- Context-aware responses
|
||||||
|
- Tool execution in conversation
|
||||||
|
|
||||||
|
2. **Provider Flexibility**
|
||||||
|
- OpenAI (GPT-4, GPT-4o-mini)
|
||||||
|
- Anthropic (Claude)
|
||||||
|
- Google (Gemini)
|
||||||
|
- Custom providers
|
||||||
|
|
||||||
|
3. **Tool Integration**
|
||||||
|
- Attach any TPMJS tool
|
||||||
|
- Automatic tool calling
|
||||||
|
- Result injection into context
|
||||||
|
|
||||||
|
4. **Public Chat Pages**
|
||||||
|
- Share agents via public URL
|
||||||
|
- Embeddable chat interfaces
|
||||||
|
- No auth required for public agents
|
||||||
|
|
||||||
|
### Agent API
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Create agent
|
||||||
|
POST /api/agents
|
||||||
|
{ "name": "My Agent", "provider": "OPENAI", "modelId": "gpt-4o-mini" }
|
||||||
|
|
||||||
|
// Chat with agent
|
||||||
|
POST /api/agents/{id}/chat
|
||||||
|
{ "messages": [{"role": "user", "content": "Hello!"}] }
|
||||||
|
|
||||||
|
// Stream response
|
||||||
|
POST /api/agents/{id}/chat
|
||||||
|
{ "messages": [...], "stream": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Public Endpoints (No Auth)
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/api/health` | GET | Health check with build info |
|
||||||
|
| `/api/stats` | GET | Platform statistics |
|
||||||
|
| `/api/stats/health` | GET | Tool health metrics |
|
||||||
|
| `/api/tools` | GET | List public tools |
|
||||||
|
| `/api/tools/{id}` | GET | Get tool details |
|
||||||
|
| `/api/tools/search` | GET | Search tools |
|
||||||
|
| `/api/collections/public` | GET | List public collections |
|
||||||
|
|
||||||
|
### Authenticated Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/api/user` | GET | Current user profile |
|
||||||
|
| `/api/user/settings` | PATCH | Update user settings |
|
||||||
|
| `/api/user/api-keys` | GET/POST | Manage API keys |
|
||||||
|
| `/api/agents` | CRUD | Agent management |
|
||||||
|
| `/api/collections` | CRUD | Collection management |
|
||||||
|
|
||||||
|
### MCP Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/api/mcp/{user}/{collection}/http` | POST | HTTP transport |
|
||||||
|
| `/api/mcp/{user}/{collection}/sse` | POST | SSE transport |
|
||||||
|
| `/api/mcp/{user}/{collection}/http` | GET | Server info |
|
||||||
|
|
||||||
|
### Sync Endpoints (Cron)
|
||||||
|
|
||||||
|
| Endpoint | Schedule | Description |
|
||||||
|
|----------|----------|-------------|
|
||||||
|
| `/api/sync/changes` | */2 * * * * | npm changes feed |
|
||||||
|
| `/api/sync/keyword` | */15 * * * * | Keyword search |
|
||||||
|
| `/api/sync/metrics` | 0 * * * * | Metrics update |
|
||||||
|
|
||||||
|
### Tool Execution
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/api/tools/{id}/execute` | POST | Execute a tool |
|
||||||
|
| `/api/execute/code` | POST | Execute code (sandbox) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SDK & Packages
|
||||||
|
|
||||||
|
### Published npm Packages
|
||||||
|
|
||||||
|
| Package | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `@tpmjs/types` | TypeScript types and Zod schemas |
|
||||||
|
| `@tpmjs/ui` | React component library |
|
||||||
|
| `@tpmjs/utils` | Utility functions |
|
||||||
|
| `@tpmjs/env` | Environment validation |
|
||||||
|
|
||||||
|
### Type Definitions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Tool types
|
||||||
|
interface TpmjsTool {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
inputSchema: JSONSchema;
|
||||||
|
outputSchema?: JSONSchema;
|
||||||
|
executor?: string;
|
||||||
|
category?: string;
|
||||||
|
tags?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// MCP types
|
||||||
|
interface McpRequest {
|
||||||
|
jsonrpc: "2.0";
|
||||||
|
id: string | number;
|
||||||
|
method: string;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface McpResponse {
|
||||||
|
jsonrpc: "2.0";
|
||||||
|
id: string | number;
|
||||||
|
result?: unknown;
|
||||||
|
error?: McpError;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### UI Components
|
||||||
|
|
||||||
|
- Buttons, Cards, Badges
|
||||||
|
- Form inputs with validation
|
||||||
|
- Code editors with syntax highlighting
|
||||||
|
- Chat interfaces
|
||||||
|
- Tool cards and lists
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security & Privacy
|
||||||
|
|
||||||
|
### Authentication Methods
|
||||||
|
|
||||||
|
1. **GitHub OAuth** - Primary user auth
|
||||||
|
2. **API Keys** - Programmatic access
|
||||||
|
3. **Session Cookies** - Web auth
|
||||||
|
|
||||||
|
### API Key Security
|
||||||
|
|
||||||
|
- SHA-256 hashed storage
|
||||||
|
- Prefix-only display after creation
|
||||||
|
- Scoped permissions
|
||||||
|
- Optional expiration
|
||||||
|
- Revocation support
|
||||||
|
|
||||||
|
### Rate Limiting
|
||||||
|
|
||||||
|
- Per-user limits
|
||||||
|
- Per-IP limits
|
||||||
|
- Per-tool limits
|
||||||
|
- Customizable thresholds
|
||||||
|
|
||||||
|
### Data Privacy
|
||||||
|
|
||||||
|
- No tool input logging by default
|
||||||
|
- Optional usage analytics
|
||||||
|
- GDPR-compliant data handling
|
||||||
|
- User data export/deletion
|
||||||
|
|
||||||
|
### Sandbox Security
|
||||||
|
|
||||||
|
- Network isolation modes
|
||||||
|
- Resource limits
|
||||||
|
- No persistent storage
|
||||||
|
- Ephemeral execution
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Infrastructure
|
||||||
|
|
||||||
|
### Deployment Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Vercel │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
|
||||||
|
│ │ Edge │ │ Serverless │ │ Serverless │ │
|
||||||
|
│ │ Network │→ │ Functions │→ │ Executors │ │
|
||||||
|
│ │ (CDN) │ │ (API) │ │ (Tool Runners) │ │
|
||||||
|
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Neon PostgreSQL │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
|
||||||
|
│ │ Tools │ │ Users │ │ Collections │ │
|
||||||
|
│ │ Registry │ │ & Auth │ │ & Agents │ │
|
||||||
|
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Monitoring
|
||||||
|
|
||||||
|
- **Health Checks** - Every 5 minutes via GitHub Actions
|
||||||
|
- **Vercel Analytics** - Performance monitoring
|
||||||
|
- **Sync Logging** - All sync operations logged
|
||||||
|
- **Error Tracking** - Automatic error collection
|
||||||
|
|
||||||
|
### CI/CD Pipeline
|
||||||
|
|
||||||
|
1. **Pre-commit** - Lint, format, type-check (Lefthook)
|
||||||
|
2. **CI** - Full test suite (GitHub Actions)
|
||||||
|
3. **Deploy** - Automatic on merge (Vercel)
|
||||||
|
4. **Health Check** - Post-deploy verification
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use Cases
|
||||||
|
|
||||||
|
### For Developers
|
||||||
|
|
||||||
|
1. **Publish AI Tools**
|
||||||
|
- Package as npm module
|
||||||
|
- Add `tpmjs` keyword
|
||||||
|
- Automatically synced to registry
|
||||||
|
|
||||||
|
2. **Build Tool Collections**
|
||||||
|
- Curate tools for specific use cases
|
||||||
|
- Share as MCP endpoint
|
||||||
|
- Embed in applications
|
||||||
|
|
||||||
|
3. **Create AI Agents**
|
||||||
|
- Attach tools to agents
|
||||||
|
- Custom system prompts
|
||||||
|
- Deploy public chat interfaces
|
||||||
|
|
||||||
|
### For AI Applications
|
||||||
|
|
||||||
|
1. **Integrate Tools**
|
||||||
|
- Connect via MCP protocol
|
||||||
|
- Use any TPMJS collection
|
||||||
|
- Standard JSON-RPC interface
|
||||||
|
|
||||||
|
2. **Extend Capabilities**
|
||||||
|
- Web scraping, code execution
|
||||||
|
- API integrations
|
||||||
|
- Data processing
|
||||||
|
|
||||||
|
3. **Build Workflows**
|
||||||
|
- Chain multiple tools
|
||||||
|
- Agent-based automation
|
||||||
|
- Custom orchestration
|
||||||
|
|
||||||
|
### For Enterprises
|
||||||
|
|
||||||
|
1. **Private Tool Registry**
|
||||||
|
- Internal tools only
|
||||||
|
- Access control
|
||||||
|
- Usage analytics
|
||||||
|
|
||||||
|
2. **Secure Execution**
|
||||||
|
- Sandboxed environments
|
||||||
|
- Audit logging
|
||||||
|
- Compliance ready
|
||||||
|
|
||||||
|
3. **Custom Agents**
|
||||||
|
- Brand-specific AI assistants
|
||||||
|
- Internal knowledge access
|
||||||
|
- Tool-enabled support
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Competitive Advantages
|
||||||
|
|
||||||
|
### vs. Building Custom MCP Servers
|
||||||
|
|
||||||
|
| TPMJS | Custom MCP Server |
|
||||||
|
|-------|-------------------|
|
||||||
|
| Instant setup | Days/weeks of development |
|
||||||
|
| 170+ tools ready | Build each tool |
|
||||||
|
| Hosted infrastructure | Self-hosted required |
|
||||||
|
| Automatic scaling | Manual scaling |
|
||||||
|
|
||||||
|
### vs. Other Tool Platforms
|
||||||
|
|
||||||
|
| Feature | TPMJS | Competitors |
|
||||||
|
|---------|-------|-------------|
|
||||||
|
| Open Protocol (MCP) | ✅ | Often proprietary |
|
||||||
|
| npm Integration | ✅ | Custom registries |
|
||||||
|
| Self-hostable | ✅ | Usually SaaS-only |
|
||||||
|
| Code Execution | ✅ | Limited |
|
||||||
|
| Agent System | ✅ | Separate product |
|
||||||
|
|
||||||
|
### Unique Features
|
||||||
|
|
||||||
|
1. **npm-Native** - Tools are just npm packages
|
||||||
|
2. **MCP-First** - Built on open standard
|
||||||
|
3. **Hybrid Execution** - Local + cloud options
|
||||||
|
4. **Collection System** - Curated tool sets
|
||||||
|
5. **Agent Platform** - Full conversational AI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix: Official Tools Collection
|
||||||
|
|
||||||
|
The `ajax-collection` includes 170+ tools across categories:
|
||||||
|
|
||||||
|
### Web & Data
|
||||||
|
- `firecrawl-aisdk` - Web crawling and extraction
|
||||||
|
- `tpmjs-tools-page-brief` - Page summarization
|
||||||
|
- `tpmjs-tools-search` - Web search
|
||||||
|
|
||||||
|
### Development
|
||||||
|
- `tpmjs-unsandbox` - Code execution (42+ languages)
|
||||||
|
- `tpmjs-tools-toc-generate` - Markdown TOC generator
|
||||||
|
- `tpmjs-tools-changelog-entry` - Changelog generation
|
||||||
|
|
||||||
|
### Content
|
||||||
|
- `tpmjs-createblogpost` - Blog post creation
|
||||||
|
- `tpmjs-tools-recipe-hash` - Recipe/workflow hashing
|
||||||
|
- `tpmjs-tools-workflow-variant-generate` - Workflow variations
|
||||||
|
|
||||||
|
### And Many More...
|
||||||
|
- API integrations
|
||||||
|
- Data transformations
|
||||||
|
- File processing
|
||||||
|
- Image manipulation
|
||||||
|
- Text analysis
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
TPMJS is a comprehensive platform for AI tool discovery, execution, and orchestration. Key takeaways:
|
||||||
|
|
||||||
|
1. **Registry** - npm-native tool discovery with automatic syncing
|
||||||
|
2. **Execution** - Secure, sandboxed tool running
|
||||||
|
3. **MCP** - Standard protocol for AI integration
|
||||||
|
4. **Collections** - Curated tool sets as MCP servers
|
||||||
|
5. **Agents** - Conversational AI with tool access
|
||||||
|
6. **Infrastructure** - Production-ready, scalable, monitored
|
||||||
|
|
||||||
|
The platform enables developers to publish tools, AI applications to consume them, and enterprises to build secure, tool-enabled AI experiences.
|
||||||
67
alien.sh
67
alien.sh
|
|
@ -1,67 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
|
|
||||||
printf "\n\033[1m WALKING DOWN\033[0m\n\n"
|
|
||||||
printf " Frame 1 Frame 2 Frame 3 Frame 4\n\n"
|
|
||||||
printf " \033[38;2;139;90;43m█\033[0m \033[38;2;139;90;43m█\033[0m \033[38;2;139;90;43m█\033[0m \033[38;2;139;90;43m█\033[0m \033[38;2;139;90;43m█\033[0m \033[38;2;139;90;43m█\033[0m \033[38;2;139;90;43m█\033[0m \033[38;2;139;90;43m█\033[0m\n"
|
|
||||||
printf " \033[38;2;139;90;43m█\033[0m \033[38;2;139;90;43m█\033[0m \033[38;2;139;90;43m█\033[0m \033[38;2;139;90;43m█\033[0m \033[38;2;139;90;43m█\033[0m \033[38;2;139;90;43m█\033[0m \033[38;2;139;90;43m█\033[0m \033[38;2;139;90;43m█\033[0m\n"
|
|
||||||
printf " \033[38;2;255;200;50m▄███▄\033[0m \033[38;2;255;200;50m▄███▄\033[0m \033[38;2;255;200;50m▄███▄\033[0m \033[38;2;255;200;50m▄███▄\033[0m\n"
|
|
||||||
printf " \033[38;2;255;200;50m███████\033[0m \033[38;2;255;200;50m███████\033[0m \033[38;2;255;200;50m███████\033[0m \033[38;2;255;200;50m███████\033[0m\n"
|
|
||||||
printf " \033[38;2;255;200;50m█\033[38;2;0;0;0m██\033[38;2;255;200;50m█\033[38;2;0;0;0m██\033[38;2;255;200;50m█\033[0m \033[38;2;255;200;50m█\033[38;2;0;0;0m██\033[38;2;255;200;50m█\033[38;2;0;0;0m██\033[38;2;255;200;50m█\033[0m \033[38;2;255;200;50m█\033[38;2;0;0;0m██\033[38;2;255;200;50m█\033[38;2;0;0;0m██\033[38;2;255;200;50m█\033[0m \033[38;2;255;200;50m█\033[38;2;0;0;0m██\033[38;2;255;200;50m█\033[38;2;0;0;0m██\033[38;2;255;200;50m█\033[0m\n"
|
|
||||||
printf " \033[38;2;255;200;50m█████\033[0m \033[38;2;255;200;50m█████\033[0m \033[38;2;255;200;50m█████\033[0m \033[38;2;255;200;50m█████\033[0m\n"
|
|
||||||
printf " \033[38;2;255;200;50m███\033[0m \033[38;2;255;200;50m███\033[0m \033[38;2;255;200;50m███\033[0m \033[38;2;255;200;50m███\033[0m\n"
|
|
||||||
printf " \033[38;2;139;90;43m▄█████▄\033[0m \033[38;2;139;90;43m▄█████▄\033[0m \033[38;2;139;90;43m▄█████▄\033[0m \033[38;2;139;90;43m▄█████▄\033[0m\n"
|
|
||||||
printf " \033[38;2;139;90;43m███\033[38;2;255;215;0m█\033[38;2;139;90;43m███\033[0m \033[38;2;139;90;43m███\033[38;2;255;215;0m█\033[38;2;139;90;43m███\033[0m \033[38;2;139;90;43m███\033[38;2;255;215;0m█\033[38;2;139;90;43m███\033[0m \033[38;2;139;90;43m███\033[38;2;255;215;0m█\033[38;2;139;90;43m███\033[0m\n"
|
|
||||||
printf " \033[38;2;139;90;43m███████\033[0m \033[38;2;139;90;43m███████\033[0m \033[38;2;139;90;43m███████\033[0m \033[38;2;139;90;43m███████\033[0m\n"
|
|
||||||
printf " \033[38;2;139;90;43m█████\033[0m \033[38;2;139;90;43m█████\033[0m \033[38;2;139;90;43m█████\033[0m \033[38;2;139;90;43m█████\033[0m\n"
|
|
||||||
printf " \033[38;2;255;200;50m█\033[0m \033[38;2;255;200;50m█\033[0m \033[38;2;255;200;50m█\033[0m \033[38;2;255;200;50m█\033[0m \033[38;2;255;200;50m█\033[0m \033[38;2;255;200;50m█\033[0m \033[38;2;255;200;50m█\033[0m \033[38;2;255;200;50m█\033[0m\n"
|
|
||||||
printf " \033[38;2;101;67;33m██\033[0m \033[38;2;101;67;33m██\033[0m \033[38;2;101;67;33m██\033[0m \033[38;2;101;67;33m█\033[0m \033[38;2;101;67;33m██\033[0m \033[38;2;101;67;33m██\033[0m \033[38;2;101;67;33m█\033[0m \033[38;2;101;67;33m██\033[0m\n"
|
|
||||||
|
|
||||||
printf "\n\033[1m WALKING UP\033[0m\n\n"
|
|
||||||
printf " Frame 1 Frame 2 Frame 3 Frame 4\n\n"
|
|
||||||
printf " \033[38;2;139;90;43m█\033[0m \033[38;2;139;90;43m█\033[0m \033[38;2;139;90;43m█\033[0m \033[38;2;139;90;43m█\033[0m \033[38;2;139;90;43m█\033[0m \033[38;2;139;90;43m█\033[0m \033[38;2;139;90;43m█\033[0m \033[38;2;139;90;43m█\033[0m\n"
|
|
||||||
printf " \033[38;2;139;90;43m█\033[0m \033[38;2;139;90;43m█\033[0m \033[38;2;139;90;43m█\033[0m \033[38;2;139;90;43m█\033[0m \033[38;2;139;90;43m█\033[0m \033[38;2;139;90;43m█\033[0m \033[38;2;139;90;43m█\033[0m \033[38;2;139;90;43m█\033[0m\n"
|
|
||||||
printf " \033[38;2;255;200;50m▄███▄\033[0m \033[38;2;255;200;50m▄███▄\033[0m \033[38;2;255;200;50m▄███▄\033[0m \033[38;2;255;200;50m▄███▄\033[0m\n"
|
|
||||||
printf " \033[38;2;255;200;50m███████\033[0m \033[38;2;255;200;50m███████\033[0m \033[38;2;255;200;50m███████\033[0m \033[38;2;255;200;50m███████\033[0m\n"
|
|
||||||
printf " \033[38;2;255;200;50m███████\033[0m \033[38;2;255;200;50m███████\033[0m \033[38;2;255;200;50m███████\033[0m \033[38;2;255;200;50m███████\033[0m\n"
|
|
||||||
printf " \033[38;2;255;200;50m█████\033[0m \033[38;2;255;200;50m█████\033[0m \033[38;2;255;200;50m█████\033[0m \033[38;2;255;200;50m█████\033[0m\n"
|
|
||||||
printf " \033[38;2;255;200;50m███\033[0m \033[38;2;255;200;50m███\033[0m \033[38;2;255;200;50m███\033[0m \033[38;2;255;200;50m███\033[0m\n"
|
|
||||||
printf " \033[38;2;139;90;43m▄█████▄\033[0m \033[38;2;139;90;43m▄█████▄\033[0m \033[38;2;139;90;43m▄█████▄\033[0m \033[38;2;139;90;43m▄█████▄\033[0m\n"
|
|
||||||
printf " \033[38;2;139;90;43m███████\033[0m \033[38;2;139;90;43m███████\033[0m \033[38;2;139;90;43m███████\033[0m \033[38;2;139;90;43m███████\033[0m\n"
|
|
||||||
printf " \033[38;2;139;90;43m███████\033[0m \033[38;2;139;90;43m███████\033[0m \033[38;2;139;90;43m███████\033[0m \033[38;2;139;90;43m███████\033[0m\n"
|
|
||||||
printf " \033[38;2;139;90;43m█████\033[0m \033[38;2;139;90;43m█████\033[0m \033[38;2;139;90;43m█████\033[0m \033[38;2;139;90;43m█████\033[0m\n"
|
|
||||||
printf " \033[38;2;255;200;50m█\033[0m \033[38;2;255;200;50m█\033[0m \033[38;2;255;200;50m█\033[0m \033[38;2;255;200;50m█\033[0m \033[38;2;255;200;50m█\033[0m \033[38;2;255;200;50m█\033[0m \033[38;2;255;200;50m█\033[0m \033[38;2;255;200;50m█\033[0m\n"
|
|
||||||
printf " \033[38;2;101;67;33m██\033[0m \033[38;2;101;67;33m██\033[0m \033[38;2;101;67;33m██\033[0m \033[38;2;101;67;33m█\033[0m \033[38;2;101;67;33m██\033[0m \033[38;2;101;67;33m██\033[0m \033[38;2;101;67;33m█\033[0m \033[38;2;101;67;33m██\033[0m\n"
|
|
||||||
|
|
||||||
printf "\n\033[1m WALKING LEFT\033[0m\n\n"
|
|
||||||
printf " Frame 1 Frame 2 Frame 3 Frame 4\n\n"
|
|
||||||
printf " \033[38;2;139;90;43m█\033[0m \033[38;2;139;90;43m█\033[0m \033[38;2;139;90;43m█\033[0m \033[38;2;139;90;43m█\033[0m\n"
|
|
||||||
printf " \033[38;2;139;90;43m█\033[0m \033[38;2;139;90;43m█\033[0m \033[38;2;139;90;43m█\033[0m \033[38;2;139;90;43m█\033[0m\n"
|
|
||||||
printf " \033[38;2;255;200;50m▄███\033[0m \033[38;2;255;200;50m▄███\033[0m \033[38;2;255;200;50m▄███\033[0m \033[38;2;255;200;50m▄███\033[0m\n"
|
|
||||||
printf " \033[38;2;255;200;50m█████\033[0m \033[38;2;255;200;50m█████\033[0m \033[38;2;255;200;50m█████\033[0m \033[38;2;255;200;50m█████\033[0m\n"
|
|
||||||
printf " \033[38;2;0;0;0m██\033[38;2;255;200;50m███\033[0m \033[38;2;0;0;0m██\033[38;2;255;200;50m███\033[0m \033[38;2;0;0;0m██\033[38;2;255;200;50m███\033[0m \033[38;2;0;0;0m██\033[38;2;255;200;50m███\033[0m\n"
|
|
||||||
printf " \033[38;2;255;200;50m████\033[0m \033[38;2;255;200;50m████\033[0m \033[38;2;255;200;50m████\033[0m \033[38;2;255;200;50m████\033[0m\n"
|
|
||||||
printf " \033[38;2;255;200;50m██\033[0m \033[38;2;255;200;50m██\033[0m \033[38;2;255;200;50m██\033[0m \033[38;2;255;200;50m██\033[0m\n"
|
|
||||||
printf " \033[38;2;139;90;43m████\033[0m \033[38;2;139;90;43m████\033[0m \033[38;2;139;90;43m████\033[0m \033[38;2;139;90;43m████\033[0m\n"
|
|
||||||
printf " \033[38;2;139;90;43m██\033[38;2;255;215;0m█\033[38;2;139;90;43m██\033[0m \033[38;2;139;90;43m██\033[38;2;255;215;0m█\033[38;2;139;90;43m██\033[0m \033[38;2;139;90;43m██\033[38;2;255;215;0m█\033[38;2;139;90;43m██\033[0m \033[38;2;139;90;43m██\033[38;2;255;215;0m█\033[38;2;139;90;43m██\033[0m\n"
|
|
||||||
printf " \033[38;2;139;90;43m████\033[0m \033[38;2;139;90;43m████\033[0m \033[38;2;139;90;43m████\033[0m \033[38;2;139;90;43m████\033[0m\n"
|
|
||||||
printf " \033[38;2;139;90;43m██\033[0m \033[38;2;139;90;43m██\033[0m \033[38;2;139;90;43m██\033[0m \033[38;2;139;90;43m██\033[0m\n"
|
|
||||||
printf " \033[38;2;255;200;50m██\033[0m \033[38;2;255;200;50m█\033[0m \033[38;2;255;200;50m█\033[0m \033[38;2;255;200;50m██\033[0m \033[38;2;255;200;50m█\033[0m \033[38;2;255;200;50m█\033[0m\n"
|
|
||||||
printf " \033[38;2;101;67;33m██\033[0m \033[38;2;101;67;33m█\033[0m \033[38;2;101;67;33m█\033[0m \033[38;2;101;67;33m██\033[0m \033[38;2;101;67;33m██\033[0m \033[38;2;101;67;33m█\033[0m \033[38;2;101;67;33m█\033[0m \033[38;2;101;67;33m██\033[0m\n"
|
|
||||||
|
|
||||||
printf "\n\033[1m WALKING RIGHT\033[0m\n\n"
|
|
||||||
printf " Frame 1 Frame 2 Frame 3 Frame 4\n\n"
|
|
||||||
printf " \033[38;2;139;90;43m█\033[0m \033[38;2;139;90;43m█\033[0m \033[38;2;139;90;43m█\033[0m \033[38;2;139;90;43m█\033[0m\n"
|
|
||||||
printf " \033[38;2;139;90;43m█\033[0m \033[38;2;139;90;43m█\033[0m \033[38;2;139;90;43m█\033[0m \033[38;2;139;90;43m█\033[0m\n"
|
|
||||||
printf " \033[38;2;255;200;50m███▄\033[0m \033[38;2;255;200;50m███▄\033[0m \033[38;2;255;200;50m███▄\033[0m \033[38;2;255;200;50m███▄\033[0m\n"
|
|
||||||
printf " \033[38;2;255;200;50m█████\033[0m \033[38;2;255;200;50m█████\033[0m \033[38;2;255;200;50m█████\033[0m \033[38;2;255;200;50m█████\033[0m\n"
|
|
||||||
printf " \033[38;2;255;200;50m███\033[38;2;0;0;0m██\033[0m \033[38;2;255;200;50m███\033[38;2;0;0;0m██\033[0m \033[38;2;255;200;50m███\033[38;2;0;0;0m██\033[0m \033[38;2;255;200;50m███\033[38;2;0;0;0m██\033[0m\n"
|
|
||||||
printf " \033[38;2;255;200;50m████\033[0m \033[38;2;255;200;50m████\033[0m \033[38;2;255;200;50m████\033[0m \033[38;2;255;200;50m████\033[0m\n"
|
|
||||||
printf " \033[38;2;255;200;50m██\033[0m \033[38;2;255;200;50m██\033[0m \033[38;2;255;200;50m██\033[0m \033[38;2;255;200;50m██\033[0m\n"
|
|
||||||
printf " \033[38;2;139;90;43m████\033[0m \033[38;2;139;90;43m████\033[0m \033[38;2;139;90;43m████\033[0m \033[38;2;139;90;43m████\033[0m\n"
|
|
||||||
printf " \033[38;2;139;90;43m██\033[38;2;255;215;0m█\033[38;2;139;90;43m██\033[0m \033[38;2;139;90;43m██\033[38;2;255;215;0m█\033[38;2;139;90;43m██\033[0m \033[38;2;139;90;43m██\033[38;2;255;215;0m█\033[38;2;139;90;43m██\033[0m \033[38;2;139;90;43m██\033[38;2;255;215;0m█\033[38;2;139;90;43m██\033[0m\n"
|
|
||||||
printf " \033[38;2;139;90;43m████\033[0m \033[38;2;139;90;43m████\033[0m \033[38;2;139;90;43m████\033[0m \033[38;2;139;90;43m████\033[0m\n"
|
|
||||||
printf " \033[38;2;139;90;43m██\033[0m \033[38;2;139;90;43m██\033[0m \033[38;2;139;90;43m██\033[0m \033[38;2;139;90;43m██\033[0m\n"
|
|
||||||
printf " \033[38;2;255;200;50m██\033[0m \033[38;2;255;200;50m█\033[0m \033[38;2;255;200;50m█\033[0m \033[38;2;255;200;50m██\033[0m \033[38;2;255;200;50m█\033[0m \033[38;2;255;200;50m█\033[0m\n"
|
|
||||||
printf " \033[38;2;101;67;33m█\033[0m \033[38;2;101;67;33m██\033[0m \033[38;2;101;67;33m██\033[0m \033[38;2;101;67;33m█\033[0m \033[38;2;101;67;33m█\033[0m \033[38;2;101;67;33m██\033[0m \033[38;2;101;67;33m██\033[0m \033[38;2;101;67;33m█\033[0m\n"
|
|
||||||
|
|
||||||
printf "\n"
|
|
||||||
1
apps/omega-mac/.gitignore
vendored
Normal file
1
apps/omega-mac/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
.build
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"colors": [
|
||||||
|
{
|
||||||
|
"color": {
|
||||||
|
"color-space": "srgb",
|
||||||
|
"components": {
|
||||||
|
"alpha": "1.000",
|
||||||
|
"blue": "0.996",
|
||||||
|
"green": "0.475",
|
||||||
|
"red": "0.325"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom": "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info": {
|
||||||
|
"author": "xcode",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
{
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"idiom": "mac",
|
||||||
|
"scale": "1x",
|
||||||
|
"size": "16x16"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "mac",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "16x16"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "mac",
|
||||||
|
"scale": "1x",
|
||||||
|
"size": "32x32"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "mac",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "32x32"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "mac",
|
||||||
|
"scale": "1x",
|
||||||
|
"size": "128x128"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "mac",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "128x128"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "mac",
|
||||||
|
"scale": "1x",
|
||||||
|
"size": "256x256"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "mac",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "256x256"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "mac",
|
||||||
|
"scale": "1x",
|
||||||
|
"size": "512x512"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "mac",
|
||||||
|
"scale": "2x",
|
||||||
|
"size": "512x512"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info": {
|
||||||
|
"author": "xcode",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
6
apps/omega-mac/OmegaMac/Assets.xcassets/Contents.json
Normal file
6
apps/omega-mac/OmegaMac/Assets.xcassets/Contents.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"info": {
|
||||||
|
"author": "xcode",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
41
apps/omega-mac/OmegaMac/Models/Conversation.swift
Normal file
41
apps/omega-mac/OmegaMac/Models/Conversation.swift
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
@Model
|
||||||
|
final class Conversation {
|
||||||
|
var id: UUID
|
||||||
|
var title: String?
|
||||||
|
var createdAt: Date
|
||||||
|
var updatedAt: Date
|
||||||
|
var executionState: String // "idle" | "running"
|
||||||
|
var inputTokensTotal: Int
|
||||||
|
var outputTokensTotal: Int
|
||||||
|
|
||||||
|
@Relationship(deleteRule: .cascade, inverse: \Message.conversation)
|
||||||
|
var messages: [Message]
|
||||||
|
|
||||||
|
@Relationship(deleteRule: .cascade, inverse: \ToolCallRecord.conversation)
|
||||||
|
var toolRuns: [ToolCallRecord]
|
||||||
|
|
||||||
|
init(
|
||||||
|
title: String? = nil
|
||||||
|
) {
|
||||||
|
self.id = UUID()
|
||||||
|
self.title = title
|
||||||
|
self.createdAt = Date()
|
||||||
|
self.updatedAt = Date()
|
||||||
|
self.executionState = "idle"
|
||||||
|
self.inputTokensTotal = 0
|
||||||
|
self.outputTokensTotal = 0
|
||||||
|
self.messages = []
|
||||||
|
self.toolRuns = []
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayTitle: String {
|
||||||
|
title ?? "New Conversation"
|
||||||
|
}
|
||||||
|
|
||||||
|
var sortedMessages: [Message] {
|
||||||
|
messages.sorted { $0.createdAt < $1.createdAt }
|
||||||
|
}
|
||||||
|
}
|
||||||
18
apps/omega-mac/OmegaMac/Models/EnvVar.swift
Normal file
18
apps/omega-mac/OmegaMac/Models/EnvVar.swift
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
@Model
|
||||||
|
final class EnvVar {
|
||||||
|
var id: UUID
|
||||||
|
var keyName: String
|
||||||
|
/// Last 4 characters of the value (for display hint)
|
||||||
|
var valueHint: String
|
||||||
|
var createdAt: Date
|
||||||
|
|
||||||
|
init(keyName: String, valueHint: String) {
|
||||||
|
self.id = UUID()
|
||||||
|
self.keyName = keyName
|
||||||
|
self.valueHint = valueHint
|
||||||
|
self.createdAt = Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
141
apps/omega-mac/OmegaMac/Models/Message.swift
Normal file
141
apps/omega-mac/OmegaMac/Models/Message.swift
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
enum MessageRole: String, Codable {
|
||||||
|
case user = "USER"
|
||||||
|
case assistant = "ASSISTANT"
|
||||||
|
case tool = "TOOL"
|
||||||
|
case system = "SYSTEM"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Model
|
||||||
|
final class Message {
|
||||||
|
var id: UUID
|
||||||
|
var role: MessageRole
|
||||||
|
var content: String
|
||||||
|
var createdAt: Date
|
||||||
|
var inputTokens: Int?
|
||||||
|
var outputTokens: Int?
|
||||||
|
|
||||||
|
/// JSON-encoded array of tool calls (for assistant messages)
|
||||||
|
var toolCallsJSON: Data?
|
||||||
|
|
||||||
|
var conversation: Conversation?
|
||||||
|
|
||||||
|
init(
|
||||||
|
role: MessageRole,
|
||||||
|
content: String,
|
||||||
|
conversation: Conversation? = nil,
|
||||||
|
inputTokens: Int? = nil,
|
||||||
|
outputTokens: Int? = nil,
|
||||||
|
toolCalls: [ToolCallData]? = nil
|
||||||
|
) {
|
||||||
|
self.id = UUID()
|
||||||
|
self.role = role
|
||||||
|
self.content = content
|
||||||
|
self.createdAt = Date()
|
||||||
|
self.inputTokens = inputTokens
|
||||||
|
self.outputTokens = outputTokens
|
||||||
|
self.conversation = conversation
|
||||||
|
if let toolCalls {
|
||||||
|
self.toolCallsJSON = try? JSONEncoder().encode(toolCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var toolCalls: [ToolCallData] {
|
||||||
|
get {
|
||||||
|
guard let data = toolCallsJSON else { return [] }
|
||||||
|
return (try? JSONDecoder().decode([ToolCallData].self, from: data)) ?? []
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
toolCallsJSON = try? JSONEncoder().encode(newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serializable tool call data stored in messages
|
||||||
|
struct ToolCallData: Codable, Identifiable {
|
||||||
|
var id: String { toolCallId }
|
||||||
|
let toolCallId: String
|
||||||
|
let toolName: String
|
||||||
|
let args: JSONValue?
|
||||||
|
let output: JSONValue?
|
||||||
|
|
||||||
|
init(toolCallId: String, toolName: String, args: JSONValue? = nil, output: JSONValue? = nil) {
|
||||||
|
self.toolCallId = toolCallId
|
||||||
|
self.toolName = toolName
|
||||||
|
self.args = args
|
||||||
|
self.output = output
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A type-erased JSON value for encoding/decoding arbitrary JSON
|
||||||
|
enum JSONValue: Codable, Equatable, Sendable {
|
||||||
|
case string(String)
|
||||||
|
case number(Double)
|
||||||
|
case bool(Bool)
|
||||||
|
case object([String: JSONValue])
|
||||||
|
case array([JSONValue])
|
||||||
|
case null
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.singleValueContainer()
|
||||||
|
if container.decodeNil() {
|
||||||
|
self = .null
|
||||||
|
} else if let b = try? container.decode(Bool.self) {
|
||||||
|
self = .bool(b)
|
||||||
|
} else if let n = try? container.decode(Double.self) {
|
||||||
|
self = .number(n)
|
||||||
|
} else if let s = try? container.decode(String.self) {
|
||||||
|
self = .string(s)
|
||||||
|
} else if let arr = try? container.decode([JSONValue].self) {
|
||||||
|
self = .array(arr)
|
||||||
|
} else if let obj = try? container.decode([String: JSONValue].self) {
|
||||||
|
self = .object(obj)
|
||||||
|
} else {
|
||||||
|
self = .null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.singleValueContainer()
|
||||||
|
switch self {
|
||||||
|
case .string(let s): try container.encode(s)
|
||||||
|
case .number(let n): try container.encode(n)
|
||||||
|
case .bool(let b): try container.encode(b)
|
||||||
|
case .object(let o): try container.encode(o)
|
||||||
|
case .array(let a): try container.encode(a)
|
||||||
|
case .null: try container.encodeNil()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert any Codable/Sendable value to JSONValue
|
||||||
|
static func from(_ value: Any) -> JSONValue {
|
||||||
|
if let s = value as? String { return .string(s) }
|
||||||
|
if let n = value as? NSNumber {
|
||||||
|
if CFBooleanGetTypeID() == CFGetTypeID(n) {
|
||||||
|
return .bool(n.boolValue)
|
||||||
|
}
|
||||||
|
return .number(n.doubleValue)
|
||||||
|
}
|
||||||
|
if let b = value as? Bool { return .bool(b) }
|
||||||
|
if let i = value as? Int { return .number(Double(i)) }
|
||||||
|
if let d = value as? Double { return .number(d) }
|
||||||
|
if let arr = value as? [Any] { return .array(arr.map { from($0) }) }
|
||||||
|
if let obj = value as? [String: Any] {
|
||||||
|
return .object(obj.mapValues { from($0) })
|
||||||
|
}
|
||||||
|
return .null
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pretty-print JSON
|
||||||
|
var prettyString: String {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
guard let data = try? encoder.encode(self),
|
||||||
|
let str = String(data: data, encoding: .utf8) else {
|
||||||
|
return "null"
|
||||||
|
}
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
}
|
||||||
51
apps/omega-mac/OmegaMac/Models/ToolCallRecord.swift
Normal file
51
apps/omega-mac/OmegaMac/Models/ToolCallRecord.swift
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
@Model
|
||||||
|
final class ToolCallRecord {
|
||||||
|
var id: UUID
|
||||||
|
var toolName: String
|
||||||
|
var toolCallId: String
|
||||||
|
var status: String // "running" | "success" | "error"
|
||||||
|
var inputJSON: Data?
|
||||||
|
var outputJSON: Data?
|
||||||
|
var errorMessage: String?
|
||||||
|
var executionTimeMs: Int?
|
||||||
|
var createdAt: Date
|
||||||
|
var completedAt: Date?
|
||||||
|
|
||||||
|
var conversation: Conversation?
|
||||||
|
|
||||||
|
init(
|
||||||
|
toolName: String,
|
||||||
|
toolCallId: String,
|
||||||
|
conversation: Conversation? = nil
|
||||||
|
) {
|
||||||
|
self.id = UUID()
|
||||||
|
self.toolName = toolName
|
||||||
|
self.toolCallId = toolCallId
|
||||||
|
self.status = "running"
|
||||||
|
self.createdAt = Date()
|
||||||
|
self.conversation = conversation
|
||||||
|
}
|
||||||
|
|
||||||
|
var input: JSONValue? {
|
||||||
|
get {
|
||||||
|
guard let data = inputJSON else { return nil }
|
||||||
|
return try? JSONDecoder().decode(JSONValue.self, from: data)
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
inputJSON = try? JSONEncoder().encode(newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var output: JSONValue? {
|
||||||
|
get {
|
||||||
|
guard let data = outputJSON else { return nil }
|
||||||
|
return try? JSONDecoder().decode(JSONValue.self, from: data)
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
outputJSON = try? JSONEncoder().encode(newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
apps/omega-mac/OmegaMac/Models/UserSettings.swift
Normal file
17
apps/omega-mac/OmegaMac/Models/UserSettings.swift
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
@Model
|
||||||
|
final class UserSettings {
|
||||||
|
var id: UUID
|
||||||
|
var systemPrompt: String?
|
||||||
|
var selectedModel: String
|
||||||
|
var pinnedToolIds: [String]
|
||||||
|
|
||||||
|
init() {
|
||||||
|
self.id = UUID()
|
||||||
|
self.systemPrompt = nil
|
||||||
|
self.selectedModel = "gpt-4.1-mini"
|
||||||
|
self.pinnedToolIds = []
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/omega-mac/OmegaMac/OmegaMac.entitlements
Normal file
14
apps/omega-mac/OmegaMac/OmegaMac.entitlements
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.app-sandbox</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.network.client</key>
|
||||||
|
<true/>
|
||||||
|
<key>keychain-access-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>$(AppIdentifierPrefix)com.tpmjs.omega-mac</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
44
apps/omega-mac/OmegaMac/OmegaMacApp.swift
Normal file
44
apps/omega-mac/OmegaMac/OmegaMacApp.swift
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import SwiftData
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct OmegaMacApp: App {
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
ContentView()
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
|
}
|
||||||
|
.modelContainer(for: [
|
||||||
|
Conversation.self,
|
||||||
|
Message.self,
|
||||||
|
ToolCallRecord.self,
|
||||||
|
EnvVar.self,
|
||||||
|
UserSettings.self,
|
||||||
|
])
|
||||||
|
.defaultSize(width: 1100, height: 750)
|
||||||
|
.commands {
|
||||||
|
CommandGroup(replacing: .newItem) {
|
||||||
|
Button("New Conversation") {
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: .newConversation, object: nil)
|
||||||
|
}
|
||||||
|
.keyboardShortcut("n", modifiers: .command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
Settings {
|
||||||
|
SettingsView()
|
||||||
|
.modelContainer(for: [
|
||||||
|
EnvVar.self,
|
||||||
|
UserSettings.self,
|
||||||
|
])
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Notification.Name {
|
||||||
|
static let newConversation = Notification.Name("newConversation")
|
||||||
|
}
|
||||||
613
apps/omega-mac/OmegaMac/Services/ChatOrchestrator.swift
Normal file
613
apps/omega-mac/OmegaMac/Services/ChatOrchestrator.swift
Normal file
|
|
@ -0,0 +1,613 @@
|
||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
/// Represents a live tool call being displayed during streaming
|
||||||
|
struct LiveToolCall: Identifiable, Sendable {
|
||||||
|
let id: String // toolCallId
|
||||||
|
let toolName: String
|
||||||
|
var arguments: String
|
||||||
|
var status: String // "running" | "success" | "error"
|
||||||
|
var output: JSONValue?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Main orchestrator for the Omega agentic chat loop.
|
||||||
|
/// Coordinates between OpenAI, TPMJS registry, and SwiftData persistence.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class ChatOrchestrator {
|
||||||
|
// MARK: - Published State
|
||||||
|
|
||||||
|
var streamingContent: String = ""
|
||||||
|
var isStreaming: Bool = false
|
||||||
|
var liveToolCalls: [LiveToolCall] = []
|
||||||
|
var error: String?
|
||||||
|
|
||||||
|
// MARK: - Private State
|
||||||
|
|
||||||
|
private let openAI = OpenAIService()
|
||||||
|
private let registry = TPMJSRegistryService()
|
||||||
|
|
||||||
|
/// Dynamically loaded tools for the current conversation (sanitizedName -> ToolMeta)
|
||||||
|
private var loadedTools: [String: ToolMeta] = [:]
|
||||||
|
|
||||||
|
/// Maximum agentic loop iterations (search -> execute -> respond)
|
||||||
|
private let maxIterations = 10
|
||||||
|
|
||||||
|
// MARK: - Public API
|
||||||
|
|
||||||
|
/// Send a user message and run the full agentic loop.
|
||||||
|
/// Streams the response, handles tool calls, and persists everything to SwiftData.
|
||||||
|
func sendMessage(
|
||||||
|
_ text: String,
|
||||||
|
conversation: Conversation,
|
||||||
|
modelContext: ModelContext
|
||||||
|
) async {
|
||||||
|
// Reset state
|
||||||
|
streamingContent = ""
|
||||||
|
isStreaming = true
|
||||||
|
liveToolCalls = []
|
||||||
|
error = nil
|
||||||
|
|
||||||
|
// Get API key
|
||||||
|
guard let apiKey = KeychainService.load(key: "OPENAI_API_KEY"), !apiKey.isEmpty else {
|
||||||
|
error = "No OpenAI API key set. Open Settings (Cmd+,) to add your key."
|
||||||
|
isStreaming = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load user settings
|
||||||
|
let settingsDescriptor = FetchDescriptor<UserSettings>()
|
||||||
|
let settings = (try? modelContext.fetch(settingsDescriptor))?.first
|
||||||
|
|
||||||
|
let model = settings?.selectedModel ?? "gpt-4.1-mini"
|
||||||
|
let customPrompt = settings?.systemPrompt
|
||||||
|
let pinnedToolIds = settings?.pinnedToolIds ?? []
|
||||||
|
|
||||||
|
// Save user message
|
||||||
|
let userMessage = Message(role: .user, content: text, conversation: conversation)
|
||||||
|
modelContext.insert(userMessage)
|
||||||
|
conversation.updatedAt = Date()
|
||||||
|
conversation.executionState = "running"
|
||||||
|
try? modelContext.save()
|
||||||
|
|
||||||
|
// Load env vars from Keychain
|
||||||
|
let envVarDescriptor = FetchDescriptor<EnvVar>()
|
||||||
|
let envVarRecords = (try? modelContext.fetch(envVarDescriptor)) ?? []
|
||||||
|
let envVars = KeychainService.loadAllEnvVars(keyNames: envVarRecords.map(\.keyName))
|
||||||
|
|
||||||
|
// Auto-discover tools via BM25 search
|
||||||
|
do {
|
||||||
|
let relevantTools = try await registry.searchTools(query: text, limit: 10)
|
||||||
|
for toolMeta in relevantTools {
|
||||||
|
let sanitized = sanitizeToolName(toolMeta.toolId)
|
||||||
|
if loadedTools[sanitized] == nil {
|
||||||
|
loadedTools[sanitized] = toolMeta
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-fatal: continue without auto-discovered tools
|
||||||
|
print("Auto-discovery failed: \(error)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build messages array from conversation history
|
||||||
|
var chatMessages = buildChatMessages(
|
||||||
|
conversation: conversation,
|
||||||
|
customPrompt: customPrompt,
|
||||||
|
pinnedToolIds: pinnedToolIds
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add the new user message
|
||||||
|
chatMessages.append(.user(text))
|
||||||
|
|
||||||
|
// Build tools list
|
||||||
|
let tools = buildToolsList()
|
||||||
|
|
||||||
|
// Agentic loop
|
||||||
|
var iteration = 0
|
||||||
|
var allToolCallData: [ToolCallData] = []
|
||||||
|
var allToolResultData: [ToolCallData] = []
|
||||||
|
var totalInputTokens = 0
|
||||||
|
var totalOutputTokens = 0
|
||||||
|
|
||||||
|
while iteration < maxIterations {
|
||||||
|
iteration += 1
|
||||||
|
|
||||||
|
var currentContent = ""
|
||||||
|
var pendingToolCalls: [ChatToolCall] = []
|
||||||
|
var receivedDone = false
|
||||||
|
|
||||||
|
do {
|
||||||
|
let stream = await openAI.streamCompletion(
|
||||||
|
apiKey: apiKey,
|
||||||
|
model: model,
|
||||||
|
messages: chatMessages,
|
||||||
|
tools: tools.isEmpty ? nil : tools
|
||||||
|
)
|
||||||
|
|
||||||
|
for try await event in stream {
|
||||||
|
switch event {
|
||||||
|
case .contentDelta(let delta):
|
||||||
|
currentContent += delta
|
||||||
|
streamingContent = currentContent
|
||||||
|
|
||||||
|
case .toolCallStarted(_, let id, let name):
|
||||||
|
let liveTC = LiveToolCall(
|
||||||
|
id: id,
|
||||||
|
toolName: name,
|
||||||
|
arguments: "",
|
||||||
|
status: "running"
|
||||||
|
)
|
||||||
|
liveToolCalls.append(liveTC)
|
||||||
|
|
||||||
|
case .toolCallArgumentDelta(let index, let delta):
|
||||||
|
if index < liveToolCalls.count {
|
||||||
|
liveToolCalls[index].arguments += delta
|
||||||
|
}
|
||||||
|
|
||||||
|
case .toolCallComplete(let toolCall):
|
||||||
|
pendingToolCalls.append(toolCall)
|
||||||
|
|
||||||
|
case .usage(let input, let output):
|
||||||
|
totalInputTokens += input
|
||||||
|
totalOutputTokens += output
|
||||||
|
|
||||||
|
case .done:
|
||||||
|
receivedDone = true
|
||||||
|
|
||||||
|
case .error(let msg):
|
||||||
|
self.error = msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we got content with no tool calls, we're done
|
||||||
|
if pendingToolCalls.isEmpty {
|
||||||
|
streamingContent = currentContent
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process tool calls
|
||||||
|
// Add assistant message with tool calls to chat history
|
||||||
|
chatMessages.append(.assistant(
|
||||||
|
content: currentContent.isEmpty ? nil : currentContent,
|
||||||
|
toolCalls: pendingToolCalls
|
||||||
|
))
|
||||||
|
|
||||||
|
// Execute each tool call
|
||||||
|
for toolCall in pendingToolCalls {
|
||||||
|
let tcData = ToolCallData(
|
||||||
|
toolCallId: toolCall.id,
|
||||||
|
toolName: toolCall.toolName,
|
||||||
|
args: .object(toolCall.parsedArguments)
|
||||||
|
)
|
||||||
|
allToolCallData.append(tcData)
|
||||||
|
|
||||||
|
// Record tool run
|
||||||
|
let record = ToolCallRecord(
|
||||||
|
toolName: toolCall.toolName,
|
||||||
|
toolCallId: toolCall.id,
|
||||||
|
conversation: conversation
|
||||||
|
)
|
||||||
|
record.input = .object(toolCall.parsedArguments)
|
||||||
|
modelContext.insert(record)
|
||||||
|
|
||||||
|
let result = await executeToolCall(
|
||||||
|
toolCall: toolCall,
|
||||||
|
envVars: envVars
|
||||||
|
)
|
||||||
|
|
||||||
|
// Update live tool call status
|
||||||
|
if let idx = liveToolCalls.firstIndex(where: { $0.id == toolCall.id }) {
|
||||||
|
liveToolCalls[idx].status = result.isError ? "error" : "success"
|
||||||
|
liveToolCalls[idx].output = result.output
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update record
|
||||||
|
record.output = result.output
|
||||||
|
record.status = result.isError ? "error" : "success"
|
||||||
|
record.completedAt = Date()
|
||||||
|
|
||||||
|
// Add tool result to chat messages
|
||||||
|
let resultJSON: String
|
||||||
|
if let data = try? JSONEncoder().encode(result.output) {
|
||||||
|
resultJSON = String(data: data, encoding: .utf8) ?? "{}"
|
||||||
|
} else {
|
||||||
|
resultJSON = "{}"
|
||||||
|
}
|
||||||
|
|
||||||
|
chatMessages.append(.toolResult(
|
||||||
|
toolCallId: toolCall.id,
|
||||||
|
name: toolCall.toolName,
|
||||||
|
content: resultJSON
|
||||||
|
))
|
||||||
|
|
||||||
|
let trData = ToolCallData(
|
||||||
|
toolCallId: toolCall.id,
|
||||||
|
toolName: toolCall.toolName,
|
||||||
|
args: .object(toolCall.parsedArguments),
|
||||||
|
output: result.output
|
||||||
|
)
|
||||||
|
allToolResultData.append(trData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset streaming for next iteration
|
||||||
|
streamingContent = ""
|
||||||
|
liveToolCalls = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save assistant message
|
||||||
|
let assistantMessage = Message(
|
||||||
|
role: .assistant,
|
||||||
|
content: streamingContent,
|
||||||
|
conversation: conversation,
|
||||||
|
inputTokens: totalInputTokens,
|
||||||
|
outputTokens: totalOutputTokens,
|
||||||
|
toolCalls: allToolCallData.isEmpty ? nil : allToolCallData
|
||||||
|
)
|
||||||
|
modelContext.insert(assistantMessage)
|
||||||
|
|
||||||
|
// Save tool results as a TOOL message if we had tool calls
|
||||||
|
if !allToolResultData.isEmpty {
|
||||||
|
let toolMessage = Message(
|
||||||
|
role: .tool,
|
||||||
|
content: "Tool results",
|
||||||
|
conversation: conversation,
|
||||||
|
toolCalls: allToolResultData
|
||||||
|
)
|
||||||
|
modelContext.insert(toolMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update conversation
|
||||||
|
conversation.executionState = "idle"
|
||||||
|
conversation.inputTokensTotal += totalInputTokens
|
||||||
|
conversation.outputTokensTotal += totalOutputTokens
|
||||||
|
conversation.updatedAt = Date()
|
||||||
|
|
||||||
|
// Auto-title from first message
|
||||||
|
if conversation.title == nil {
|
||||||
|
let title = text.count > 50 ? String(text.prefix(50)) + "..." : text
|
||||||
|
conversation.title = title
|
||||||
|
}
|
||||||
|
|
||||||
|
try? modelContext.save()
|
||||||
|
|
||||||
|
isStreaming = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear loaded tools (when switching conversations)
|
||||||
|
func resetConversation() {
|
||||||
|
loadedTools = [:]
|
||||||
|
streamingContent = ""
|
||||||
|
isStreaming = false
|
||||||
|
liveToolCalls = []
|
||||||
|
error = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
|
private struct ToolResult {
|
||||||
|
let output: JSONValue
|
||||||
|
let isError: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
private func executeToolCall(
|
||||||
|
toolCall: ChatToolCall,
|
||||||
|
envVars: [String: String]
|
||||||
|
) async -> ToolResult {
|
||||||
|
let name = toolCall.toolName
|
||||||
|
let args = toolCall.parsedArguments
|
||||||
|
|
||||||
|
// Handle registrySearch
|
||||||
|
if name == "registrySearch" {
|
||||||
|
return await handleRegistrySearch(args: args)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle registryExecute
|
||||||
|
if name == "registryExecute" {
|
||||||
|
return await handleRegistryExecute(args: args, envVars: envVars)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle dynamic tools (loaded from search)
|
||||||
|
if let toolMeta = loadedTools[name] {
|
||||||
|
return await handleDynamicTool(meta: toolMeta, args: args, envVars: envVars)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check by finding the tool ID from the sanitized name
|
||||||
|
if let toolId = findToolId(sanitizedName: name, in: loadedTools),
|
||||||
|
let toolMeta = loadedTools.values.first(where: { $0.toolId == toolId }) {
|
||||||
|
return await handleDynamicTool(meta: toolMeta, args: args, envVars: envVars)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ToolResult(
|
||||||
|
output: .object([
|
||||||
|
"error": .bool(true),
|
||||||
|
"message": .string("Unknown tool: \(name)"),
|
||||||
|
]),
|
||||||
|
isError: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleRegistrySearch(args: [String: JSONValue]) async -> ToolResult {
|
||||||
|
guard case .string(let query) = args["query"] else {
|
||||||
|
return ToolResult(
|
||||||
|
output: .object(["error": .bool(true), "message": .string("Missing 'query' parameter")]),
|
||||||
|
isError: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let limit: Int
|
||||||
|
if case .number(let n) = args["limit"] {
|
||||||
|
limit = Int(n)
|
||||||
|
} else {
|
||||||
|
limit = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let tools = try await registry.searchTools(query: query, limit: limit)
|
||||||
|
|
||||||
|
// Inject found tools into loaded tools
|
||||||
|
for toolMeta in tools {
|
||||||
|
let sanitized = sanitizeToolName(toolMeta.toolId)
|
||||||
|
if loadedTools[sanitized] == nil {
|
||||||
|
loadedTools[sanitized] = toolMeta
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let toolsJSON: [JSONValue] = tools.map { t in
|
||||||
|
.object([
|
||||||
|
"toolId": .string(t.toolId),
|
||||||
|
"name": .string(t.name),
|
||||||
|
"package": .string(t.packageName),
|
||||||
|
"description": .string(t.description),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
return ToolResult(
|
||||||
|
output: .object([
|
||||||
|
"query": .string(query),
|
||||||
|
"matchCount": .number(Double(tools.count)),
|
||||||
|
"tools": .array(toolsJSON),
|
||||||
|
]),
|
||||||
|
isError: false
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return ToolResult(
|
||||||
|
output: .object([
|
||||||
|
"error": .bool(true),
|
||||||
|
"message": .string(error.localizedDescription),
|
||||||
|
]),
|
||||||
|
isError: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleRegistryExecute(
|
||||||
|
args: [String: JSONValue],
|
||||||
|
envVars: [String: String]
|
||||||
|
) async -> ToolResult {
|
||||||
|
guard case .string(let toolId) = args["toolId"] else {
|
||||||
|
return ToolResult(
|
||||||
|
output: .object(["error": .bool(true), "message": .string("Missing 'toolId' parameter")]),
|
||||||
|
isError: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let params = args["params"] ?? .object([:])
|
||||||
|
|
||||||
|
do {
|
||||||
|
let response = try await registry.executeByToolId(
|
||||||
|
toolId: toolId,
|
||||||
|
params: params,
|
||||||
|
env: envVars
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.success {
|
||||||
|
return ToolResult(
|
||||||
|
output: .object([
|
||||||
|
"toolId": .string(toolId),
|
||||||
|
"executionTimeMs": .number(Double(response.executionTimeMs ?? 0)),
|
||||||
|
"output": response.output ?? .null,
|
||||||
|
]),
|
||||||
|
isError: false
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return ToolResult(
|
||||||
|
output: .object([
|
||||||
|
"error": .bool(true),
|
||||||
|
"message": .string(response.error ?? "Tool execution failed"),
|
||||||
|
"toolId": .string(toolId),
|
||||||
|
]),
|
||||||
|
isError: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return ToolResult(
|
||||||
|
output: .object([
|
||||||
|
"error": .bool(true),
|
||||||
|
"message": .string(error.localizedDescription),
|
||||||
|
"toolId": .string(toolId),
|
||||||
|
]),
|
||||||
|
isError: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleDynamicTool(
|
||||||
|
meta: ToolMeta,
|
||||||
|
args: [String: JSONValue],
|
||||||
|
envVars: [String: String]
|
||||||
|
) async -> ToolResult {
|
||||||
|
do {
|
||||||
|
let response = try await registry.executeTool(
|
||||||
|
packageName: meta.packageName,
|
||||||
|
name: meta.name,
|
||||||
|
version: meta.version,
|
||||||
|
importUrl: meta.importUrl,
|
||||||
|
params: .object(args),
|
||||||
|
env: envVars
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.success {
|
||||||
|
return ToolResult(
|
||||||
|
output: response.output ?? .null,
|
||||||
|
isError: false
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return ToolResult(
|
||||||
|
output: .object([
|
||||||
|
"error": .bool(true),
|
||||||
|
"message": .string(response.error ?? "Tool execution failed"),
|
||||||
|
"toolId": .string(meta.toolId),
|
||||||
|
]),
|
||||||
|
isError: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return ToolResult(
|
||||||
|
output: .object([
|
||||||
|
"error": .bool(true),
|
||||||
|
"message": .string(error.localizedDescription),
|
||||||
|
"toolId": .string(meta.toolId),
|
||||||
|
]),
|
||||||
|
isError: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build chat messages from conversation history
|
||||||
|
private func buildChatMessages(
|
||||||
|
conversation: Conversation,
|
||||||
|
customPrompt: String?,
|
||||||
|
pinnedToolIds: [String]
|
||||||
|
) -> [ChatMessage] {
|
||||||
|
var messages: [ChatMessage] = []
|
||||||
|
|
||||||
|
// System prompt
|
||||||
|
let systemPrompt = SystemPromptBuilder.build(
|
||||||
|
customSystemPrompt: customPrompt,
|
||||||
|
pinnedToolIds: pinnedToolIds,
|
||||||
|
loadedTools: loadedTools
|
||||||
|
)
|
||||||
|
messages.append(.system(systemPrompt))
|
||||||
|
|
||||||
|
// Last 20 messages from conversation history
|
||||||
|
let sorted = conversation.sortedMessages
|
||||||
|
let recent = sorted.suffix(20)
|
||||||
|
|
||||||
|
for msg in recent {
|
||||||
|
switch msg.role {
|
||||||
|
case .user:
|
||||||
|
messages.append(.user(msg.content))
|
||||||
|
|
||||||
|
case .assistant:
|
||||||
|
let toolCalls = msg.toolCalls
|
||||||
|
if !toolCalls.isEmpty {
|
||||||
|
let chatToolCalls = toolCalls.map { tc in
|
||||||
|
ChatToolCall(
|
||||||
|
id: tc.toolCallId,
|
||||||
|
type: "function",
|
||||||
|
function: ChatToolCallFunction(
|
||||||
|
name: tc.toolName,
|
||||||
|
arguments: {
|
||||||
|
if let args = tc.args,
|
||||||
|
let data = try? JSONEncoder().encode(args) {
|
||||||
|
return String(data: data, encoding: .utf8) ?? "{}"
|
||||||
|
}
|
||||||
|
return "{}"
|
||||||
|
}()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
messages.append(.assistant(content: msg.content, toolCalls: chatToolCalls))
|
||||||
|
} else {
|
||||||
|
messages.append(.assistant(content: msg.content, toolCalls: nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
case .tool:
|
||||||
|
for tc in msg.toolCalls {
|
||||||
|
let outputJSON: String
|
||||||
|
if let output = tc.output,
|
||||||
|
let data = try? JSONEncoder().encode(output) {
|
||||||
|
outputJSON = String(data: data, encoding: .utf8) ?? "{}"
|
||||||
|
} else {
|
||||||
|
outputJSON = "{}"
|
||||||
|
}
|
||||||
|
messages.append(.toolResult(
|
||||||
|
toolCallId: tc.toolCallId,
|
||||||
|
name: tc.toolName,
|
||||||
|
content: outputJSON
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
case .system:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the OpenAI tools array from static + dynamic tools
|
||||||
|
private func buildToolsList() -> [ChatTool] {
|
||||||
|
var tools: [ChatTool] = []
|
||||||
|
|
||||||
|
// Static: registrySearch
|
||||||
|
tools.append(ChatTool(
|
||||||
|
function: ChatFunction(
|
||||||
|
name: "registrySearch",
|
||||||
|
description: "Search the TPMJS tool registry to find AI SDK tools. Use this to discover tools for any task. Returns toolIds that can be executed with registryExecute.",
|
||||||
|
parameters: JSONSchemaObject(
|
||||||
|
type: "object",
|
||||||
|
properties: [
|
||||||
|
"query": JSONSchemaProperty(
|
||||||
|
type: "string",
|
||||||
|
description: "Search query (keywords, tool names, descriptions)"
|
||||||
|
),
|
||||||
|
"limit": JSONSchemaProperty(
|
||||||
|
type: "number",
|
||||||
|
description: "Maximum number of results (1-20, default 5)",
|
||||||
|
minimum: 1,
|
||||||
|
maximum: 20
|
||||||
|
),
|
||||||
|
],
|
||||||
|
required: ["query"],
|
||||||
|
additionalProperties: false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
))
|
||||||
|
|
||||||
|
// Static: registryExecute
|
||||||
|
tools.append(ChatTool(
|
||||||
|
function: ChatFunction(
|
||||||
|
name: "registryExecute",
|
||||||
|
description: "Execute a tool from the TPMJS registry. Use registrySearch first to find the toolId. Tools run in a secure sandbox.",
|
||||||
|
parameters: JSONSchemaObject(
|
||||||
|
type: "object",
|
||||||
|
properties: [
|
||||||
|
"toolId": JSONSchemaProperty(
|
||||||
|
type: "string",
|
||||||
|
description: "Tool identifier from registrySearch (format: 'package::name')"
|
||||||
|
),
|
||||||
|
"params": JSONSchemaProperty(
|
||||||
|
type: "object",
|
||||||
|
description: "Parameters to pass to the tool",
|
||||||
|
additionalProperties: .bool(true)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
required: ["toolId", "params"],
|
||||||
|
additionalProperties: false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
))
|
||||||
|
|
||||||
|
// Dynamic tools
|
||||||
|
for (_, meta) in loadedTools {
|
||||||
|
tools.append(meta.toChatTool())
|
||||||
|
}
|
||||||
|
|
||||||
|
return tools
|
||||||
|
}
|
||||||
|
}
|
||||||
124
apps/omega-mac/OmegaMac/Services/KeychainService.swift
Normal file
124
apps/omega-mac/OmegaMac/Services/KeychainService.swift
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
|
||||||
|
/// Wrapper around macOS Keychain for storing API keys and env var values securely.
|
||||||
|
enum KeychainService {
|
||||||
|
private static let serviceName = "com.tpmjs.omega-mac"
|
||||||
|
|
||||||
|
/// Save or update a value in the Keychain
|
||||||
|
static func save(key: String, value: String) throws {
|
||||||
|
guard let data = value.data(using: .utf8) else {
|
||||||
|
throw KeychainError.encodingFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if item exists
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: serviceName,
|
||||||
|
kSecAttrAccount as String: key,
|
||||||
|
]
|
||||||
|
|
||||||
|
let status = SecItemCopyMatching(query as CFDictionary, nil)
|
||||||
|
|
||||||
|
if status == errSecSuccess {
|
||||||
|
// Update existing
|
||||||
|
let attributes: [String: Any] = [
|
||||||
|
kSecValueData as String: data,
|
||||||
|
]
|
||||||
|
let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
|
||||||
|
guard updateStatus == errSecSuccess else {
|
||||||
|
throw KeychainError.unhandledError(updateStatus)
|
||||||
|
}
|
||||||
|
} else if status == errSecItemNotFound {
|
||||||
|
// Add new
|
||||||
|
var addQuery = query
|
||||||
|
addQuery[kSecValueData as String] = data
|
||||||
|
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
|
||||||
|
guard addStatus == errSecSuccess else {
|
||||||
|
throw KeychainError.unhandledError(addStatus)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw KeychainError.unhandledError(status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve a value from the Keychain
|
||||||
|
static func load(key: String) -> String? {
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: serviceName,
|
||||||
|
kSecAttrAccount as String: key,
|
||||||
|
kSecReturnData as String: true,
|
||||||
|
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||||
|
]
|
||||||
|
|
||||||
|
var result: AnyObject?
|
||||||
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||||
|
|
||||||
|
guard status == errSecSuccess,
|
||||||
|
let data = result as? Data,
|
||||||
|
let value = String(data: data, encoding: .utf8) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a value from the Keychain
|
||||||
|
static func delete(key: String) throws {
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: serviceName,
|
||||||
|
kSecAttrAccount as String: key,
|
||||||
|
]
|
||||||
|
|
||||||
|
let status = SecItemDelete(query as CFDictionary)
|
||||||
|
guard status == errSecSuccess || status == errSecItemNotFound else {
|
||||||
|
throw KeychainError.unhandledError(status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load all stored keys (returns key names only, not values)
|
||||||
|
static func allKeys() -> [String] {
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: serviceName,
|
||||||
|
kSecReturnAttributes as String: true,
|
||||||
|
kSecMatchLimit as String: kSecMatchLimitAll,
|
||||||
|
]
|
||||||
|
|
||||||
|
var result: AnyObject?
|
||||||
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||||
|
|
||||||
|
guard status == errSecSuccess,
|
||||||
|
let items = result as? [[String: Any]] else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.compactMap { $0[kSecAttrAccount as String] as? String }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience: load all env vars as a dictionary
|
||||||
|
static func loadAllEnvVars(keyNames: [String]) -> [String: String] {
|
||||||
|
var result: [String: String] = [:]
|
||||||
|
for key in keyNames {
|
||||||
|
if let value = load(key: key) {
|
||||||
|
result[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum KeychainError: LocalizedError {
|
||||||
|
case encodingFailed
|
||||||
|
case unhandledError(OSStatus)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .encodingFailed:
|
||||||
|
return "Failed to encode value for Keychain"
|
||||||
|
case .unhandledError(let status):
|
||||||
|
return "Keychain error: \(status)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
141
apps/omega-mac/OmegaMac/Services/OpenAIService.swift
Normal file
141
apps/omega-mac/OmegaMac/Services/OpenAIService.swift
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Actor that handles all communication with the OpenAI Chat Completions API.
|
||||||
|
/// Supports streaming via Server-Sent Events (SSE).
|
||||||
|
actor OpenAIService {
|
||||||
|
private let session: URLSession
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let config = URLSessionConfiguration.default
|
||||||
|
config.timeoutIntervalForRequest = 300
|
||||||
|
config.timeoutIntervalForResource = 300
|
||||||
|
self.session = URLSession(configuration: config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stream a chat completion, yielding parsed events as they arrive.
|
||||||
|
func streamCompletion(
|
||||||
|
apiKey: String,
|
||||||
|
model: String,
|
||||||
|
messages: [ChatMessage],
|
||||||
|
tools: [ChatTool]?
|
||||||
|
) -> AsyncThrowingStream<StreamParser.StreamEvent, Error> {
|
||||||
|
AsyncThrowingStream { continuation in
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let request = try buildRequest(
|
||||||
|
apiKey: apiKey,
|
||||||
|
model: model,
|
||||||
|
messages: messages,
|
||||||
|
tools: tools,
|
||||||
|
stream: true
|
||||||
|
)
|
||||||
|
|
||||||
|
let (bytes, response) = try await session.bytes(for: request)
|
||||||
|
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
continuation.yield(.error("Invalid response type"))
|
||||||
|
continuation.finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard httpResponse.statusCode == 200 else {
|
||||||
|
// Try to read error body
|
||||||
|
var errorBody = ""
|
||||||
|
for try await line in bytes.lines {
|
||||||
|
errorBody += line
|
||||||
|
}
|
||||||
|
continuation.yield(.error("API error \(httpResponse.statusCode): \(errorBody)"))
|
||||||
|
continuation.finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track accumulated tool calls
|
||||||
|
var toolCallAccumulators: [Int: StreamParser.ToolCallAccumulator] = [:]
|
||||||
|
|
||||||
|
for try await line in bytes.lines {
|
||||||
|
let events = StreamParser.parseLine(line)
|
||||||
|
for event in events {
|
||||||
|
switch event {
|
||||||
|
case .toolCallStarted(let index, let id, let name):
|
||||||
|
toolCallAccumulators[index] = StreamParser.ToolCallAccumulator(
|
||||||
|
id: id,
|
||||||
|
name: name,
|
||||||
|
arguments: ""
|
||||||
|
)
|
||||||
|
continuation.yield(event)
|
||||||
|
|
||||||
|
case .toolCallArgumentDelta(let index, let delta):
|
||||||
|
toolCallAccumulators[index]?.arguments += delta
|
||||||
|
continuation.yield(event)
|
||||||
|
|
||||||
|
case .done:
|
||||||
|
// Emit completed tool calls
|
||||||
|
for (_, acc) in toolCallAccumulators.sorted(by: { $0.key < $1.key }) {
|
||||||
|
let toolCall = ChatToolCall(
|
||||||
|
id: acc.id,
|
||||||
|
type: "function",
|
||||||
|
function: ChatToolCallFunction(
|
||||||
|
name: acc.name,
|
||||||
|
arguments: acc.arguments
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continuation.yield(.toolCallComplete(toolCall))
|
||||||
|
}
|
||||||
|
continuation.yield(.done)
|
||||||
|
continuation.finish()
|
||||||
|
|
||||||
|
default:
|
||||||
|
continuation.yield(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we reach here without [DONE], still emit completed tool calls
|
||||||
|
if !toolCallAccumulators.isEmpty {
|
||||||
|
for (_, acc) in toolCallAccumulators.sorted(by: { $0.key < $1.key }) {
|
||||||
|
let toolCall = ChatToolCall(
|
||||||
|
id: acc.id,
|
||||||
|
type: "function",
|
||||||
|
function: ChatToolCallFunction(
|
||||||
|
name: acc.name,
|
||||||
|
arguments: acc.arguments
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continuation.yield(.toolCallComplete(toolCall))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continuation.finish()
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
continuation.yield(.error(error.localizedDescription))
|
||||||
|
continuation.finish(throwing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildRequest(
|
||||||
|
apiKey: String,
|
||||||
|
model: String,
|
||||||
|
messages: [ChatMessage],
|
||||||
|
tools: [ChatTool]?,
|
||||||
|
stream: Bool
|
||||||
|
) throws -> URLRequest {
|
||||||
|
var request = URLRequest(url: URL(string: "https://api.openai.com/v1/chat/completions")!)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
||||||
|
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
|
||||||
|
let body = ChatCompletionRequest(
|
||||||
|
model: model,
|
||||||
|
messages: messages,
|
||||||
|
tools: tools?.isEmpty == true ? nil : tools,
|
||||||
|
stream: stream,
|
||||||
|
maxTokens: 4096,
|
||||||
|
streamOptions: stream ? StreamOptions(includeUsage: true) : nil
|
||||||
|
)
|
||||||
|
|
||||||
|
request.httpBody = try JSONEncoder().encode(body)
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
}
|
||||||
209
apps/omega-mac/OmegaMac/Services/OpenAITypes.swift
Normal file
209
apps/omega-mac/OmegaMac/Services/OpenAITypes.swift
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Request Types
|
||||||
|
|
||||||
|
struct ChatCompletionRequest: Encodable {
|
||||||
|
let model: String
|
||||||
|
let messages: [ChatMessage]
|
||||||
|
let tools: [ChatTool]?
|
||||||
|
let stream: Bool
|
||||||
|
let maxTokens: Int?
|
||||||
|
let streamOptions: StreamOptions?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case model, messages, tools, stream
|
||||||
|
case maxTokens = "max_tokens"
|
||||||
|
case streamOptions = "stream_options"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StreamOptions: Encodable {
|
||||||
|
let includeUsage: Bool
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case includeUsage = "include_usage"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ChatMessage: Codable {
|
||||||
|
let role: String
|
||||||
|
let content: String?
|
||||||
|
let toolCalls: [ChatToolCall]?
|
||||||
|
let toolCallId: String?
|
||||||
|
let name: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case role, content, name
|
||||||
|
case toolCalls = "tool_calls"
|
||||||
|
case toolCallId = "tool_call_id"
|
||||||
|
}
|
||||||
|
|
||||||
|
static func system(_ content: String) -> ChatMessage {
|
||||||
|
ChatMessage(role: "system", content: content, toolCalls: nil, toolCallId: nil, name: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func user(_ content: String) -> ChatMessage {
|
||||||
|
ChatMessage(role: "user", content: content, toolCalls: nil, toolCallId: nil, name: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func assistant(content: String?, toolCalls: [ChatToolCall]?) -> ChatMessage {
|
||||||
|
ChatMessage(role: "assistant", content: content, toolCalls: toolCalls, toolCallId: nil, name: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func toolResult(toolCallId: String, name: String, content: String) -> ChatMessage {
|
||||||
|
ChatMessage(role: "tool", content: content, toolCalls: nil, toolCallId: toolCallId, name: name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ChatTool: Encodable {
|
||||||
|
let type: String = "function"
|
||||||
|
let function: ChatFunction
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ChatFunction: Encodable {
|
||||||
|
let name: String
|
||||||
|
let description: String
|
||||||
|
let parameters: JSONSchemaObject
|
||||||
|
}
|
||||||
|
|
||||||
|
struct JSONSchemaObject: Encodable {
|
||||||
|
let type: String
|
||||||
|
let properties: [String: JSONSchemaProperty]
|
||||||
|
let required: [String]?
|
||||||
|
let additionalProperties: Bool?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct JSONSchemaProperty: Encodable {
|
||||||
|
let type: String
|
||||||
|
let description: String?
|
||||||
|
let minimum: Int?
|
||||||
|
let maximum: Int?
|
||||||
|
let additionalProperties: JSONSchemaAdditional?
|
||||||
|
|
||||||
|
init(type: String, description: String? = nil, minimum: Int? = nil, maximum: Int? = nil, additionalProperties: JSONSchemaAdditional? = nil) {
|
||||||
|
self.type = type
|
||||||
|
self.description = description
|
||||||
|
self.minimum = minimum
|
||||||
|
self.maximum = maximum
|
||||||
|
self.additionalProperties = additionalProperties
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
indirect enum JSONSchemaAdditional: Encodable {
|
||||||
|
case bool(Bool)
|
||||||
|
case typed(JSONSchemaProperty)
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.singleValueContainer()
|
||||||
|
switch self {
|
||||||
|
case .bool(let b): try container.encode(b)
|
||||||
|
case .typed(let p): try container.encode(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ChatToolCall: Codable, Identifiable {
|
||||||
|
var id: String
|
||||||
|
let type: String?
|
||||||
|
let function: ChatToolCallFunction?
|
||||||
|
|
||||||
|
var toolName: String { function?.name ?? "" }
|
||||||
|
var arguments: String { function?.arguments ?? "{}" }
|
||||||
|
|
||||||
|
var parsedArguments: [String: JSONValue] {
|
||||||
|
guard let data = arguments.data(using: .utf8),
|
||||||
|
let obj = try? JSONDecoder().decode([String: JSONValue].self, from: data) else {
|
||||||
|
return [:]
|
||||||
|
}
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ChatToolCallFunction: Codable {
|
||||||
|
let name: String?
|
||||||
|
let arguments: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Response Types (non-streaming)
|
||||||
|
|
||||||
|
struct ChatCompletionResponse: Decodable {
|
||||||
|
let id: String
|
||||||
|
let choices: [ChatChoice]
|
||||||
|
let usage: ChatUsage?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ChatChoice: Decodable {
|
||||||
|
let index: Int
|
||||||
|
let message: ChatResponseMessage
|
||||||
|
let finishReason: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case index, message
|
||||||
|
case finishReason = "finish_reason"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ChatResponseMessage: Decodable {
|
||||||
|
let role: String
|
||||||
|
let content: String?
|
||||||
|
let toolCalls: [ChatToolCall]?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case role, content
|
||||||
|
case toolCalls = "tool_calls"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ChatUsage: Decodable {
|
||||||
|
let promptTokens: Int?
|
||||||
|
let completionTokens: Int?
|
||||||
|
let totalTokens: Int?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case promptTokens = "prompt_tokens"
|
||||||
|
case completionTokens = "completion_tokens"
|
||||||
|
case totalTokens = "total_tokens"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Streaming Types
|
||||||
|
|
||||||
|
struct ChatCompletionChunk: Decodable {
|
||||||
|
let id: String?
|
||||||
|
let choices: [ChunkChoice]?
|
||||||
|
let usage: ChatUsage?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ChunkChoice: Decodable {
|
||||||
|
let index: Int?
|
||||||
|
let delta: ChunkDelta?
|
||||||
|
let finishReason: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case index, delta
|
||||||
|
case finishReason = "finish_reason"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ChunkDelta: Decodable {
|
||||||
|
let role: String?
|
||||||
|
let content: String?
|
||||||
|
let toolCalls: [ChunkToolCall]?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case role, content
|
||||||
|
case toolCalls = "tool_calls"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ChunkToolCall: Decodable {
|
||||||
|
let index: Int?
|
||||||
|
let id: String?
|
||||||
|
let type: String?
|
||||||
|
let function: ChunkToolCallFunction?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ChunkToolCallFunction: Decodable {
|
||||||
|
let name: String?
|
||||||
|
let arguments: String?
|
||||||
|
}
|
||||||
105
apps/omega-mac/OmegaMac/Services/StreamParser.swift
Normal file
105
apps/omega-mac/OmegaMac/Services/StreamParser.swift
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Parses Server-Sent Events (SSE) from OpenAI's streaming API.
|
||||||
|
/// Handles `data: {...}` lines and `data: [DONE]` termination.
|
||||||
|
struct StreamParser {
|
||||||
|
|
||||||
|
/// Accumulated tool call state during streaming
|
||||||
|
struct ToolCallAccumulator {
|
||||||
|
var id: String = ""
|
||||||
|
var name: String = ""
|
||||||
|
var arguments: String = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of parsing the stream - yields content deltas and complete tool calls
|
||||||
|
enum StreamEvent: Sendable {
|
||||||
|
case contentDelta(String)
|
||||||
|
case toolCallStarted(index: Int, id: String, name: String)
|
||||||
|
case toolCallArgumentDelta(index: Int, delta: String)
|
||||||
|
case toolCallComplete(ChatToolCall)
|
||||||
|
case usage(inputTokens: Int, outputTokens: Int)
|
||||||
|
case done
|
||||||
|
case error(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a single SSE line and return events
|
||||||
|
static func parseLine(_ line: String) -> [StreamEvent] {
|
||||||
|
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
|
// Skip empty lines and comments
|
||||||
|
guard !trimmed.isEmpty, !trimmed.hasPrefix(":") else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must start with "data: "
|
||||||
|
guard trimmed.hasPrefix("data: ") else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = String(trimmed.dropFirst(6))
|
||||||
|
|
||||||
|
// Check for stream end
|
||||||
|
if payload == "[DONE]" {
|
||||||
|
return [.done]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse JSON chunk
|
||||||
|
guard let data = payload.data(using: .utf8) else {
|
||||||
|
return [.error("Invalid UTF-8 in SSE payload")]
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let chunk = try JSONDecoder().decode(ChatCompletionChunk.self, from: data)
|
||||||
|
return processChunk(chunk)
|
||||||
|
} catch {
|
||||||
|
return [.error("Failed to parse chunk: \(error.localizedDescription)")]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func processChunk(_ chunk: ChatCompletionChunk) -> [StreamEvent] {
|
||||||
|
var events: [StreamEvent] = []
|
||||||
|
|
||||||
|
if let choices = chunk.choices {
|
||||||
|
for choice in choices {
|
||||||
|
guard let delta = choice.delta else { continue }
|
||||||
|
|
||||||
|
// Content delta
|
||||||
|
if let content = delta.content, !content.isEmpty {
|
||||||
|
events.append(.contentDelta(content))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tool calls
|
||||||
|
if let toolCalls = delta.toolCalls {
|
||||||
|
for tc in toolCalls {
|
||||||
|
let idx = tc.index ?? 0
|
||||||
|
if let id = tc.id, !id.isEmpty {
|
||||||
|
events.append(.toolCallStarted(
|
||||||
|
index: idx,
|
||||||
|
id: id,
|
||||||
|
name: tc.function?.name ?? ""
|
||||||
|
))
|
||||||
|
}
|
||||||
|
if let args = tc.function?.arguments, !args.isEmpty {
|
||||||
|
events.append(.toolCallArgumentDelta(index: idx, delta: args))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finish reason
|
||||||
|
if choice.finishReason == "stop" || choice.finishReason == "tool_calls" {
|
||||||
|
// Will be handled by [DONE]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage info (sometimes included in last chunk)
|
||||||
|
if let usage = chunk.usage {
|
||||||
|
events.append(.usage(
|
||||||
|
inputTokens: usage.promptTokens ?? 0,
|
||||||
|
outputTokens: usage.completionTokens ?? 0
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
}
|
||||||
156
apps/omega-mac/OmegaMac/Services/TPMJSRegistryService.swift
Normal file
156
apps/omega-mac/OmegaMac/Services/TPMJSRegistryService.swift
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Actor that handles communication with the TPMJS tool registry API
|
||||||
|
/// and the remote executor service.
|
||||||
|
actor TPMJSRegistryService {
|
||||||
|
private let session: URLSession
|
||||||
|
private let registryBaseURL: String
|
||||||
|
private let executorBaseURL: String
|
||||||
|
|
||||||
|
init(
|
||||||
|
registryBaseURL: String = "https://tpmjs.com",
|
||||||
|
executorBaseURL: String = "https://executor.tpmjs.com"
|
||||||
|
) {
|
||||||
|
let config = URLSessionConfiguration.default
|
||||||
|
config.timeoutIntervalForRequest = 60
|
||||||
|
self.session = URLSession(configuration: config)
|
||||||
|
self.registryBaseURL = registryBaseURL
|
||||||
|
self.executorBaseURL = executorBaseURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Search
|
||||||
|
|
||||||
|
/// Search for tools matching a query using BM25
|
||||||
|
func searchTools(query: String, limit: Int = 10) async throws -> [ToolMeta] {
|
||||||
|
var components = URLComponents(string: "\(registryBaseURL)/api/tools/search")!
|
||||||
|
components.queryItems = [
|
||||||
|
URLQueryItem(name: "q", value: query),
|
||||||
|
URLQueryItem(name: "limit", value: String(limit)),
|
||||||
|
]
|
||||||
|
|
||||||
|
guard let url = components.url else {
|
||||||
|
throw TPMJSError.invalidURL
|
||||||
|
}
|
||||||
|
|
||||||
|
let (data, response) = try await session.data(from: url)
|
||||||
|
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse,
|
||||||
|
httpResponse.statusCode == 200 else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
let searchResponse = try JSONDecoder().decode(TPMJSSearchResponse.self, from: data)
|
||||||
|
let tools = searchResponse.results?.tools ?? []
|
||||||
|
|
||||||
|
return tools.map { tool in
|
||||||
|
ToolMeta(
|
||||||
|
toolId: "\(tool.package.npmPackageName)::\(tool.name)",
|
||||||
|
packageName: tool.package.npmPackageName,
|
||||||
|
name: tool.name,
|
||||||
|
description: tool.description ?? "Tool: \(tool.name)",
|
||||||
|
version: tool.package.npmVersion,
|
||||||
|
importUrl: tool.importUrl ?? "https://esm.sh/\(tool.package.npmPackageName)@\(tool.package.npmVersion)",
|
||||||
|
inputSchema: tool.inputSchema,
|
||||||
|
env: tool.package.env
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Execute via Executor
|
||||||
|
|
||||||
|
/// Execute a tool via the TPMJS remote sandbox executor
|
||||||
|
func executeTool(
|
||||||
|
packageName: String,
|
||||||
|
name: String,
|
||||||
|
version: String,
|
||||||
|
importUrl: String,
|
||||||
|
params: JSONValue,
|
||||||
|
env: [String: String]
|
||||||
|
) async throws -> TPMJSExecuteResponse {
|
||||||
|
guard let url = URL(string: "\(executorBaseURL)/execute-tool") else {
|
||||||
|
throw TPMJSError.invalidURL
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
|
||||||
|
let body = TPMJSExecuteRequest(
|
||||||
|
packageName: packageName,
|
||||||
|
name: name,
|
||||||
|
version: version,
|
||||||
|
importUrl: importUrl,
|
||||||
|
params: params,
|
||||||
|
env: env
|
||||||
|
)
|
||||||
|
|
||||||
|
request.httpBody = try JSONEncoder().encode(body)
|
||||||
|
|
||||||
|
let (data, response) = try await session.data(for: request)
|
||||||
|
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse,
|
||||||
|
httpResponse.statusCode == 200 else {
|
||||||
|
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1
|
||||||
|
throw TPMJSError.httpError(statusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return try JSONDecoder().decode(TPMJSExecuteResponse.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Registry Execute (uses search first to find metadata)
|
||||||
|
|
||||||
|
/// Execute a tool by its toolId (package::name format).
|
||||||
|
/// Fetches metadata first via search, then executes via executor.
|
||||||
|
func executeByToolId(
|
||||||
|
toolId: String,
|
||||||
|
params: JSONValue,
|
||||||
|
env: [String: String]
|
||||||
|
) async throws -> TPMJSExecuteResponse {
|
||||||
|
// Parse toolId format: "package::name"
|
||||||
|
guard let separatorIndex = toolId.range(of: "::", options: .backwards) else {
|
||||||
|
throw TPMJSError.invalidToolId(toolId)
|
||||||
|
}
|
||||||
|
|
||||||
|
let packageName = String(toolId[toolId.startIndex..<separatorIndex.lowerBound])
|
||||||
|
let name = String(toolId[separatorIndex.upperBound...])
|
||||||
|
|
||||||
|
guard !packageName.isEmpty, !name.isEmpty else {
|
||||||
|
throw TPMJSError.invalidToolId(toolId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for the tool to get version metadata
|
||||||
|
let searchResults = try await searchTools(query: name, limit: 10)
|
||||||
|
guard let toolMeta = searchResults.first(where: {
|
||||||
|
$0.packageName == packageName && $0.name == name
|
||||||
|
}) else {
|
||||||
|
throw TPMJSError.toolNotFound(toolId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return try await executeTool(
|
||||||
|
packageName: toolMeta.packageName,
|
||||||
|
name: toolMeta.name,
|
||||||
|
version: toolMeta.version,
|
||||||
|
importUrl: toolMeta.importUrl,
|
||||||
|
params: params,
|
||||||
|
env: env
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Errors
|
||||||
|
|
||||||
|
enum TPMJSError: LocalizedError {
|
||||||
|
case invalidURL
|
||||||
|
case httpError(Int)
|
||||||
|
case invalidToolId(String)
|
||||||
|
case toolNotFound(String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidURL: return "Invalid URL"
|
||||||
|
case .httpError(let code): return "HTTP error: \(code)"
|
||||||
|
case .invalidToolId(let id): return "Invalid tool ID format: \(id). Expected 'package::name'"
|
||||||
|
case .toolNotFound(let id): return "Tool not found: \(id). Try using registrySearch to find available tools."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
123
apps/omega-mac/OmegaMac/Services/TPMJSTypes.swift
Normal file
123
apps/omega-mac/OmegaMac/Services/TPMJSTypes.swift
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Search API Types
|
||||||
|
|
||||||
|
struct TPMJSSearchResponse: Decodable {
|
||||||
|
let results: TPMJSSearchResults?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TPMJSSearchResults: Decodable {
|
||||||
|
let tools: [TPMJSToolResult]?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TPMJSToolResult: Decodable {
|
||||||
|
let name: String
|
||||||
|
let description: String?
|
||||||
|
let inputSchema: JSONValue?
|
||||||
|
let qualityScore: Double?
|
||||||
|
let executionHealth: String?
|
||||||
|
let importUrl: String?
|
||||||
|
let package: TPMJSPackageInfo
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case name, description, inputSchema, qualityScore, executionHealth, importUrl
|
||||||
|
case package = "package"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TPMJSPackageInfo: Decodable {
|
||||||
|
let npmPackageName: String
|
||||||
|
let npmVersion: String
|
||||||
|
let category: String?
|
||||||
|
let env: [String]?
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Executor API Types
|
||||||
|
|
||||||
|
struct TPMJSExecuteRequest: Encodable {
|
||||||
|
let packageName: String
|
||||||
|
let name: String
|
||||||
|
let version: String
|
||||||
|
let importUrl: String
|
||||||
|
let params: JSONValue
|
||||||
|
let env: [String: String]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TPMJSExecuteResponse: Decodable, Sendable {
|
||||||
|
let success: Bool
|
||||||
|
let output: JSONValue?
|
||||||
|
let error: String?
|
||||||
|
let executionTimeMs: Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tool Metadata (internal tracking)
|
||||||
|
|
||||||
|
struct ToolMeta: Sendable {
|
||||||
|
let toolId: String
|
||||||
|
let packageName: String
|
||||||
|
let name: String
|
||||||
|
let description: String
|
||||||
|
let version: String
|
||||||
|
let importUrl: String
|
||||||
|
let inputSchema: JSONValue?
|
||||||
|
let env: [String]?
|
||||||
|
|
||||||
|
/// Convert to an OpenAI function tool definition
|
||||||
|
func toChatTool() -> ChatTool {
|
||||||
|
let properties: [String: JSONSchemaProperty]
|
||||||
|
let required: [String]?
|
||||||
|
|
||||||
|
if case .object(let schemaObj) = inputSchema {
|
||||||
|
// Extract properties from schema
|
||||||
|
var props: [String: JSONSchemaProperty] = [:]
|
||||||
|
var reqs: [String] = []
|
||||||
|
|
||||||
|
if case .object(let propsObj) = schemaObj["properties"] {
|
||||||
|
for (key, value) in propsObj {
|
||||||
|
if case .object(let propDef) = value {
|
||||||
|
let typeStr: String
|
||||||
|
if case .string(let t) = propDef["type"] {
|
||||||
|
typeStr = t
|
||||||
|
} else {
|
||||||
|
typeStr = "string"
|
||||||
|
}
|
||||||
|
let desc: String?
|
||||||
|
if case .string(let d) = propDef["description"] {
|
||||||
|
desc = d
|
||||||
|
} else {
|
||||||
|
desc = nil
|
||||||
|
}
|
||||||
|
props[key] = JSONSchemaProperty(type: typeStr, description: desc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if case .array(let reqArr) = schemaObj["required"] {
|
||||||
|
for item in reqArr {
|
||||||
|
if case .string(let s) = item {
|
||||||
|
reqs.append(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
properties = props
|
||||||
|
required = reqs.isEmpty ? nil : reqs
|
||||||
|
} else {
|
||||||
|
properties = [:]
|
||||||
|
required = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return ChatTool(
|
||||||
|
function: ChatFunction(
|
||||||
|
name: sanitizeToolName(toolId),
|
||||||
|
description: description,
|
||||||
|
parameters: JSONSchemaObject(
|
||||||
|
type: "object",
|
||||||
|
properties: properties,
|
||||||
|
required: required,
|
||||||
|
additionalProperties: true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
40
apps/omega-mac/OmegaMac/Utilities/SanitizeToolName.swift
Normal file
40
apps/omega-mac/OmegaMac/Utilities/SanitizeToolName.swift
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Sanitize a tool ID to be a valid OpenAI function name.
|
||||||
|
/// Port of the web's sanitizeToolName logic.
|
||||||
|
/// OpenAI requires tool names to be <= 64 characters and match [a-zA-Z0-9_-].
|
||||||
|
func sanitizeToolName(_ name: String) -> String {
|
||||||
|
var sanitized = name
|
||||||
|
.replacingOccurrences(of: "@", with: "")
|
||||||
|
.replacingOccurrences(of: "/", with: "_")
|
||||||
|
.replacingOccurrences(of: "-", with: "_")
|
||||||
|
.replacingOccurrences(of: "::", with: "_")
|
||||||
|
|
||||||
|
// Remove any remaining invalid characters
|
||||||
|
sanitized = String(sanitized.unicodeScalars.filter { scalar in
|
||||||
|
CharacterSet.alphanumerics.contains(scalar) || scalar == "_"
|
||||||
|
})
|
||||||
|
|
||||||
|
// OpenAI API requires tool names <= 64 characters
|
||||||
|
if sanitized.count <= 64 {
|
||||||
|
return sanitized
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate but try to keep the meaningful part (tool name at the end)
|
||||||
|
let last64 = String(sanitized.suffix(64))
|
||||||
|
if let first = last64.first, first.isLetter {
|
||||||
|
return last64
|
||||||
|
}
|
||||||
|
return String(sanitized.prefix(64))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reverse lookup: find the original toolId from a sanitized name
|
||||||
|
/// by checking against loaded tool metadata.
|
||||||
|
func findToolId(sanitizedName: String, in tools: [String: ToolMeta]) -> String? {
|
||||||
|
for (_, meta) in tools {
|
||||||
|
if sanitizeToolName(meta.toolId) == sanitizedName {
|
||||||
|
return meta.toolId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
134
apps/omega-mac/OmegaMac/Utilities/SystemPromptBuilder.swift
Normal file
134
apps/omega-mac/OmegaMac/Utilities/SystemPromptBuilder.swift
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Builds the system prompt for the Omega agent.
|
||||||
|
/// Port of the web's buildSystemPrompt logic from system-prompt.ts.
|
||||||
|
enum SystemPromptBuilder {
|
||||||
|
|
||||||
|
static let basePrompt = """
|
||||||
|
You are Omega, an AI assistant powered by the TPMJS tool registry - a collection of 1M+ AI-ready tools.
|
||||||
|
|
||||||
|
## Core Tools
|
||||||
|
|
||||||
|
You have access to two powerful meta-tools that give you access to the entire TPMJS registry:
|
||||||
|
|
||||||
|
1. **registrySearch** - Search for tools by keyword, category, or description
|
||||||
|
2. **registryExecute** - Execute any tool by its toolId
|
||||||
|
|
||||||
|
These tools are importable by users into their own AI agents via:
|
||||||
|
```typescript
|
||||||
|
import { registrySearchTool } from '@tpmjs/registry-search';
|
||||||
|
import { registryExecuteTool } from '@tpmjs/registry-execute';
|
||||||
|
```
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. When the user asks for something, relevant tools are automatically discovered and loaded
|
||||||
|
2. You can also explicitly search using registrySearch
|
||||||
|
3. Once tools are found, you have two options:
|
||||||
|
- Use registryExecute with the toolId to execute any tool
|
||||||
|
- Call dynamically loaded tools directly by their sanitized name
|
||||||
|
|
||||||
|
## Workflow Examples
|
||||||
|
|
||||||
|
### Example 1: User wants weather data
|
||||||
|
1. Call registrySearch({ query: "weather api" })
|
||||||
|
2. Review the results (toolIds like "@weather-api/sdk::getWeather")
|
||||||
|
3. Call registryExecute({ toolId: "@weather-api/sdk::getWeather", params: { city: "Tokyo" } })
|
||||||
|
4. Explain the result to the user
|
||||||
|
|
||||||
|
### Example 2: Tool already loaded
|
||||||
|
If you see a tool like "weatherapi_sdk_getWeather" in the dynamically loaded tools list, call it directly instead of using registryExecute.
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
- **Search first** - If you don't see a relevant tool loaded, use registrySearch
|
||||||
|
- **Execute don't describe** - Actually call tools to get real results
|
||||||
|
- **Handle errors** - If a tool fails, explain and try an alternative
|
||||||
|
- **Be efficient** - If a tool is already loaded, call it directly
|
||||||
|
|
||||||
|
## Response Style
|
||||||
|
|
||||||
|
- Keep responses concise and helpful
|
||||||
|
- Present tool outputs in a clear, readable format
|
||||||
|
- Tell the user which tool you used
|
||||||
|
- Offer to do more if the user might need it
|
||||||
|
|
||||||
|
Remember: Your value is in EXECUTING tools to get real results, not describing what tools could do.
|
||||||
|
"""
|
||||||
|
|
||||||
|
/// Build the complete system prompt with tool listings and user customizations
|
||||||
|
static func build(
|
||||||
|
customSystemPrompt: String?,
|
||||||
|
pinnedToolIds: [String],
|
||||||
|
loadedTools: [String: ToolMeta]
|
||||||
|
) -> String {
|
||||||
|
var parts: [String] = [basePrompt]
|
||||||
|
|
||||||
|
// Pinned tools
|
||||||
|
if !pinnedToolIds.isEmpty {
|
||||||
|
let pinned = pinnedToolIds.map { "- Tool ID: \($0)" }.joined(separator: "\n")
|
||||||
|
parts.append("""
|
||||||
|
## Pinned Tools
|
||||||
|
|
||||||
|
The user has pinned the following tools as favorites. Consider using these first when they match the task:
|
||||||
|
\(pinned)
|
||||||
|
""")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom system prompt
|
||||||
|
if let custom = customSystemPrompt, !custom.isEmpty {
|
||||||
|
parts.append("""
|
||||||
|
## User Instructions
|
||||||
|
|
||||||
|
The user has provided the following custom instructions:
|
||||||
|
|
||||||
|
\(custom)
|
||||||
|
""")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static tools
|
||||||
|
let staticToolsList = """
|
||||||
|
- registrySearch: Search the TPMJS registry to find AI SDK tools by keyword. Returns toolIds for registryExecute.
|
||||||
|
- registryExecute: Execute any tool from the TPMJS registry by toolId. Use registrySearch first to find tools.
|
||||||
|
"""
|
||||||
|
|
||||||
|
parts.append("""
|
||||||
|
## Static Tools (Always Available)
|
||||||
|
|
||||||
|
These tools let you access the entire TPMJS registry of 1M+ tools:
|
||||||
|
|
||||||
|
\(staticToolsList)
|
||||||
|
""")
|
||||||
|
|
||||||
|
// Dynamic tools
|
||||||
|
let dynamicToolsList: String
|
||||||
|
if loadedTools.isEmpty {
|
||||||
|
dynamicToolsList = "No tools loaded yet. Use registrySearch to find tools, or they will be auto-loaded based on your requests."
|
||||||
|
} else {
|
||||||
|
dynamicToolsList = loadedTools.map { (name, meta) in
|
||||||
|
"- \(name): \(meta.description)"
|
||||||
|
}.joined(separator: "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.append("""
|
||||||
|
## Dynamically Loaded Tools
|
||||||
|
|
||||||
|
These tools have been discovered and loaded for this conversation. Call them directly:
|
||||||
|
|
||||||
|
\(dynamicToolsList)
|
||||||
|
""")
|
||||||
|
|
||||||
|
// Usage instructions
|
||||||
|
parts.append("""
|
||||||
|
## How to Use Tools
|
||||||
|
|
||||||
|
1. **To find a tool**: Use registrySearch with a keyword (e.g., "weather", "web scraping", "database")
|
||||||
|
2. **To execute a found tool**: Use registryExecute with the toolId returned from search
|
||||||
|
3. **Direct execution**: If a tool is already loaded above, call it directly by name
|
||||||
|
|
||||||
|
Remember: Your value is in EXECUTING tools to get real results, not just describing what tools could do.
|
||||||
|
""")
|
||||||
|
|
||||||
|
return parts.joined(separator: "\n\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
119
apps/omega-mac/OmegaMac/Views/Chat/ChatInputBar.swift
Normal file
119
apps/omega-mac/OmegaMac/Views/Chat/ChatInputBar.swift
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ChatInputBar: View {
|
||||||
|
@Binding var text: String
|
||||||
|
let isStreaming: Bool
|
||||||
|
let onSend: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
HStack(alignment: .bottom, spacing: 8) {
|
||||||
|
SendableTextEditor(text: $text, onSend: {
|
||||||
|
if canSend { onSend() }
|
||||||
|
})
|
||||||
|
.font(.body)
|
||||||
|
.frame(minHeight: 40, maxHeight: 160)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
|
Button(action: onSend) {
|
||||||
|
Image(systemName: "arrow.up.circle.fill")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(canSend ? Color.accentColor : Color.secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.disabled(!canSend)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
|
||||||
|
Text("Enter to send, Shift+Enter for new line")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
}
|
||||||
|
.background(.background)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var canSend: Bool {
|
||||||
|
!isStreaming && !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// NSTextView-backed editor that intercepts Return (send) vs Shift+Return (newline)
|
||||||
|
struct SendableTextEditor: NSViewRepresentable {
|
||||||
|
@Binding var text: String
|
||||||
|
let onSend: () -> Void
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator {
|
||||||
|
Coordinator(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeNSView(context: Context) -> NSScrollView {
|
||||||
|
let scrollView = NSScrollView()
|
||||||
|
let textView = SendableNSTextView()
|
||||||
|
textView.delegate = context.coordinator
|
||||||
|
textView.sendAction = onSend
|
||||||
|
textView.isRichText = false
|
||||||
|
textView.allowsUndo = true
|
||||||
|
textView.font = .systemFont(ofSize: NSFont.systemFontSize)
|
||||||
|
textView.textColor = .labelColor
|
||||||
|
textView.drawsBackground = false
|
||||||
|
textView.isVerticallyResizable = true
|
||||||
|
textView.isHorizontallyResizable = false
|
||||||
|
textView.textContainerInset = NSSize(width: 8, height: 8)
|
||||||
|
textView.textContainer?.widthTracksTextView = true
|
||||||
|
textView.autoresizingMask = [.width]
|
||||||
|
|
||||||
|
scrollView.documentView = textView
|
||||||
|
scrollView.hasVerticalScroller = false
|
||||||
|
scrollView.drawsBackground = false
|
||||||
|
scrollView.borderType = .noBorder
|
||||||
|
scrollView.contentView.drawsBackground = false
|
||||||
|
|
||||||
|
// Style the scroll view as a rounded input field
|
||||||
|
scrollView.wantsLayer = true
|
||||||
|
scrollView.layer?.cornerRadius = 10
|
||||||
|
scrollView.layer?.backgroundColor = NSColor.quaternaryLabelColor.withAlphaComponent(0.3).cgColor
|
||||||
|
|
||||||
|
return scrollView
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateNSView(_ scrollView: NSScrollView, context: Context) {
|
||||||
|
guard let textView = scrollView.documentView as? NSTextView else { return }
|
||||||
|
if textView.string != text {
|
||||||
|
textView.string = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Coordinator: NSObject, NSTextViewDelegate {
|
||||||
|
var parent: SendableTextEditor
|
||||||
|
|
||||||
|
init(_ parent: SendableTextEditor) {
|
||||||
|
self.parent = parent
|
||||||
|
}
|
||||||
|
|
||||||
|
func textDidChange(_ notification: Notification) {
|
||||||
|
guard let textView = notification.object as? NSTextView else { return }
|
||||||
|
parent.text = textView.string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Custom NSTextView that sends on Return and inserts newline on Shift+Return
|
||||||
|
class SendableNSTextView: NSTextView {
|
||||||
|
var sendAction: (() -> Void)?
|
||||||
|
|
||||||
|
override func keyDown(with event: NSEvent) {
|
||||||
|
if event.keyCode == 36 { // Return key
|
||||||
|
if event.modifierFlags.contains(.shift) {
|
||||||
|
super.keyDown(with: event) // Insert newline
|
||||||
|
} else {
|
||||||
|
sendAction?()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
super.keyDown(with: event)
|
||||||
|
}
|
||||||
|
}
|
||||||
129
apps/omega-mac/OmegaMac/Views/Chat/ChatView.swift
Normal file
129
apps/omega-mac/OmegaMac/Views/Chat/ChatView.swift
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
import AppKit
|
||||||
|
import SwiftData
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ChatView: View {
|
||||||
|
@Bindable var conversation: Conversation
|
||||||
|
var orchestrator: ChatOrchestrator
|
||||||
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
|
||||||
|
@State private var inputText: String = ""
|
||||||
|
@State private var showCopied: Bool = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Messages
|
||||||
|
MessageList(
|
||||||
|
conversation: conversation,
|
||||||
|
streamingContent: orchestrator.streamingContent,
|
||||||
|
isStreaming: orchestrator.isStreaming,
|
||||||
|
liveToolCalls: orchestrator.liveToolCalls
|
||||||
|
)
|
||||||
|
|
||||||
|
// Error banner
|
||||||
|
if let error = orchestrator.error {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
Text(error)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
Spacer()
|
||||||
|
Button("Dismiss") {
|
||||||
|
orchestrator.error = nil
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.font(.callout)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(.red.opacity(0.1))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input bar
|
||||||
|
ChatInputBar(
|
||||||
|
text: $inputText,
|
||||||
|
isStreaming: orchestrator.isStreaming,
|
||||||
|
onSend: sendMessage
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.navigationTitle(conversation.displayTitle)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .automatic) {
|
||||||
|
if orchestrator.isStreaming {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .automatic) {
|
||||||
|
Button(action: copyConversationAsJSON) {
|
||||||
|
Label(showCopied ? "Copied!" : "Copy JSON",
|
||||||
|
systemImage: showCopied ? "checkmark" : "doc.on.doc")
|
||||||
|
}
|
||||||
|
.help("Copy conversation as JSON (Cmd+Shift+C)")
|
||||||
|
.keyboardShortcut("c", modifiers: [.command, .shift])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendMessage() {
|
||||||
|
let text = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !text.isEmpty else { return }
|
||||||
|
inputText = ""
|
||||||
|
|
||||||
|
Task {
|
||||||
|
await orchestrator.sendMessage(text, conversation: conversation, modelContext: modelContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func copyConversationAsJSON() {
|
||||||
|
let messages = conversation.sortedMessages.map { msg -> [String: Any] in
|
||||||
|
var dict: [String: Any] = [
|
||||||
|
"role": msg.role.rawValue.lowercased(),
|
||||||
|
"content": msg.content,
|
||||||
|
"createdAt": ISO8601DateFormatter().string(from: msg.createdAt),
|
||||||
|
]
|
||||||
|
if let input = msg.inputTokens { dict["inputTokens"] = input }
|
||||||
|
if let output = msg.outputTokens { dict["outputTokens"] = output }
|
||||||
|
|
||||||
|
let toolCalls = msg.toolCalls
|
||||||
|
if !toolCalls.isEmpty {
|
||||||
|
dict["toolCalls"] = toolCalls.map { tc -> [String: Any] in
|
||||||
|
var tcDict: [String: Any] = [
|
||||||
|
"toolCallId": tc.toolCallId,
|
||||||
|
"toolName": tc.toolName,
|
||||||
|
]
|
||||||
|
if let args = tc.args,
|
||||||
|
let data = try? JSONEncoder().encode(args),
|
||||||
|
let json = try? JSONSerialization.jsonObject(with: data) {
|
||||||
|
tcDict["args"] = json
|
||||||
|
}
|
||||||
|
if let output = tc.output,
|
||||||
|
let data = try? JSONEncoder().encode(output),
|
||||||
|
let json = try? JSONSerialization.jsonObject(with: data) {
|
||||||
|
tcDict["output"] = json
|
||||||
|
}
|
||||||
|
return tcDict
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dict
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload: [String: Any] = [
|
||||||
|
"conversationId": conversation.id.uuidString,
|
||||||
|
"title": conversation.displayTitle,
|
||||||
|
"createdAt": ISO8601DateFormatter().string(from: conversation.createdAt),
|
||||||
|
"messages": messages,
|
||||||
|
]
|
||||||
|
|
||||||
|
if let data = try? JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys]),
|
||||||
|
let json = String(data: data, encoding: .utf8) {
|
||||||
|
NSPasteboard.general.clearContents()
|
||||||
|
NSPasteboard.general.setString(json, forType: .string)
|
||||||
|
showCopied = true
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||||
|
showCopied = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
69
apps/omega-mac/OmegaMac/Views/Chat/MessageBubble.swift
Normal file
69
apps/omega-mac/OmegaMac/Views/Chat/MessageBubble.swift
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MessageBubble: View {
|
||||||
|
let message: Message
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
switch message.role {
|
||||||
|
case .user:
|
||||||
|
userBubble
|
||||||
|
case .assistant:
|
||||||
|
assistantBubble
|
||||||
|
case .tool:
|
||||||
|
toolResultsBubble
|
||||||
|
case .system:
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var userBubble: some View {
|
||||||
|
HStack(alignment: .top) {
|
||||||
|
Spacer(minLength: 60)
|
||||||
|
Text(message.content)
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(12)
|
||||||
|
.background(Color.accentColor)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var assistantBubble: some View {
|
||||||
|
HStack(alignment: .top) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
if !message.content.isEmpty {
|
||||||
|
MarkdownView(content: message.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token usage
|
||||||
|
if let input = message.inputTokens, let output = message.outputTokens {
|
||||||
|
Text("In: \(input) | Out: \(output)")
|
||||||
|
.font(.system(size: 10, design: .monospaced))
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(.quaternary.opacity(0.5))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.textSelection(.enabled)
|
||||||
|
|
||||||
|
Spacer(minLength: 60)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var toolResultsBubble: some View {
|
||||||
|
HStack(alignment: .top) {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
ForEach(message.toolCalls) { tc in
|
||||||
|
JSONToolResultView(toolCallData: tc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(minLength: 60)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
}
|
||||||
|
}
|
||||||
95
apps/omega-mac/OmegaMac/Views/Chat/MessageList.swift
Normal file
95
apps/omega-mac/OmegaMac/Views/Chat/MessageList.swift
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MessageList: View {
|
||||||
|
let conversation: Conversation
|
||||||
|
let streamingContent: String
|
||||||
|
let isStreaming: Bool
|
||||||
|
let liveToolCalls: [LiveToolCall]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollViewReader { proxy in
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(spacing: 12) {
|
||||||
|
ForEach(conversation.sortedMessages) { message in
|
||||||
|
MessageBubble(message: message)
|
||||||
|
.id(message.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live tool calls
|
||||||
|
ForEach(isStreaming ? liveToolCalls : []) { tc in
|
||||||
|
ToolCallView(toolCall: tc)
|
||||||
|
.id("live-tc-\(tc.id)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Streaming content
|
||||||
|
if isStreaming && !streamingContent.isEmpty {
|
||||||
|
HStack(alignment: .top) {
|
||||||
|
assistantBubble(content: streamingContent, isStreaming: true)
|
||||||
|
Spacer(minLength: 60)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.id("streaming")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thinking indicator
|
||||||
|
if isStreaming && streamingContent.isEmpty && liveToolCalls.isEmpty {
|
||||||
|
HStack {
|
||||||
|
StreamingIndicator()
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.id("thinking")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom spacer for scroll padding
|
||||||
|
Color.clear.frame(height: 8)
|
||||||
|
.id("bottom")
|
||||||
|
}
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.onChange(of: streamingContent) {
|
||||||
|
withAnimation(.easeOut(duration: 0.15)) {
|
||||||
|
proxy.scrollTo("bottom", anchor: .bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: conversation.messages.count) {
|
||||||
|
withAnimation(.easeOut(duration: 0.15)) {
|
||||||
|
proxy.scrollTo("bottom", anchor: .bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: liveToolCalls.count) {
|
||||||
|
withAnimation(.easeOut(duration: 0.15)) {
|
||||||
|
proxy.scrollTo("bottom", anchor: .bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func assistantBubble(content: String, isStreaming: Bool) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
MarkdownView(content: content)
|
||||||
|
|
||||||
|
if isStreaming {
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color.accentColor)
|
||||||
|
.frame(width: 2, height: 16)
|
||||||
|
.opacity(0.8)
|
||||||
|
.modifier(PulseAnimation())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(.quaternary.opacity(0.5))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PulseAnimation: ViewModifier {
|
||||||
|
@State private var isAnimating = false
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.opacity(isAnimating ? 0.3 : 1.0)
|
||||||
|
.animation(.easeInOut(duration: 0.6).repeatForever(autoreverses: true), value: isAnimating)
|
||||||
|
.onAppear { isAnimating = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
24
apps/omega-mac/OmegaMac/Views/Chat/StreamingIndicator.swift
Normal file
24
apps/omega-mac/OmegaMac/Views/Chat/StreamingIndicator.swift
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct StreamingIndicator: View {
|
||||||
|
@State private var dotCount = 0
|
||||||
|
private let timer = Timer.publish(every: 0.4, on: .main, in: .common).autoconnect()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "sparkles")
|
||||||
|
.foregroundStyle(Color.accentColor)
|
||||||
|
.font(.caption)
|
||||||
|
|
||||||
|
Text("Omega is thinking" + String(repeating: ".", count: dotCount))
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(.quaternary.opacity(0.5))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.onReceive(timer) { _ in
|
||||||
|
dotCount = (dotCount + 1) % 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue