mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 14:01:24 +08:00
Compare commits
507 Commits
v2026.5.25
...
codex/spli
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dde017027c | ||
|
|
2b63eb2825 | ||
|
|
6930538500 | ||
|
|
cd46057b90 | ||
|
|
8c575bd3c8 | ||
|
|
598aad4f66 | ||
|
|
1fd8de8495 | ||
|
|
564e0bb5c1 | ||
|
|
c867ecb136 | ||
|
|
9fd8158c06 | ||
|
|
7a147419db | ||
|
|
a5eee8f1c6 | ||
|
|
3c6fd49d74 | ||
|
|
e8f584e400 | ||
|
|
7e6837bc07 | ||
|
|
0ec29289c6 | ||
|
|
82dae95c76 | ||
|
|
c147e27f5a | ||
|
|
081e29595e | ||
|
|
6c18c212e9 | ||
|
|
9e43d0327f | ||
|
|
5535eef6b0 | ||
|
|
84b9704ccc | ||
|
|
27359ec417 | ||
|
|
cf21c8abcb | ||
|
|
c84f61cd2e | ||
|
|
fdb7848a7c | ||
|
|
496fd8f853 | ||
|
|
373b3bfe54 | ||
|
|
d5bf325126 | ||
|
|
645cbf6c33 | ||
|
|
12b81d8978 | ||
|
|
06afc57102 | ||
|
|
c7821bd2a8 | ||
|
|
9ced76a4bb | ||
|
|
7671068daf | ||
|
|
ead847f606 | ||
|
|
b7c461af7b | ||
|
|
0973a7e4e4 | ||
|
|
d001d35ea2 | ||
|
|
d6fcb562f4 | ||
|
|
6118f3f615 | ||
|
|
fb853de554 | ||
|
|
e96cde7e14 | ||
|
|
5ef812293b | ||
|
|
0f605ee003 | ||
|
|
e89afa6afa | ||
|
|
dc0d4c263e | ||
|
|
d54c90699f | ||
|
|
4ff5a6152c | ||
|
|
cf6f9ad8a3 | ||
|
|
19e4c37c37 | ||
|
|
35310dce8c | ||
|
|
8685dbd547 | ||
|
|
d1c8f09b00 | ||
|
|
42ba297b0a | ||
|
|
4d4e2ec256 | ||
|
|
cac0b2db18 | ||
|
|
45feb37b13 | ||
|
|
ce61d224d8 | ||
|
|
c38b5033e6 | ||
|
|
0cca7861c1 | ||
|
|
d0dd8b8a41 | ||
|
|
295b5ea9ab | ||
|
|
8c7f226401 | ||
|
|
e37ac22fdd | ||
|
|
50c7d780dc | ||
|
|
4c6aeb9bb2 | ||
|
|
9777526eaa | ||
|
|
84e4bff73b | ||
|
|
13f72e4102 | ||
|
|
a17ac3ec9d | ||
|
|
e549d0c235 | ||
|
|
8d6a6e9f89 | ||
|
|
df13d3a724 | ||
|
|
a07dc3896b | ||
|
|
30e59b4090 | ||
|
|
dfe94ff048 | ||
|
|
419178b9bc | ||
|
|
efebf6bfcf | ||
|
|
cb34175dfd | ||
|
|
884d346999 | ||
|
|
13c6a3332c | ||
|
|
a3bb4fe814 | ||
|
|
31a8fe7462 | ||
|
|
92fb79ee69 | ||
|
|
30c4489af4 | ||
|
|
94a04e1aa6 | ||
|
|
8307e2f762 | ||
|
|
5b49433535 | ||
|
|
c2b1d20c25 | ||
|
|
18ff19e043 | ||
|
|
f0599fddac | ||
|
|
fe9f28f520 | ||
|
|
71e7a1fd7d | ||
|
|
92082723f7 | ||
|
|
e20b8d70a6 | ||
|
|
198d0a56d3 | ||
|
|
11512b1257 | ||
|
|
d1f2eb0709 | ||
|
|
e8cb2b5ab3 | ||
|
|
dcf0941cd6 | ||
|
|
da16a966c3 | ||
|
|
4ebc13abe1 | ||
|
|
f1ceed94db | ||
|
|
68f877ef66 | ||
|
|
1c5b8353d6 | ||
|
|
7aedff8fbb | ||
|
|
f2ad94ec9a | ||
|
|
8e110a2122 | ||
|
|
4c8e9da033 | ||
|
|
55af31e0c6 | ||
|
|
4f1cd8eb00 | ||
|
|
e295c86dbc | ||
|
|
91080fde68 | ||
|
|
4838e704a0 | ||
|
|
21aebd5fbc | ||
|
|
29919cbec5 | ||
|
|
90bcec9fa4 | ||
|
|
0e733795f4 | ||
|
|
99032f0354 | ||
|
|
f63754b314 | ||
|
|
b34e1b32d8 | ||
|
|
9434228cdc | ||
|
|
21000a3da7 | ||
|
|
3f6b63aa1d | ||
|
|
c5530c798c | ||
|
|
d3bbfa1f5a | ||
|
|
a5653c0ce9 | ||
|
|
b93cee45d0 | ||
|
|
3548cff14b | ||
|
|
b377618fae | ||
|
|
437a9e9171 | ||
|
|
abc7b7b331 | ||
|
|
2e17003165 | ||
|
|
918472a27b | ||
|
|
4a1d772f3d | ||
|
|
4beadbf951 | ||
|
|
6c5b39291f | ||
|
|
3b023e9bdb | ||
|
|
a3cd90fb5a | ||
|
|
17f7ef5c0f | ||
|
|
41eef4a796 | ||
|
|
a46556a6c2 | ||
|
|
81f62a689b | ||
|
|
083377adb8 | ||
|
|
4b03e07294 | ||
|
|
16d137dce6 | ||
|
|
3452382cc0 | ||
|
|
11b1b7c888 | ||
|
|
5c3fb1f9d1 | ||
|
|
c04c03f8e9 | ||
|
|
505aca9ef7 | ||
|
|
5174d9744e | ||
|
|
23e9bc8c0b | ||
|
|
711e963723 | ||
|
|
7db4b3db41 | ||
|
|
c14c043be7 | ||
|
|
3bb4be23c0 | ||
|
|
72a7d6a8dc | ||
|
|
e752f9bca1 | ||
|
|
c43ed9e4fe | ||
|
|
1e9b6b7627 | ||
|
|
a9bf582684 | ||
|
|
21aefb877a | ||
|
|
c4f0682396 | ||
|
|
4118a32aad | ||
|
|
4fdf61753a | ||
|
|
bc3d6bafae | ||
|
|
17ab9b967c | ||
|
|
947febb2fb | ||
|
|
bee8ad34a0 | ||
|
|
7fbca96a0c | ||
|
|
bcde7b138a | ||
|
|
0d23c3b4e1 | ||
|
|
a695c28bfb | ||
|
|
c9d0464ed1 | ||
|
|
5a33378f9c | ||
|
|
609d70d35e | ||
|
|
4738d0a296 | ||
|
|
34d862d45d | ||
|
|
f32273257c | ||
|
|
eab8d29db2 | ||
|
|
93015982d3 | ||
|
|
6f57286678 | ||
|
|
0c5f622f9a | ||
|
|
3d0659433e | ||
|
|
fddca995e8 | ||
|
|
2e6ba44706 | ||
|
|
6984a823af | ||
|
|
743bce2c27 | ||
|
|
f824e1596a | ||
|
|
592f192bf0 | ||
|
|
010a79b5d8 | ||
|
|
8f1f7901b9 | ||
|
|
c410658725 | ||
|
|
e049105891 | ||
|
|
f2142ebf3a | ||
|
|
669df88249 | ||
|
|
c9364f03dc | ||
|
|
24d58af560 | ||
|
|
6421808c27 | ||
|
|
80aa6d77fc | ||
|
|
d00d0a21c2 | ||
|
|
321f06ad0e | ||
|
|
ee51169b20 | ||
|
|
9e93431ae9 | ||
|
|
56633e4f3c | ||
|
|
ea2496b00c | ||
|
|
ef8619d5f5 | ||
|
|
71e9eaab14 | ||
|
|
c59635ae97 | ||
|
|
6814525867 | ||
|
|
1514cc84cb | ||
|
|
6defcb0a40 | ||
|
|
60afca187d | ||
|
|
719ce7f96f | ||
|
|
57748a66fd | ||
|
|
2a6b4ed3e2 | ||
|
|
978a2d01da | ||
|
|
3a4f2b17fc | ||
|
|
6c7b3f3f23 | ||
|
|
068924e2d4 | ||
|
|
5dc704361f | ||
|
|
bef0ba8f5a | ||
|
|
84929e4265 | ||
|
|
c87957db5e | ||
|
|
65a210553b | ||
|
|
fe3374789f | ||
|
|
da831e2b8a | ||
|
|
399c692895 | ||
|
|
fc2d2d595c | ||
|
|
342bde2af6 | ||
|
|
d7361eff66 | ||
|
|
c1a026a976 | ||
|
|
1d21224de3 | ||
|
|
a4f12699cf | ||
|
|
acbdb8c373 | ||
|
|
00f9809531 | ||
|
|
bec7d56b73 | ||
|
|
68ab48b179 | ||
|
|
ec7ad3b4ac | ||
|
|
1531fe2525 | ||
|
|
0164fd5e99 | ||
|
|
5e8a9a905d | ||
|
|
75ac0b5ed9 | ||
|
|
0f35ec29d3 | ||
|
|
fda0141a01 | ||
|
|
48adcb162c | ||
|
|
3a48366f3e | ||
|
|
75c6cf2966 | ||
|
|
0f54221f86 | ||
|
|
0a38932ed9 | ||
|
|
94968c83c6 | ||
|
|
2ffd7a7172 | ||
|
|
7b30291cc4 | ||
|
|
116c600f60 | ||
|
|
9c79a0f8f4 | ||
|
|
16702496c6 | ||
|
|
6e85869161 | ||
|
|
1cc0a96df1 | ||
|
|
c4c80cea35 | ||
|
|
9cb1e4799c | ||
|
|
63dee51dfb | ||
|
|
cd96542d37 | ||
|
|
55c9a6beea | ||
|
|
9be760fb37 | ||
|
|
99d96c1ff2 | ||
|
|
3b0805414e | ||
|
|
5b6d03e3e2 | ||
|
|
0d4575a241 | ||
|
|
a122d804dd | ||
|
|
4424dafe64 | ||
|
|
0f67dfd074 | ||
|
|
f4cfa012e1 | ||
|
|
5dccba7405 | ||
|
|
f6a49a4e8a | ||
|
|
cda7c30150 | ||
|
|
9f7485e182 | ||
|
|
c51fa0d127 | ||
|
|
148db14736 | ||
|
|
5a9673ecd7 | ||
|
|
f1197ed6fc | ||
|
|
4e9dac5e00 | ||
|
|
b30f8e5290 | ||
|
|
2afb8198c1 | ||
|
|
009b18c1f4 | ||
|
|
77d9ac30bb | ||
|
|
a98660eebd | ||
|
|
c55bee5ec7 | ||
|
|
fe14bcecee | ||
|
|
aa05c5c9dd | ||
|
|
e7c7ee4385 | ||
|
|
36f269d60b | ||
|
|
117e08240b | ||
|
|
9a6c16130a | ||
|
|
aff8e644fc | ||
|
|
fe8d99d421 | ||
|
|
78a1e7dfe6 | ||
|
|
623a60a2b7 | ||
|
|
2aa5f1771f | ||
|
|
778fa8705c | ||
|
|
fef57f99ba | ||
|
|
74f3a1eee2 | ||
|
|
c88f660258 | ||
|
|
a0023fbfa0 | ||
|
|
d0ab0d9922 | ||
|
|
170e0aac2a | ||
|
|
423f7d22bc | ||
|
|
5b6d409248 | ||
|
|
f00a912c25 | ||
|
|
baab4cf045 | ||
|
|
e844d1d6e5 | ||
|
|
a61d5308b5 | ||
|
|
9b9d8970b0 | ||
|
|
8351556059 | ||
|
|
bdc6b32828 | ||
|
|
985bc934a1 | ||
|
|
c916906584 | ||
|
|
9330b76a51 | ||
|
|
1e188bcda9 | ||
|
|
407cf8e328 | ||
|
|
c0f2d89c20 | ||
|
|
915c820c38 | ||
|
|
cd7994f227 | ||
|
|
44bb0be033 | ||
|
|
cf275676f3 | ||
|
|
baf469f02e | ||
|
|
f01b2a8eab | ||
|
|
f5d2db2a60 | ||
|
|
9445960d9d | ||
|
|
207a5a2983 | ||
|
|
48532227d5 | ||
|
|
804a31ec5c | ||
|
|
6ccd4e72f0 | ||
|
|
b5ada806dd | ||
|
|
177ebdc24c | ||
|
|
b0c8a4d11d | ||
|
|
bc12e04993 | ||
|
|
6e8d2dbbbc | ||
|
|
8129dba5d8 | ||
|
|
7cd15d2493 | ||
|
|
822ee62947 | ||
|
|
aafed830a5 | ||
|
|
f87aa0ff1b | ||
|
|
8061d66713 | ||
|
|
17954a4f33 | ||
|
|
c5b987274a | ||
|
|
b83dfcb953 | ||
|
|
bd65b4232a | ||
|
|
5ae91f01fa | ||
|
|
3eb06e305e | ||
|
|
5cfa577778 | ||
|
|
d967760b41 | ||
|
|
d5b0174eb1 | ||
|
|
313762282c | ||
|
|
a11d4e6871 | ||
|
|
1b64ccbfff | ||
|
|
159e4406ab | ||
|
|
f271f003d4 | ||
|
|
dd375f9fc3 | ||
|
|
4012ae4f42 | ||
|
|
fc93af5637 | ||
|
|
a9c91ca81f | ||
|
|
657b246e56 | ||
|
|
fbb6340542 | ||
|
|
abe99230df | ||
|
|
dc17412c3a | ||
|
|
f0b6f70053 | ||
|
|
99997e4441 | ||
|
|
633e4b8a7c | ||
|
|
69d728ac4f | ||
|
|
2cac9e54b4 | ||
|
|
50d6611c10 | ||
|
|
8a93851ee2 | ||
|
|
e97e831c12 | ||
|
|
3f363e0450 | ||
|
|
89aea9b843 | ||
|
|
c4bce00727 | ||
|
|
bc10fad79c | ||
|
|
8f260de3e7 | ||
|
|
276ba1090e | ||
|
|
16ffc2507a | ||
|
|
8da8bc4aad | ||
|
|
bb6f37e777 | ||
|
|
aa702cf3db | ||
|
|
6f695c1864 | ||
|
|
277d8fece2 | ||
|
|
026cfb6ba1 | ||
|
|
e7ad116b9b | ||
|
|
2e3b59bc58 | ||
|
|
489e415339 | ||
|
|
459e89ada8 | ||
|
|
0ab63e2b18 | ||
|
|
f0bfb3fc33 | ||
|
|
a3ae5c8382 | ||
|
|
23d38e4682 | ||
|
|
b9f975b64e | ||
|
|
32ddfc22f5 | ||
|
|
b077c3a813 | ||
|
|
ee5f8c7c22 | ||
|
|
d63e8d4b4f | ||
|
|
89a21db627 | ||
|
|
d51f26850d | ||
|
|
d6b7fe8615 | ||
|
|
6f76d9f246 | ||
|
|
e761eb8f3e | ||
|
|
75c72360ad | ||
|
|
8fe4f34af2 | ||
|
|
5d018034f6 | ||
|
|
6eb46ceac8 | ||
|
|
fcf0bff929 | ||
|
|
ba2b820c5c | ||
|
|
968c87d798 | ||
|
|
5f934830d3 | ||
|
|
dc26069a71 | ||
|
|
dc2c4aab6d | ||
|
|
fc3cd4970c | ||
|
|
2e7e4bc966 | ||
|
|
a6df39dd92 | ||
|
|
92afd8ba25 | ||
|
|
28f169be0c | ||
|
|
c637944707 | ||
|
|
90caa3b610 | ||
|
|
d270879c4b | ||
|
|
0bb9b421f3 | ||
|
|
7ff29a9e6d | ||
|
|
70c7d6f588 | ||
|
|
9ca52ce3d9 | ||
|
|
5e944691b7 | ||
|
|
9a60fcfd3c | ||
|
|
e9b8a6ecbf | ||
|
|
f950132207 | ||
|
|
e2c174e8c8 | ||
|
|
8b42771aab | ||
|
|
5182ebcf38 | ||
|
|
2fcd481276 | ||
|
|
9239f94e5b | ||
|
|
e7c696a5b0 | ||
|
|
4737e19058 | ||
|
|
033693843c | ||
|
|
9afbfc1b63 | ||
|
|
a1fe86a0ff | ||
|
|
4a45098a86 | ||
|
|
bbc1772f4d | ||
|
|
a39a2c5acb | ||
|
|
912fdfbedd | ||
|
|
82bbcf60b0 | ||
|
|
c791e4242b | ||
|
|
35dcd42c9d | ||
|
|
f7fcbdb53b | ||
|
|
0a98c2d626 | ||
|
|
17edec75e4 | ||
|
|
2016a511c3 | ||
|
|
f9a87bf312 | ||
|
|
44bb2be0b4 | ||
|
|
50e6cb0828 | ||
|
|
f036bac144 | ||
|
|
b1b28415c2 | ||
|
|
b552919277 | ||
|
|
b6b275575f | ||
|
|
236edb267d | ||
|
|
84ab206887 | ||
|
|
ff1fde1bb4 | ||
|
|
fbb6982e6e | ||
|
|
be8cd12c7a | ||
|
|
a289dd9863 | ||
|
|
c3ab2def0a | ||
|
|
0014724428 | ||
|
|
c44367f1e5 | ||
|
|
a8fc28c71a | ||
|
|
cd627803a0 | ||
|
|
316d97c938 | ||
|
|
6704d0ab27 | ||
|
|
73189e3ecb | ||
|
|
6709f4efe5 | ||
|
|
0580f57108 | ||
|
|
e2bd20f0aa | ||
|
|
aa50c51902 | ||
|
|
0dabb7010b | ||
|
|
b962110637 | ||
|
|
ab910f88ad | ||
|
|
c3c8a65373 | ||
|
|
3dd0e8ed6a | ||
|
|
a5d5604198 | ||
|
|
d3c293d9c8 | ||
|
|
dd47e479ae | ||
|
|
908b894432 | ||
|
|
3a03dd5712 | ||
|
|
0eead19fec | ||
|
|
5bd5509e06 | ||
|
|
730fd1907f | ||
|
|
56a383cdfa | ||
|
|
777402eeb5 | ||
|
|
deb54b5dab | ||
|
|
119a01c829 | ||
|
|
95d1b39b96 | ||
|
|
9db04a27eb | ||
|
|
ffb02a5919 | ||
|
|
4656275202 | ||
|
|
48c4f57401 | ||
|
|
70614f88cc | ||
|
|
d7aa1f31de | ||
|
|
e52a3b31e4 | ||
|
|
3db1508f1e | ||
|
|
ca70015a7c | ||
|
|
4798264a29 |
88
.agents/skills/agent-transcript/SKILL.md
Normal file
88
.agents/skills/agent-transcript/SKILL.md
Normal file
@@ -0,0 +1,88 @@
|
||||
---
|
||||
name: agent-transcript
|
||||
description: "Add a redacted agent transcript section to GitHub PR or issue bodies during OpenClaw agent-created PR/issue workflows."
|
||||
---
|
||||
|
||||
# Agent Transcript
|
||||
|
||||
Best-effort local-only provenance for OpenClaw PR/issue bodies. Use during agent-created GitHub PR or issue workflows before creating/updating the body.
|
||||
|
||||
## Contract
|
||||
|
||||
- Never use network. Session discovery reads local agent logs only.
|
||||
- Never upload raw logs. Render sanitized Markdown first.
|
||||
- Always ask the user before adding transcript logs to a GitHub PR/issue body.
|
||||
- Tell the user sanitized session logs help reviewers and can make PRs easier to prioritize.
|
||||
- Offer a local HTML preview before insertion. If the user wants preview, open it and wait for confirmation before adding the section.
|
||||
- Fail closed on unresolved secrets, private keys, browser/session/cookie details, or auth URLs.
|
||||
- Drop system/developer prompts, raw tool outputs, reasoning, env, cookies, tokens, and broad local paths.
|
||||
- Keep user prompts, assistant visible decisions, terse tool summaries, and test/proof outcomes.
|
||||
- Remove session turns unrelated to the PR/issue work. Use the PR/issue title, branch name, changed files, and stated goal as scope; omit earlier/later unrelated tasks even when they are in the same session log.
|
||||
- Best effort only: PR/issue creation must continue if no safe transcript is found.
|
||||
- Add the `## Agent Transcript` section only when inserting a real transcript. Never add a placeholder transcript heading or text such as "A sanitized local transcript preview was generated but not included."
|
||||
- Use a collapsed `<details>` section and update existing markers instead of duplicating sections.
|
||||
|
||||
## Helper
|
||||
|
||||
```bash
|
||||
.agents/skills/agent-transcript/scripts/agent-transcript --help
|
||||
```
|
||||
|
||||
Find a likely local session:
|
||||
|
||||
```bash
|
||||
.agents/skills/agent-transcript/scripts/agent-transcript find \
|
||||
--query "$PR_TITLE $BRANCH_OR_PR_URL" \
|
||||
--cwd "$PWD" \
|
||||
--since-days 14
|
||||
```
|
||||
|
||||
`find` scans the newest 400 matching local JSONL logs by default across Codex, Claude, Pi, and OpenClaw agent sessions. Use `--max-files N` for a wider local search.
|
||||
|
||||
Render a PR/issue body section:
|
||||
|
||||
```bash
|
||||
.agents/skills/agent-transcript/scripts/agent-transcript render \
|
||||
--session "$SESSION_JSONL" \
|
||||
--out /tmp/agent-transcript.md
|
||||
```
|
||||
|
||||
Preview one candidate session locally:
|
||||
|
||||
```bash
|
||||
.agents/skills/agent-transcript/scripts/agent-transcript preview \
|
||||
--session "$SESSION_JSONL" \
|
||||
--out /tmp/agent-transcript-preview.html
|
||||
open /tmp/agent-transcript-preview.html
|
||||
```
|
||||
|
||||
Append/update a body file before `gh pr create --body-file` or connector PR creation:
|
||||
|
||||
```bash
|
||||
.agents/skills/agent-transcript/scripts/agent-transcript append-body \
|
||||
--body /tmp/pr-body.md \
|
||||
--session "$SESSION_JSONL" \
|
||||
--out /tmp/pr-body.with-transcript.md
|
||||
```
|
||||
|
||||
## PR/Issue Workflow
|
||||
|
||||
1. Draft the normal PR/issue body first.
|
||||
2. Run `find` with title, branch, PR URL/number if known, and cwd.
|
||||
3. If a high-confidence session is found, ask:
|
||||
`Include a redacted agent transcript? It helps reviewers and can make the PR easier to prioritize. I can open a local preview first.`
|
||||
4. If the user wants preview, run `preview`, open the HTML with `open`, and wait for confirmation.
|
||||
5. Before insertion, trim unrelated session turns from the generated section. Keep only turns that explain this PR/issue's goal, implementation choices, files, tests, proof, blockers, and final outcome.
|
||||
6. If the user approves, run `append-body`.
|
||||
7. Use the enriched body file for creation/update.
|
||||
8. If no safe session is found, say nothing and continue without transcript. If the user declines, continue without transcript and do not add any transcript placeholder section.
|
||||
|
||||
## Review Artifacts
|
||||
|
||||
For manual audits across many PR/session candidates, create a local HTML preview from a local JSON file. This is for maintainers only and is not part of the PR/issue workflow:
|
||||
|
||||
```bash
|
||||
.agents/skills/agent-transcript/scripts/agent-transcript html \
|
||||
--prs /tmp/recent-prs.json \
|
||||
--out /tmp/agent-transcript-preview.html
|
||||
```
|
||||
683
.agents/skills/agent-transcript/scripts/agent-transcript
Executable file
683
.agents/skills/agent-transcript/scripts/agent-transcript
Executable file
@@ -0,0 +1,683 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
const MARKER_START = "<!-- agent-transcript:start -->";
|
||||
const MARKER_END = "<!-- agent-transcript:end -->";
|
||||
const DEFAULT_MAX_CHARS = 50000;
|
||||
const DEFAULT_ENTRY_MAX_CHARS = 6000;
|
||||
|
||||
function usage() {
|
||||
console.log(`Usage:
|
||||
agent-transcript find --query TEXT [--cwd PATH] [--since-days N] [--max-files N] [--root PATH...]
|
||||
agent-transcript render --session FILE [--out FILE] [--max-chars N] [--entry-max-chars N] [--title TEXT] [--url URL]
|
||||
agent-transcript preview --session FILE [--out FILE] [--max-chars N] [--entry-max-chars N] [--title TEXT] [--url URL]
|
||||
agent-transcript append-body --body FILE --session FILE [--out FILE] [--max-chars N] [--entry-max-chars N]
|
||||
agent-transcript html --prs FILE [--out FILE] [--since-days N] [--min-score N] [--root PATH...] [--exclude-session FILE...]
|
||||
|
||||
Local-only. No network calls.`);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { _: [] };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (!arg.startsWith("--")) {
|
||||
args._.push(arg);
|
||||
continue;
|
||||
}
|
||||
const key = arg.slice(2);
|
||||
const next = argv[i + 1];
|
||||
if (next == null || next.startsWith("--")) {
|
||||
args[key] = true;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
if (args[key] == null) args[key] = next;
|
||||
else if (Array.isArray(args[key])) args[key].push(next);
|
||||
else args[key] = [args[key], next];
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function asArray(value) {
|
||||
if (value == null) return [];
|
||||
return Array.isArray(value) ? value : [value];
|
||||
}
|
||||
|
||||
function homePath(...parts) {
|
||||
return path.join(os.homedir(), ...parts);
|
||||
}
|
||||
|
||||
function openClawSessionRoots() {
|
||||
const stateDir = process.env.OPENCLAW_STATE_DIR || homePath(".openclaw");
|
||||
const agentsDir = path.join(stateDir, "agents");
|
||||
if (!fs.existsSync(agentsDir)) return [];
|
||||
try {
|
||||
const roots = fs
|
||||
.readdirSync(agentsDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.flatMap((entry) => {
|
||||
const agentDir = path.join(agentsDir, entry.name);
|
||||
return [
|
||||
path.join(agentDir, "sessions"),
|
||||
path.join(agentDir, "agent", "sessions"),
|
||||
path.join(agentDir, "agent", "codex-home", "sessions"),
|
||||
];
|
||||
})
|
||||
.filter((root) => fs.existsSync(root));
|
||||
return [...new Set(roots)];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function defaultRoots() {
|
||||
return [
|
||||
homePath(".codex", "sessions"),
|
||||
homePath(".claude", "projects"),
|
||||
homePath(".pi", "agent", "sessions"),
|
||||
...openClawSessionRoots(),
|
||||
];
|
||||
}
|
||||
|
||||
function walkJsonl(root, sinceMs, out = []) {
|
||||
if (!root || !fs.existsSync(root)) return out;
|
||||
const stat = fs.statSync(root);
|
||||
if (stat.isFile()) {
|
||||
if (root.endsWith(".jsonl") && stat.mtimeMs >= sinceMs) out.push(root);
|
||||
return out;
|
||||
}
|
||||
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
||||
if (entry.name === "node_modules" || entry.name === ".git") continue;
|
||||
const file = path.join(root, entry.name);
|
||||
if (entry.isDirectory()) walkJsonl(file, sinceMs, out);
|
||||
else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
||||
const entryStat = fs.statSync(file);
|
||||
if (entryStat.mtimeMs >= sinceMs) out.push(file);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function readJsonl(file, maxLines = 12000) {
|
||||
const text = fs.readFileSync(file, "utf8");
|
||||
const lines = text.split(/\n+/).filter(Boolean).slice(0, maxLines);
|
||||
const rows = [];
|
||||
for (const line of lines) {
|
||||
try {
|
||||
rows.push(JSON.parse(line));
|
||||
} catch {
|
||||
rows.push({ type: "unparsed", text: line });
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function stringContent(value) {
|
||||
if (value == null) return "";
|
||||
if (typeof value === "string") return value;
|
||||
if (Array.isArray(value)) return value.map(stringContent).filter(Boolean).join("\n");
|
||||
if (typeof value === "object") {
|
||||
if (typeof value.text === "string") return value.text;
|
||||
if (typeof value.content === "string") return value.content;
|
||||
if (typeof value.message === "string") return value.message;
|
||||
if (Array.isArray(value.content)) return stringContent(value.content);
|
||||
if (value.type === "text" && value.text) return String(value.text);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function detectAgent(file, rows) {
|
||||
if (file.includes(`${path.sep}.codex${path.sep}`)) return "codex";
|
||||
if (file.includes(`${path.sep}.claude${path.sep}`)) return "claude";
|
||||
if (file.includes(`${path.sep}.pi${path.sep}`)) return "pi";
|
||||
if (
|
||||
file.includes(`${path.sep}.openclaw${path.sep}`) ||
|
||||
(file.includes(`${path.sep}agents${path.sep}`) && file.includes(`${path.sep}sessions${path.sep}`))
|
||||
) {
|
||||
return "openclaw";
|
||||
}
|
||||
if (rows.some((row) => row?.type === "session_meta" || row?.type === "response_item")) return "codex";
|
||||
if (rows.some((row) => row?.sessionId && row?.userType)) return "claude";
|
||||
return "agent";
|
||||
}
|
||||
|
||||
function eventText(row) {
|
||||
if (row?.type === "event_msg") {
|
||||
const payload = row.payload || {};
|
||||
return stringContent(payload.message || payload.text_elements || payload.content);
|
||||
}
|
||||
if (row?.type === "response_item") {
|
||||
const payload = row.payload || {};
|
||||
return stringContent(payload.content || payload.summary || payload.arguments || payload.output);
|
||||
}
|
||||
if (row?.message) return stringContent(row.message);
|
||||
if (row?.content) return stringContent(row.content);
|
||||
if (row?.text) return stringContent(row.text);
|
||||
return "";
|
||||
}
|
||||
|
||||
function eventRole(row) {
|
||||
if (row?.type === "event_msg") {
|
||||
const type = row.payload?.type;
|
||||
if (type === "user_message") return "user";
|
||||
if (type === "agent_message") return "assistant";
|
||||
if (type === "token_count" || type === "task_started" || type === "task_complete") return null;
|
||||
if (type === "web_search_end") return "web";
|
||||
}
|
||||
if (row?.type === "response_item") {
|
||||
const payload = row.payload || {};
|
||||
if (payload.type === "function_call") return "tool";
|
||||
if (payload.type === "function_call_output") return "tool_output";
|
||||
if (payload.type === "reasoning") return null;
|
||||
if (payload.type === "web_search_call") return "web";
|
||||
if (payload.role === "user") return "user";
|
||||
if (payload.role === "assistant") return "assistant";
|
||||
}
|
||||
if (row?.type === "user") return "user";
|
||||
if (row?.type === "assistant") return "assistant";
|
||||
if (row?.message?.role === "user") return "user";
|
||||
if (row?.message?.role === "assistant") return "assistant";
|
||||
if (row?.type === "tool_result" || row?.type === "tool_use") return "tool";
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasSetupBlob(text) {
|
||||
return (
|
||||
text.includes("<INSTRUCTIONS>") ||
|
||||
text.includes("# AGENTS.MD") ||
|
||||
text.includes("Knowledge cutoff:") ||
|
||||
text.includes("You are Codex") ||
|
||||
/\byour instructions\b/i.test(text) ||
|
||||
/\binstructions absorbed\b/i.test(text) ||
|
||||
/\bAGENTS\.md\b/i.test(text)
|
||||
);
|
||||
}
|
||||
|
||||
function redact(input, stats) {
|
||||
let s = String(input ?? "");
|
||||
const rules = [
|
||||
[/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, "[REDACTED_PRIVATE_KEY]"],
|
||||
[/sk-[A-Za-z0-9_-]{20,}/g, "[REDACTED_OPENAI_KEY]"],
|
||||
[/(gh[pousr]_[A-Za-z0-9_]{20,})/g, "[REDACTED_GITHUB_TOKEN]"],
|
||||
[/(AKIA[0-9A-Z]{16})/g, "[REDACTED_AWS_KEY]"],
|
||||
[/eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}/g, "[REDACTED_JWT]"],
|
||||
[/\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{16,}/gi, "[REDACTED_AUTH_HEADER]"],
|
||||
[/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "[REDACTED_EMAIL]"],
|
||||
[/\b(?:\+?\d[\d .()-]{7,}\d)\b/g, "[REDACTED_PHONE]"],
|
||||
[/\/Users\/[^\s`"'>)]+/g, "[LOCAL_PATH]"],
|
||||
[/~\/[^\s`"'>)]+/g, "[HOME_PATH]"],
|
||||
[/([?&](?:token|key|secret|signature|sig|access_token|auth)=)[^\s`"'>&]+/gi, "$1[REDACTED]"],
|
||||
];
|
||||
for (const [re, repl] of rules) {
|
||||
const before = s;
|
||||
s = s.replace(re, repl);
|
||||
if (s !== before) stats.redactions++;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function unsafe(text) {
|
||||
const patterns = [
|
||||
/-----BEGIN [A-Z ]*PRIVATE KEY-----/,
|
||||
/\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{16,}/i,
|
||||
/\b(?:user_session|_gh_sess|__Host-user_session_same_site|GH_SESSION_TOKEN)\b/i,
|
||||
/\b(?:GITHUB_TOKEN|GH_TOKEN|OPENAI_API_KEY|ANTHROPIC_API_KEY)\b/,
|
||||
/\/upload\/policies\/assets|uploadToken|authenticity_token/i,
|
||||
];
|
||||
return patterns.filter((pattern) => pattern.test(text)).map((pattern) => String(pattern));
|
||||
}
|
||||
|
||||
function normalizeEntry(role, text, stats, options = {}) {
|
||||
let t = redact(text, stats).replace(/\n{3,}/g, "\n\n").trim();
|
||||
if (!t) return null;
|
||||
if (hasSetupBlob(t)) t = "[instructions recap omitted; policy/config text, not task dialogue]";
|
||||
if (unsafe(t).length) t = "[omitted: browser/session/auth internals; not useful for public PR transcript]";
|
||||
const entryMaxChars = Number(options.entryMaxChars || options["entry-max-chars"] || DEFAULT_ENTRY_MAX_CHARS);
|
||||
if (t.length > entryMaxChars) {
|
||||
t = `${t.slice(0, entryMaxChars).trimEnd()}\n...[truncated ${t.length - entryMaxChars} chars]`;
|
||||
}
|
||||
return `[${role}]\n${t}`;
|
||||
}
|
||||
|
||||
function entryRole(entry) {
|
||||
const match = entry.match(/^\[([^\]]+)\]\n/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
function entryBody(entry) {
|
||||
return entry.replace(/^\[[^\]]+\]\n/, "");
|
||||
}
|
||||
|
||||
function coalesceEntries(entries) {
|
||||
const coalesced = [];
|
||||
for (const entry of entries) {
|
||||
const role = entryRole(entry);
|
||||
const body = entryBody(entry);
|
||||
const last = coalesced[coalesced.length - 1];
|
||||
if (!last || !role || entryRole(last) !== role || role === "tool summary") {
|
||||
coalesced.push(entry);
|
||||
continue;
|
||||
}
|
||||
const lastBody = entryBody(last);
|
||||
if (lastBody === body || lastBody.includes(body)) continue;
|
||||
if (body.includes(lastBody)) {
|
||||
coalesced[coalesced.length - 1] = `[${role}]\n${body}`;
|
||||
continue;
|
||||
}
|
||||
coalesced[coalesced.length - 1] = `[${role}]\n${lastBody}\n\n${body}`;
|
||||
}
|
||||
return coalesced;
|
||||
}
|
||||
|
||||
function toolFamily(name) {
|
||||
const normalized = String(name).toLowerCase();
|
||||
if (
|
||||
/(read|fetch|open|list|find|search|grep|rg|sed|cat|head|tail|jq|wc|status|diff|show|view|snapshot|screenshot)/.test(
|
||||
normalized,
|
||||
)
|
||||
) {
|
||||
return "read";
|
||||
}
|
||||
if (/(write|edit|patch|apply|create|update|append|save|comment|fill|click|type|navigate|upload)/.test(normalized)) {
|
||||
return "write";
|
||||
}
|
||||
if (/(exec|command|shell|run|test|build|lint|format|install|pnpm|npm|node|git|gh|ssh)/.test(normalized)) {
|
||||
return "execute";
|
||||
}
|
||||
if (/(web|http|fetch|browser|chrome|github|dropbox|notion|gmail|calendar)/.test(normalized)) {
|
||||
return "network";
|
||||
}
|
||||
return "other";
|
||||
}
|
||||
|
||||
function shellFamily(command) {
|
||||
const cmd = String(command || "").trim();
|
||||
if (!cmd) return "execute";
|
||||
if (
|
||||
/^(rg|grep|sed|cat|head|tail|jq|wc|ls|find|pwd|git (status|diff|show|log|blame)|gh (pr|issue|api|run|repo|auth) (view|list|status)|test |stat |ps |which |command -v )\b/.test(
|
||||
cmd,
|
||||
)
|
||||
) {
|
||||
return "read";
|
||||
}
|
||||
if (/^(open |chmod |mkdir |touch |cp |mv |kill |git add|git commit|git push|gh pr create|gh issue create)\b/.test(cmd)) {
|
||||
return "write";
|
||||
}
|
||||
if (/^(node|npm|pnpm|bun|python|python3|ruby|tsx|tsgo|make|cargo|go test|swift|xcodebuild)\b/.test(cmd)) {
|
||||
return "execute";
|
||||
}
|
||||
if (/^(ssh|curl|wget|tailscale|nc )\b/.test(cmd)) return "network";
|
||||
return "execute";
|
||||
}
|
||||
|
||||
function toolCallFamily(row) {
|
||||
const name = row.payload?.name || row.name || row.message?.name || row.type || "tool";
|
||||
if (name === "exec_command") {
|
||||
try {
|
||||
const args = JSON.parse(row.payload?.arguments || "{}");
|
||||
return shellFamily(args.cmd);
|
||||
} catch {
|
||||
return "execute";
|
||||
}
|
||||
}
|
||||
if (name === "apply_patch") return "write";
|
||||
if (name === "write_stdin") return "execute";
|
||||
return toolFamily(name);
|
||||
}
|
||||
|
||||
function compactToolSummary(familyCounts, dropped) {
|
||||
const families = new Map();
|
||||
for (const [family, count] of familyCounts.entries()) {
|
||||
families.set(family, (families.get(family) || 0) + count);
|
||||
}
|
||||
const ordered = ["read", "write", "execute", "network", "other"]
|
||||
.map((family) => [family, families.get(family) || 0])
|
||||
.filter(([, count]) => count > 0)
|
||||
.map(([family, count]) => `${count} ${family}`);
|
||||
const calls = ordered.length ? ordered.join(", ") : "0 tool";
|
||||
return `${calls}; raw tool outputs dropped: ${dropped}`;
|
||||
}
|
||||
|
||||
function recountEntries(stats, entries) {
|
||||
stats.rawEntries = stats.entries;
|
||||
stats.entries = entries.length;
|
||||
stats.user = entries.filter((entry) => entry.startsWith("[user]\n")).length;
|
||||
stats.assistant = entries.filter((entry) => entry.startsWith("[assistant]\n")).length;
|
||||
}
|
||||
|
||||
function renderSession(file, options = {}) {
|
||||
const rows = readJsonl(file);
|
||||
const agent = detectAgent(file, rows);
|
||||
const stats = {
|
||||
agent,
|
||||
entries: 0,
|
||||
user: 0,
|
||||
assistant: 0,
|
||||
toolCalls: 0,
|
||||
toolOutputsDropped: 0,
|
||||
web: 0,
|
||||
redactions: 0,
|
||||
omittedUnsafe: 0,
|
||||
};
|
||||
const toolCounts = new Map();
|
||||
const items = [];
|
||||
const seenEntries = new Set();
|
||||
const hasEventDialogue = rows.some((row) => {
|
||||
const type = row?.type === "event_msg" ? row.payload?.type : null;
|
||||
return type === "user_message" || type === "agent_message";
|
||||
});
|
||||
for (const row of rows) {
|
||||
const role = eventRole(row);
|
||||
if (!role) continue;
|
||||
if (hasEventDialogue && row.type === "response_item" && (role === "user" || role === "assistant")) {
|
||||
continue;
|
||||
}
|
||||
if (role === "tool_output") {
|
||||
stats.toolOutputsDropped++;
|
||||
continue;
|
||||
}
|
||||
if (role === "tool") {
|
||||
const family = toolCallFamily(row);
|
||||
toolCounts.set(family, (toolCounts.get(family) || 0) + 1);
|
||||
stats.toolCalls++;
|
||||
continue;
|
||||
}
|
||||
if (role === "web") {
|
||||
stats.web++;
|
||||
continue;
|
||||
}
|
||||
const before = eventText(row);
|
||||
const entry = normalizeEntry(role, before, stats, options);
|
||||
if (!entry) continue;
|
||||
const dedupeKey = entry.replace(/\s+/g, " ").trim();
|
||||
if (seenEntries.has(dedupeKey)) continue;
|
||||
seenEntries.add(dedupeKey);
|
||||
if (entry.includes("[omitted: browser/session/auth internals")) stats.omittedUnsafe++;
|
||||
items.push(entry);
|
||||
stats.entries++;
|
||||
if (role === "user") stats.user++;
|
||||
if (role === "assistant") stats.assistant++;
|
||||
}
|
||||
if (toolCounts.size) {
|
||||
items.push(`[tool summary]\n${compactToolSummary(toolCounts, stats.toolOutputsDropped)}`);
|
||||
stats.entries++;
|
||||
}
|
||||
const renderedItems = coalesceEntries(items);
|
||||
recountEntries(stats, renderedItems);
|
||||
const maxChars = Number(options.maxChars || DEFAULT_MAX_CHARS);
|
||||
let joined = renderedItems.join("\n\n");
|
||||
if (joined.length > maxChars) joined = `${joined.slice(0, maxChars).trimEnd()}\n\n...[transcript truncated to ${maxChars} chars]`;
|
||||
const headerBits = [options.title, options.url].filter(Boolean).join(" | ");
|
||||
const unsafeAfter = unsafe(joined);
|
||||
const safe = unsafeAfter.length === 0;
|
||||
const markdown = `${MARKER_START}
|
||||
## Agent Transcript
|
||||
|
||||
<details>
|
||||
<summary>Redacted ${agent} session transcript${headerBits ? `: ${redact(headerBits, stats)}` : ""}</summary>
|
||||
|
||||
\`\`\`\`text
|
||||
source: [LOCAL_SESSION]
|
||||
redaction: local paths, emails, phone-shaped strings, token-shaped strings, auth headers, auth query params
|
||||
omitted: raw tool outputs, system/developer prompts, local paths, secrets, browser/session/auth details
|
||||
stats: ${JSON.stringify(stats)}
|
||||
|
||||
${joined}
|
||||
\`\`\`\`
|
||||
|
||||
</details>
|
||||
${MARKER_END}
|
||||
`;
|
||||
return { file, agent, safe, unsafeAfter, stats, markdown };
|
||||
}
|
||||
|
||||
function readBoundedText(file, maxBytes = 220000) {
|
||||
const fd = fs.openSync(file, "r");
|
||||
try {
|
||||
const stat = fs.fstatSync(fd);
|
||||
if (stat.size <= maxBytes) {
|
||||
const buffer = Buffer.alloc(stat.size);
|
||||
fs.readSync(fd, buffer, 0, stat.size, 0);
|
||||
return buffer.toString("utf8");
|
||||
}
|
||||
const half = Math.floor(maxBytes / 2);
|
||||
const head = Buffer.alloc(half);
|
||||
const tail = Buffer.alloc(half);
|
||||
fs.readSync(fd, head, 0, half, 0);
|
||||
fs.readSync(fd, tail, 0, half, Math.max(0, stat.size - half));
|
||||
return `${head.toString("utf8")}\n[...middle omitted for scan...]\n${tail.toString("utf8")}`;
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
function sessionScanRecord(file, maxBytes) {
|
||||
const stat = fs.statSync(file);
|
||||
const agent = detectAgent(file, []);
|
||||
return {
|
||||
file,
|
||||
agent,
|
||||
mtime: new Date(stat.mtimeMs).toISOString(),
|
||||
haystack: `${file}\n${readBoundedText(file, maxBytes)}`.toLowerCase(),
|
||||
};
|
||||
}
|
||||
|
||||
function scoreScanRecord(record, terms, cwd) {
|
||||
const haystack = record.haystack;
|
||||
let score = 0;
|
||||
const reasons = [];
|
||||
for (const term of terms) {
|
||||
const normalized = term.toLowerCase().trim();
|
||||
if (normalized.length < 3) continue;
|
||||
if (haystack.includes(normalized)) {
|
||||
score += Math.min(20, Math.max(3, Math.floor(normalized.length / 3)));
|
||||
reasons.push(normalized.slice(0, 80));
|
||||
}
|
||||
}
|
||||
if (cwd) {
|
||||
const cwdLower = cwd.toLowerCase();
|
||||
if (haystack.includes(cwdLower) || record.file.toLowerCase().includes(cwdLower.replaceAll("/", "-"))) {
|
||||
score += 8;
|
||||
reasons.push("cwd");
|
||||
}
|
||||
}
|
||||
return { file: record.file, score, reasons, mtime: record.mtime, agent: record.agent };
|
||||
}
|
||||
|
||||
function recentFiles(files, maxFiles) {
|
||||
return files
|
||||
.map((file) => {
|
||||
try {
|
||||
return { file, mtimeMs: fs.statSync(file).mtimeMs };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
||||
.slice(0, maxFiles)
|
||||
.map((entry) => entry.file);
|
||||
}
|
||||
|
||||
function candidateFiles(roots, terms, sinceMs, options = {}) {
|
||||
return recentFiles(roots.flatMap((root) => walkJsonl(root, sinceMs)), Number(options["max-files"] || 400));
|
||||
}
|
||||
|
||||
function findSessions(options) {
|
||||
const sinceDays = Number(options["since-days"] || 14);
|
||||
const sinceMs = Date.now() - sinceDays * 24 * 60 * 60 * 1000;
|
||||
const roots = asArray(options.root).length ? asArray(options.root) : defaultRoots();
|
||||
const query = String(options.query || "");
|
||||
const terms = query
|
||||
.split(/\s+/)
|
||||
.concat(query.match(/https?:\/\/\S+/g) || [])
|
||||
.filter(Boolean);
|
||||
const files = candidateFiles(roots, terms, sinceMs, options);
|
||||
const scanBytes = Number(options["scan-bytes"] || 60000);
|
||||
const results = files
|
||||
.map((file) => scoreScanRecord(sessionScanRecord(file, scanBytes), terms, options.cwd))
|
||||
.filter((result) => result.score > 0)
|
||||
.sort((a, b) => b.score - a.score || b.mtime.localeCompare(a.mtime))
|
||||
.slice(0, Number(options.limit || 10));
|
||||
return results;
|
||||
}
|
||||
|
||||
function sessionScanRecords(options) {
|
||||
const sinceDays = Number(options["since-days"] || 14);
|
||||
const sinceMs = Date.now() - sinceDays * 24 * 60 * 60 * 1000;
|
||||
const roots = asArray(options.root).length ? asArray(options.root) : defaultRoots();
|
||||
const excluded = new Set(asArray(options["exclude-session"]).map((file) => path.resolve(file)));
|
||||
return roots
|
||||
.flatMap((root) => walkJsonl(root, sinceMs))
|
||||
.filter((file) => !excluded.has(path.resolve(file)))
|
||||
.map((file) => sessionScanRecord(file, Number(options["scan-bytes"] || 90000)));
|
||||
}
|
||||
|
||||
function replaceSection(body, section) {
|
||||
const start = body.indexOf(MARKER_START);
|
||||
const end = body.indexOf(MARKER_END);
|
||||
if (start !== -1 && end !== -1 && end > start) {
|
||||
return `${body.slice(0, start).trimEnd()}\n\n${section.trim()}\n\n${body.slice(end + MARKER_END.length).trimStart()}`;
|
||||
}
|
||||
return `${body.trimEnd()}\n\n${section.trim()}\n`;
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
return String(text)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
function htmlDocument(records) {
|
||||
const rows = records
|
||||
.map((record) => `<section>
|
||||
<h2><a href="${escapeHtml(record.url || "")}">${escapeHtml(record.title || record.url || "PR")}</a></h2>
|
||||
<p><code>${escapeHtml(record.session ? "[LOCAL_SESSION]" : "no session")}</code> score: ${escapeHtml(record.score ?? "")} safe: ${escapeHtml(record.safe ?? "")}</p>
|
||||
<pre>${escapeHtml(record.markdown || record.error || "")}</pre>
|
||||
</section>`)
|
||||
.join("\n");
|
||||
return `<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>Agent Transcript Preview</title>
|
||||
<style>
|
||||
body{font:14px/1.45 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;margin:32px;color:#1f2328;background:#fff}
|
||||
section{border-top:1px solid #d0d7de;padding:24px 0}
|
||||
h1,h2{line-height:1.2}
|
||||
pre{white-space:pre-wrap;background:#f6f8fa;border:1px solid #d0d7de;border-radius:6px;padding:16px;overflow:auto}
|
||||
code{background:#f6f8fa;padding:2px 4px;border-radius:4px}
|
||||
a{color:#0969da}
|
||||
</style>
|
||||
<h1>Agent Transcript Preview</h1>
|
||||
${rows}
|
||||
`;
|
||||
}
|
||||
|
||||
function singlePreviewDocument(record) {
|
||||
return htmlDocument([record]);
|
||||
}
|
||||
|
||||
function readPrs(file) {
|
||||
const raw = fs.readFileSync(file, "utf8");
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : parsed.items || parsed.prs || [];
|
||||
}
|
||||
|
||||
function main() {
|
||||
const [command, ...rest] = process.argv.slice(2);
|
||||
const args = parseArgs(rest);
|
||||
if (!command || command === "--help" || command === "-h" || args.help) {
|
||||
usage();
|
||||
return;
|
||||
}
|
||||
if (command === "find") {
|
||||
console.log(JSON.stringify(findSessions(args), null, 2));
|
||||
return;
|
||||
}
|
||||
if (command === "render") {
|
||||
if (!args.session) throw new Error("--session is required");
|
||||
const rendered = renderSession(args.session, args);
|
||||
if (!rendered.safe) throw new Error(`unsafe transcript after redaction: ${rendered.unsafeAfter.join(", ")}`);
|
||||
if (args.out) fs.writeFileSync(args.out, rendered.markdown);
|
||||
else process.stdout.write(rendered.markdown);
|
||||
return;
|
||||
}
|
||||
if (command === "preview") {
|
||||
if (!args.session) throw new Error("--session is required");
|
||||
const rendered = renderSession(args.session, args);
|
||||
if (!rendered.safe) throw new Error(`unsafe transcript after redaction: ${rendered.unsafeAfter.join(", ")}`);
|
||||
const output = singlePreviewDocument({
|
||||
title: args.title || "Agent Transcript Preview",
|
||||
url: args.url || "",
|
||||
session: args.session,
|
||||
safe: rendered.safe,
|
||||
markdown: rendered.markdown,
|
||||
});
|
||||
if (args.out) fs.writeFileSync(args.out, output);
|
||||
else process.stdout.write(output);
|
||||
return;
|
||||
}
|
||||
if (command === "append-body") {
|
||||
if (!args.body || !args.session) throw new Error("--body and --session are required");
|
||||
const rendered = renderSession(args.session, args);
|
||||
if (!rendered.safe) throw new Error(`unsafe transcript after redaction: ${rendered.unsafeAfter.join(", ")}`);
|
||||
const body = fs.readFileSync(args.body, "utf8");
|
||||
const next = replaceSection(body, rendered.markdown);
|
||||
if (args.out) fs.writeFileSync(args.out, next);
|
||||
else process.stdout.write(next);
|
||||
return;
|
||||
}
|
||||
if (command === "html") {
|
||||
if (!args.prs) throw new Error("--prs is required");
|
||||
const records = [];
|
||||
const scanRecords = sessionScanRecords(args);
|
||||
const minScore = Number(args["min-score"] || 50);
|
||||
for (const pr of readPrs(args.prs)) {
|
||||
const query = [pr.url, pr.number ? `#${pr.number}` : "", pr.number, pr.title, pr.headRefName, pr.headRefName || pr.branch]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const terms = query
|
||||
.split(/\s+/)
|
||||
.concat(query.match(/https?:\/\/\S+/g) || [])
|
||||
.filter(Boolean);
|
||||
const [candidate] = scanRecords
|
||||
.map((record) => scoreScanRecord(record, terms, args.cwd))
|
||||
.filter((result) => result.score >= minScore)
|
||||
.sort((a, b) => b.score - a.score || b.mtime.localeCompare(a.mtime));
|
||||
if (!candidate) {
|
||||
records.push({ ...pr, error: "No local session match found." });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const rendered = renderSession(candidate.file, { ...args, title: pr.title, url: pr.url });
|
||||
records.push({
|
||||
...pr,
|
||||
session: candidate.file,
|
||||
score: candidate.score,
|
||||
safe: rendered.safe,
|
||||
markdown: rendered.markdown,
|
||||
});
|
||||
} catch (error) {
|
||||
records.push({ ...pr, session: candidate.file, score: candidate.score, error: String(error) });
|
||||
}
|
||||
}
|
||||
const output = htmlDocument(records);
|
||||
if (args.out) fs.writeFileSync(args.out, output);
|
||||
else process.stdout.write(output);
|
||||
return;
|
||||
}
|
||||
usage();
|
||||
process.exitCode = 2;
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -26,6 +26,9 @@ Use when:
|
||||
- If a review-triggered fix changes code, rerun focused tests and rerun the structured review helper.
|
||||
- For security-audit suppression changes, verify accepted findings remain auditable: suppressed findings stay in structured output, active output keeps an unsuppressible suppression notice, and aggregate findings cannot hide unrelated active risk.
|
||||
- Never switch or override the requested review engine/model. If the review hits model capacity, retry the same command a few times with the same engine/model.
|
||||
- Be patient with large bundles. Structured review can take up to 30 minutes while the model call is active, especially with Codex tools or web search.
|
||||
- Treat heartbeat lines like `review still running: ... elapsed=... pid=...` as healthy progress, not a hang. Let the helper continue while heartbeats are advancing.
|
||||
- Do not kill a review just because it has been quiet for 2-5 minutes, or because it is still running under the 30-minute window. Inspect the process only after missing multiple expected heartbeats, after 30 minutes, or after an obviously failed subprocess; prefer letting the same helper command finish.
|
||||
- Tools are useful in review mode. The helper allows read-only inspection tools and web search by default so reviewers can check dependency contracts, upstream docs, and current behavior.
|
||||
- Security perspective is always included, but it should not cripple legitimate functionality. Report security findings only when the change creates a concrete, actionable risk or removes an important safety check.
|
||||
- Do not invoke built-in `codex review`, nested reviewers, or reviewer panels from inside the review. The helper builds one bundle, calls one selected engine, validates one structured result, and stops.
|
||||
@@ -169,6 +172,7 @@ The helper:
|
||||
- supports `--dry-run`, `--parallel-tests`, `--prompt`, `--prompt-file`, `--dataset`, `--no-tools`, `--no-web-search`, and commit refs
|
||||
- supports opt-in review panels with `--panel` / `--reviewers`, plus per-engine `--model` and `--thinking`
|
||||
- allows read-only tools and web search by default where the selected CLI supports them; forbids nested review in the prompt; Codex is run through `codex exec` with read-only sandbox and structured output
|
||||
- prints `review still running: <engine> elapsed=<seconds>s pid=<pid>` to stderr at long-running intervals while waiting for the selected review engine
|
||||
- prints `autoreview clean: no accepted/actionable findings reported` when the selected review command exits 0
|
||||
- exits nonzero when accepted/actionable findings are present
|
||||
|
||||
|
||||
@@ -93,6 +93,37 @@ def run(args: list[str], cwd: Path, *, input_text: str | None = None, check: boo
|
||||
return result
|
||||
|
||||
|
||||
def run_with_heartbeat(
|
||||
args: list[str],
|
||||
cwd: Path,
|
||||
*,
|
||||
input_text: str | None = None,
|
||||
label: str,
|
||||
heartbeat_seconds: int = 60,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
started = time.monotonic()
|
||||
proc = subprocess.Popen(
|
||||
args,
|
||||
cwd=cwd,
|
||||
stdin=subprocess.PIPE if input_text is not None else None,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
first_communicate = True
|
||||
while True:
|
||||
try:
|
||||
stdout, stderr = proc.communicate(
|
||||
input=input_text if first_communicate else None,
|
||||
timeout=heartbeat_seconds,
|
||||
)
|
||||
return subprocess.CompletedProcess(args, int(proc.returncode or 0), stdout, stderr)
|
||||
except subprocess.TimeoutExpired:
|
||||
first_communicate = False
|
||||
elapsed = int(time.monotonic() - started)
|
||||
print(f"review still running: {label} elapsed={elapsed}s pid={proc.pid}", file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def git(repo: Path, *args: str, check: bool = True) -> str:
|
||||
return run(["git", *args], repo, check=check).stdout
|
||||
|
||||
@@ -320,7 +351,7 @@ def run_codex(args: argparse.Namespace, repo: Path, prompt: str) -> str:
|
||||
"-",
|
||||
]
|
||||
)
|
||||
result = run(cmd, repo, input_text=prompt, check=False)
|
||||
result = run_with_heartbeat(cmd, repo, input_text=prompt, label="codex")
|
||||
try:
|
||||
output = output_path.read_text()
|
||||
finally:
|
||||
@@ -349,7 +380,7 @@ def run_claude(args: argparse.Namespace, repo: Path, prompt: str) -> str:
|
||||
cmd.extend(["--model", args.model])
|
||||
if args.thinking:
|
||||
cmd.extend(["--effort", args.thinking])
|
||||
result = run(cmd, repo, input_text=prompt, check=False)
|
||||
result = run_with_heartbeat(cmd, repo, input_text=prompt, label="claude")
|
||||
if result.returncode != 0:
|
||||
raise SystemExit(f"claude engine failed ({result.returncode})\n{result.stderr or result.stdout}")
|
||||
return result.stdout
|
||||
@@ -374,7 +405,7 @@ def run_droid(args: argparse.Namespace, repo: Path, prompt: str) -> str:
|
||||
cmd.extend(["--model", args.model])
|
||||
if not args.tools:
|
||||
cmd.extend(["--disabled-tools", "*"])
|
||||
result = run(cmd, repo, check=False)
|
||||
result = run_with_heartbeat(cmd, repo, label="droid")
|
||||
prompt_path.unlink(missing_ok=True)
|
||||
if result.returncode != 0:
|
||||
raise SystemExit(f"droid engine failed ({result.returncode})\n{result.stderr or result.stdout}")
|
||||
@@ -416,7 +447,7 @@ def run_copilot(args: argparse.Namespace, repo: Path, prompt: str) -> str:
|
||||
)
|
||||
if args.web_search:
|
||||
cmd.append("--allow-all-urls")
|
||||
result = run(cmd, Path(tempdir), check=False)
|
||||
result = run_with_heartbeat(cmd, Path(tempdir), label="copilot")
|
||||
if result.returncode != 0:
|
||||
raise SystemExit(f"copilot engine failed ({result.returncode})\n{result.stderr or result.stdout}")
|
||||
return result.stdout
|
||||
|
||||
@@ -149,7 +149,7 @@ pnpm crabbox:run -- \
|
||||
--ttl 240m \
|
||||
--timing-json \
|
||||
--shell -- \
|
||||
"env CI=1 NODE_OPTIONS=--max-old-space-size=4096 OPENCLAW_TEST_PROJECTS_PARALLEL=6 OPENCLAW_VITEST_MAX_WORKERS=1 OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS=900000 pnpm test:changed"
|
||||
"pnpm test:changed"
|
||||
```
|
||||
|
||||
Full suite:
|
||||
@@ -160,7 +160,7 @@ pnpm crabbox:run -- \
|
||||
--ttl 240m \
|
||||
--timing-json \
|
||||
--shell -- \
|
||||
"env CI=1 NODE_OPTIONS=--max-old-space-size=4096 OPENCLAW_TEST_PROJECTS_PARALLEL=6 OPENCLAW_VITEST_MAX_WORKERS=1 OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS=900000 pnpm test"
|
||||
"pnpm test"
|
||||
```
|
||||
|
||||
Focused rerun:
|
||||
@@ -171,7 +171,7 @@ pnpm crabbox:run -- \
|
||||
--ttl 240m \
|
||||
--timing-json \
|
||||
--shell -- \
|
||||
"env CI=1 NODE_OPTIONS=--max-old-space-size=4096 OPENCLAW_VITEST_MAX_WORKERS=1 OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS=900000 pnpm test <path-or-filter>"
|
||||
"pnpm test <path-or-filter>"
|
||||
```
|
||||
|
||||
Read the JSON summary. Useful fields:
|
||||
@@ -206,7 +206,7 @@ node scripts/crabbox-wrapper.mjs run \
|
||||
--ttl 240m \
|
||||
--timing-json \
|
||||
-- \
|
||||
CI=1 NODE_OPTIONS=--max-old-space-size=4096 OPENCLAW_TEST_PROJECTS_PARALLEL=6 OPENCLAW_VITEST_MAX_WORKERS=1 OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS=900000 OPENCLAW_TESTBOX=1 OPENCLAW_TESTBOX_REMOTE_RUN=1 pnpm check:changed
|
||||
corepack pnpm check:changed
|
||||
```
|
||||
|
||||
Read the JSON summary and the Testbox line. Useful fields:
|
||||
@@ -544,14 +544,14 @@ If brokered AWS cannot dispatch, sync, attach, or stop, retry once with
|
||||
|
||||
```sh
|
||||
pnpm crabbox:run -- --debug --timing-json -- \
|
||||
CI=1 NODE_OPTIONS=--max-old-space-size=4096 OPENCLAW_TEST_PROJECTS_PARALLEL=6 OPENCLAW_VITEST_MAX_WORKERS=1 OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS=900000 pnpm test:changed
|
||||
pnpm test:changed
|
||||
```
|
||||
|
||||
Full suite:
|
||||
|
||||
```sh
|
||||
pnpm crabbox:run -- --debug --timing-json -- \
|
||||
CI=1 NODE_OPTIONS=--max-old-space-size=4096 OPENCLAW_TEST_PROJECTS_PARALLEL=6 OPENCLAW_VITEST_MAX_WORKERS=1 OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS=900000 pnpm test
|
||||
pnpm test
|
||||
```
|
||||
|
||||
Auth fallback, only when `blacksmith` says auth is missing:
|
||||
@@ -591,7 +591,7 @@ Minimal Blacksmith-backed Crabbox run, from repo root:
|
||||
|
||||
```sh
|
||||
pnpm crabbox:run -- --provider blacksmith-testbox --timing-json -- \
|
||||
CI=1 NODE_OPTIONS=--max-old-space-size=4096 OPENCLAW_TEST_PROJECTS_PARALLEL=6 OPENCLAW_VITEST_MAX_WORKERS=1 pnpm test:changed
|
||||
corepack pnpm test:changed
|
||||
```
|
||||
|
||||
Use direct Blacksmith only when Crabbox is the broken layer and you are
|
||||
@@ -617,7 +617,7 @@ provider deliberately.
|
||||
```sh
|
||||
pnpm crabbox:warmup -- --class beast --market on-demand --idle-timeout 90m
|
||||
pnpm crabbox:hydrate -- --id <cbx_id-or-slug>
|
||||
pnpm crabbox:run -- --id <cbx_id-or-slug> --timing-json --shell -- "env NODE_OPTIONS=--max-old-space-size=4096 OPENCLAW_TEST_PROJECTS_PARALLEL=6 OPENCLAW_VITEST_MAX_WORKERS=1 OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS=900000 pnpm test:changed"
|
||||
pnpm crabbox:run -- --id <cbx_id-or-slug> --timing-json --shell -- "pnpm test:changed"
|
||||
pnpm crabbox:stop -- <cbx_id-or-slug>
|
||||
```
|
||||
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
---
|
||||
name: openclaw-docs
|
||||
description: Write or review high-quality OpenClaw developer documentation.
|
||||
dependencies: []
|
||||
---
|
||||
|
||||
# OpenClaw Docs
|
||||
|
||||
## Overview
|
||||
|
||||
Use this skill when writing, editing, or reviewing OpenClaw developer documentation for APIs, SDKs, CLI tools, integrations, quickstarts, platform guides, or technical product docs.
|
||||
|
||||
Write documentation that is concise, helpful, and comprehensive: fast for first success, precise for production, and easy to scan when debugging.
|
||||
|
||||
## Core Model
|
||||
|
||||
Use an OpenClaw documentation model, strengthened by Write the Docs principles:
|
||||
|
||||
- Lead with what the developer is trying to do.
|
||||
- Give one recommended path before alternatives.
|
||||
- Make examples runnable and realistic.
|
||||
- Keep guides task-oriented and references exhaustive.
|
||||
- Explain production risks exactly where developers can make mistakes.
|
||||
- Link concepts, guides, API references, SDKs, testing, and troubleshooting so readers can move between them without rereading.
|
||||
- Treat docs as part of the product lifecycle: draft them before or alongside implementation, review them with code, and keep them current.
|
||||
- Make each page discoverable, addressable, cumulative, complete within its stated scope, and easy to skim.
|
||||
|
||||
## Structure
|
||||
|
||||
Choose the page type before writing:
|
||||
|
||||
- Overview: route readers to the right product, integration path, or guide.
|
||||
- Quickstart: get a new user to a working result with the fewest safe steps.
|
||||
- Topic page: give an end-to-end overview of a major domain entity, with setup,
|
||||
key subtopics, troubleshooting, and links to deeper references.
|
||||
- Guide: explain one workflow from prerequisites to production readiness.
|
||||
- API reference: define every object, endpoint, parameter, enum, response, error, and version rule.
|
||||
- SDK or CLI reference: document install, auth, commands or methods, options, examples, and failure modes.
|
||||
- Testing guide: show sandbox setup, fixtures, test data, simulated failures, and live-mode differences.
|
||||
- Troubleshooting guide: map symptoms to checks, causes, and fixes.
|
||||
|
||||
Use this default topic page structure:
|
||||
|
||||
1. Title: name the major entity or surface.
|
||||
2. Opening overview: start with a few unheaded sentences that explain what it
|
||||
is, what it owns, and what it does not own. Do not add a `## Overview`
|
||||
heading unless the page is itself an overview index.
|
||||
3. Requirements: include only when setup needs specific accounts, versions,
|
||||
permissions, plugins, operating systems, or credentials.
|
||||
4. Quickstart: show the recommended setup path and smallest reliable verification.
|
||||
5. Configuration: show the minimum configuration needed to use the surface,
|
||||
common variants users must choose between, and where each option is set:
|
||||
CLI, config file, environment variable, plugin manifest, dashboard, or API.
|
||||
6. Major subtopics: organize the entity's major concepts, workflows, and
|
||||
decisions by reader intent. Put each major subtopic under its own heading;
|
||||
do not wrap them in a generic `## Subtopics` section.
|
||||
7. Troubleshooting: diagnose common observable failures under an explicit
|
||||
`## Troubleshooting` heading.
|
||||
8. Related: link to guides, references, commands, concepts, and adjacent topics.
|
||||
|
||||
Topic pages may be longer than quickstarts, but they should not become exhaustive
|
||||
references. Move field tables, API contracts, narrow internals, legacy details,
|
||||
and rare debugging workflows to linked reference or troubleshooting pages when
|
||||
they interrupt the end-to-end overview.
|
||||
|
||||
For configuration, keep task-critical options inline. Link to reference docs for
|
||||
full option lists, defaults, enums, generated schemas, and advanced settings. Do
|
||||
not duplicate exhaustive config reference tables in topic pages unless the topic
|
||||
page is itself the reference.
|
||||
|
||||
Use this default guide structure:
|
||||
|
||||
1. Title: name the outcome, not the implementation detail.
|
||||
2. Opening: state what the reader can accomplish in one or two sentences.
|
||||
3. Before you begin: list accounts, keys, permissions, versions, tools, and assumptions.
|
||||
4. Choose a path: compare options only when the reader must decide.
|
||||
5. Steps: use verb-led headings with code, expected output, and checks.
|
||||
6. Test: show the smallest reliable proof that the integration works.
|
||||
7. Production readiness: cover security, idempotency, retries, limits, observability, migrations, and cleanup.
|
||||
8. Troubleshooting: include common errors near the workflow that causes them.
|
||||
9. See also: link to concepts, API references, SDK docs, and adjacent guides.
|
||||
|
||||
Keep navigation user-intent based. Do not force readers to understand internal product taxonomy before they can pick a task.
|
||||
|
||||
## Documentation Lifecycle
|
||||
|
||||
Write and maintain docs with the same discipline as code:
|
||||
|
||||
- Draft docs early enough to expose unclear product, API, CLI, or config design.
|
||||
- Keep docs source near the code, config, command, plugin, or protocol it describes when the repo layout allows it.
|
||||
- Avoid duplicate truth. If the same contract appears in multiple places, pick the canonical page and link to it.
|
||||
- Update docs in the same change as behavior, config, API, CLI, plugin, or troubleshooting changes.
|
||||
- Remove, redirect, or clearly mark stale docs. Incorrect docs are worse than missing docs.
|
||||
- Involve the right reviewers: code owners for behavior, support or QA for user failure modes, and docs maintainers for structure and style.
|
||||
- Preserve older-version guidance only when users need it; otherwise document the current supported behavior.
|
||||
|
||||
Do not use FAQs as a dumping ground for unrelated material. Promote recurring questions into task, concept, troubleshooting, or reference pages.
|
||||
|
||||
## Writing Style
|
||||
|
||||
Write in a direct, practical voice:
|
||||
|
||||
- Use present tense and active voice.
|
||||
- Address the reader as "you" when giving instructions.
|
||||
- Prefer short paragraphs and scannable lists.
|
||||
- Use concrete nouns: "agent profile", "Gateway webhook", "plugin manifest", "session state".
|
||||
- Put caveats exactly where they affect the step.
|
||||
- Avoid marketing language, hype, generic benefits, and vague claims.
|
||||
- Avoid long conceptual lead-ins before the first actionable step.
|
||||
- Do not over-explain common developer concepts unless the product has a nonstandard contract.
|
||||
- Define OpenClaw-specific jargon and abbreviations before first use.
|
||||
- Use sentence case for headings unless an OpenClaw product name, command, or identifier requires capitalization.
|
||||
- Use descriptive link text that names the destination or action; avoid vague links such as "this page" or "click here".
|
||||
- Avoid culturally specific idioms, violent idioms, and jokes that make docs harder to translate or scan.
|
||||
- Write accessible prose: do not rely on color, screenshots, or visual position as the only way to understand an instruction.
|
||||
|
||||
Use headings that describe actions or reference surfaces:
|
||||
|
||||
- Good: "Create an agent", "Configure a Slack channel", "Repair plugin installation"
|
||||
- Avoid: "How it works", "Under the hood", "Important notes" unless the section truly needs that shape
|
||||
|
||||
Use precise modal language:
|
||||
|
||||
- Use "must" for required behavior.
|
||||
- Use "can" for optional capability.
|
||||
- Use "recommended" for the default path.
|
||||
- Use "avoid" for known footguns.
|
||||
- Explain "why" only when it changes a developer decision.
|
||||
|
||||
## Detail Level
|
||||
|
||||
Vary detail by page type:
|
||||
|
||||
- Overview pages: be brief; help readers choose.
|
||||
- Quickstarts: be procedural; include only what is needed for first success.
|
||||
- Guides: be complete for one workflow; include decisions, side effects, and failure handling.
|
||||
- References: be exhaustive; document every field, default, enum, nullable value, constraint, response, and error.
|
||||
- Troubleshooting: be explicit; assume the reader is blocked and needs observable checks.
|
||||
|
||||
Go deep where mistakes are expensive:
|
||||
|
||||
- Authentication and secret handling
|
||||
- Money movement, billing, permissions, and irreversible actions
|
||||
- Webhooks, retries, duplicate events, and ordering
|
||||
- Idempotency and concurrency
|
||||
- Sandbox versus production differences
|
||||
- Versioning, migrations, and backwards compatibility
|
||||
- Limits, rate limits, quotas, and timeouts
|
||||
- Error codes and recovery paths
|
||||
- Data retention, privacy, and compliance-sensitive behavior
|
||||
|
||||
Do not bury this detail in a distant reference if developers need it to complete the task safely.
|
||||
|
||||
## Examples
|
||||
|
||||
Make examples production-shaped, even when using test data:
|
||||
|
||||
- Prefer complete copy-pasteable commands or snippets.
|
||||
- Use realistic variable names and values.
|
||||
- Mark placeholders clearly with angle-bracket names such as `<API_KEY>` or `<CUSTOMER_ID>`.
|
||||
- Show expected success output after commands.
|
||||
- Show full request and response examples for API references when response shape matters.
|
||||
- Keep one conceptual unit per code block.
|
||||
- Use language-specific code fences.
|
||||
- Avoid toy examples that hide required setup, auth, error handling, or cleanup.
|
||||
|
||||
When multiple languages are useful, keep the same scenario across languages so readers can compare equivalents.
|
||||
|
||||
## Discoverability and Navigation
|
||||
|
||||
Design every page so readers can find it, link to it, and decide quickly whether it answers their question:
|
||||
|
||||
- Use goal-oriented titles and headings that match likely search terms.
|
||||
- Start each page with a concise answer to "what can I do here?"
|
||||
- Include metadata or frontmatter required by the OpenClaw docs index.
|
||||
- Add "Read when" hints for docs-list routing when creating or changing OpenClaw docs pages that participate in the docs index.
|
||||
- Link from likely entry points, not only from nearby internal taxonomy pages.
|
||||
- Keep section headings stable enough for links from issues, PRs, support replies, and chat answers.
|
||||
- Order tutorials and examples from prerequisites to advanced tasks; order reference pages alphabetically or topically when that helps lookup.
|
||||
- State scope up front when a page is intentionally partial.
|
||||
|
||||
## API Reference Pattern
|
||||
|
||||
For endpoints, methods, objects, or commands, include:
|
||||
|
||||
1. Short purpose statement.
|
||||
2. Auth or permission requirements.
|
||||
3. Request shape, including path, query, headers, and body fields.
|
||||
4. Parameter table with type, requiredness, default, constraints, enum values, and side effects.
|
||||
5. Return shape with object lifecycle states.
|
||||
6. Error cases with codes, causes, and recovery guidance.
|
||||
7. Runnable example request.
|
||||
8. Representative successful response.
|
||||
9. Related guides and adjacent reference pages.
|
||||
|
||||
For nested objects, document child fields near their parent. Do not make readers jump across pages to understand the shape of a single request.
|
||||
|
||||
## Verification
|
||||
|
||||
Verify docs changes like product changes:
|
||||
|
||||
- Run the relevant docs build, docs index, formatter, link checker, or generated-doc check when available.
|
||||
- Run commands, snippets, and examples that the page tells users to run whenever feasible.
|
||||
- Confirm screenshots, UI labels, CLI output, config keys, flags, defaults, errors, and file paths match current behavior.
|
||||
- Prefer executable checks over prose-only review for API, CLI, config, generated reference, and troubleshooting docs.
|
||||
- If a verification step is not feasible, say what was not verified and why.
|
||||
|
||||
## Completeness Checks
|
||||
|
||||
Before finalizing a page, verify:
|
||||
|
||||
- The first screen tells readers what they can accomplish.
|
||||
- The recommended path is obvious.
|
||||
- Prerequisites are explicit and testable.
|
||||
- Examples can run with documented inputs.
|
||||
- The page has a clear audience: user, operator, plugin author, contributor, or maintainer.
|
||||
- Test-mode and production-mode behavior are separated.
|
||||
- Security-sensitive values are never exposed in examples.
|
||||
- Every warning is attached to the step where it matters.
|
||||
- Edge cases are documented where they affect implementation.
|
||||
- API fields include types, defaults, constraints, and errors.
|
||||
- Troubleshooting starts from observable symptoms.
|
||||
- Related links help the reader continue without duplicating the page.
|
||||
- The page says where to get support, file issues, or contribute when that is relevant to the reader's next step.
|
||||
- The page is complete for the scope it claims, or the limitation is stated up front.
|
||||
|
||||
## Review Pass
|
||||
|
||||
Edit in this order:
|
||||
|
||||
1. Remove repetition and generic explanation.
|
||||
2. Move conceptual background below the first useful action unless it is required to choose correctly.
|
||||
3. Replace passive or abstract wording with concrete instructions.
|
||||
4. Tighten headings until the outline reads like a task map.
|
||||
5. Add missing operational details for production safety.
|
||||
6. Check examples for copy-paste accuracy.
|
||||
7. Add links between guide, reference, SDK, testing, and troubleshooting surfaces.
|
||||
8. Check discoverability, addressability, accessibility, and docs-as-code verification.
|
||||
@@ -5,7 +5,7 @@ description: Inspect, patch, validate, publish, or confirm OpenClaw GHSA securit
|
||||
|
||||
# OpenClaw GHSA Maintainer
|
||||
|
||||
Use this skill for repo security advisory workflow only. Keep general release work in `openclaw-release-maintainer`.
|
||||
Use this skill for repo security advisory workflow only. Keep general release work in `release-openclaw-maintainer`.
|
||||
|
||||
## Respect advisory guardrails
|
||||
|
||||
|
||||
@@ -89,11 +89,11 @@ Reject:
|
||||
- if unwritable or wrong shape, create own PR and preserve useful contributor credit
|
||||
- if no PR exists, create one
|
||||
- add regression test when it fits
|
||||
- changelog for user-facing fixes; thank credited human reporter/contributor
|
||||
- release-note context for user-facing fixes in PR body or commit message; credit human reporter/contributor when known
|
||||
6. Review, refresh, and publish:
|
||||
- rebase or otherwise refresh the PR branch on current `origin/main`
|
||||
- resolve drift, including newly exposed CI failures, rather than counting the PR as ready
|
||||
- changelog-only conflicts are routine on busy `main`; resolve them mechanically when already refreshing, but do not treat them as a real code conflict, a reason to reject the PR, or evidence that the branch needs extra fixup beyond the changelog entry order
|
||||
- do not add `CHANGELOG.md` during normal sweep PRs; release automation generates it from PRs and commits
|
||||
- left-test the rebased head with the smallest meaningful local/Testbox/live command that proves the bug
|
||||
- run `$autoreview` until no accepted/actionable findings remain before creating, updating, or presenting the PR URL
|
||||
- create/update PR with real body and proof fields
|
||||
|
||||
@@ -139,12 +139,12 @@ Issue triage is review/prove/patch-local by default:
|
||||
2. Fix only issues that are easy, high-confidence, and narrowly owned by the implicated path.
|
||||
3. Add focused regression proof when practical.
|
||||
4. Stop with the dirty diff, touched files, and test/gate output for maintainer review.
|
||||
5. After maintainer approval to ship, make one commit per accepted fix, with its own changelog entry when user-facing.
|
||||
5. After maintainer approval to ship, make one commit per accepted fix, with release-note context in the PR body or commit message when user-facing.
|
||||
6. Pull/rebase, push, then comment and close only the issues that were fixed or explicitly triaged closed.
|
||||
|
||||
Do not batch unrelated issue fixes into one commit. Do not publish, comment, close, or label during the review/prove phase.
|
||||
|
||||
Missing changelog is not a PR review finding or merge blocker. If landing/fixing a user-visible change, add/update changelog automatically when practical; never ask or block solely on it.
|
||||
Missing `CHANGELOG.md` is not a PR review finding or merge blocker. If landing/fixing a user-visible change, make sure the PR body or commit message captures the release-note context; never ask or block solely on it.
|
||||
|
||||
Only list candidates that pass all gates:
|
||||
|
||||
@@ -244,9 +244,8 @@ gh search issues --repo openclaw/openclaw --match title,body --limit 50 \
|
||||
|
||||
## Follow PR review and landing hygiene
|
||||
|
||||
- Never mention merge conflicts that are relatively easy to resolve, such as
|
||||
`CHANGELOG.md` entries, in review-only output. These are landing mechanics,
|
||||
not correctness findings.
|
||||
- Never mention release-note bookkeeping in review-only output. It is landing
|
||||
or release-generation mechanics, not a correctness finding.
|
||||
- If bot review conversations exist on your PR, address them and resolve them yourself once fixed.
|
||||
- Leave a review conversation unresolved only when reviewer or maintainer judgment is still needed.
|
||||
- Before landing any PR with non-trivial code changes, run `$autoreview` until no accepted/actionable findings remain, unless equivalent manual review already covered it, the change is trivial/docs-only, or the user opts out.
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: "OpenClaw Plugin Pre-Release Testing"
|
||||
short_description: "Plan plugin release validation"
|
||||
default_prompt: "Use $openclaw-pre-release-plugin-testing to plan or run pre-release OpenClaw plugin validation across package, lifecycle, doctor, gateway, SDK, and live-ish proof."
|
||||
@@ -98,7 +98,7 @@ barrels, package-boundary tests, or extension suites.
|
||||
- add `--keep`/`--id <id-or-slug>` only when several commands must share one
|
||||
warmed box; stop it with `pnpm crabbox:stop -- <id-or-slug>`.
|
||||
5. If plugin performance is package-artifact sensitive, switch to
|
||||
`openclaw-pre-release-plugin-testing` and Package Acceptance rather than
|
||||
`release-openclaw-plugin-testing` and Package Acceptance rather than
|
||||
trusting source-only timing.
|
||||
|
||||
## Metric Collection
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
---
|
||||
name: optimizetests
|
||||
description: Optimize OpenClaw slow tests, imports, misplaced coverage, and CI wall time without dropping coverage.
|
||||
---
|
||||
|
||||
# Optimize Tests
|
||||
|
||||
Goal: real OpenClaw test/runtime speedups with coverage intact. Do not add shards,
|
||||
skip assertions, weaken gates, or tune runner flags as the main fix.
|
||||
|
||||
## Runbook
|
||||
|
||||
1. Read `docs/help/testing.md`, `docs/ci.md`, and the scoped `AGENTS.md` files
|
||||
for any subtree you will edit.
|
||||
2. Establish evidence before edits:
|
||||
- Full ranking: `pnpm test:perf:groups --full-suite --allow-failures --output .artifacts/test-perf/<name>.json`
|
||||
- Targeted file: `timeout 240 /usr/bin/time -l pnpm test <file> --maxWorkers=1 --reporter=verbose`
|
||||
- Import suspicion: add `OPENCLAW_VITEST_IMPORT_DURATIONS=1 OPENCLAW_VITEST_PRINT_IMPORT_BREAKDOWN=1`
|
||||
3. Attack highest-return hotspots first:
|
||||
- broad barrels or `importActual()` in hot tests
|
||||
- per-test `vi.resetModules()` plus fresh imports
|
||||
- expensive gateway/server/client setup where reset/reuse proves same behavior
|
||||
- core tests asserting extension-owned behavior
|
||||
- duplicated fixture construction or contract assertions
|
||||
4. Prefer production-quality fixes:
|
||||
- narrow runtime seams over broad mocks
|
||||
- pure helpers for static parsing/metadata
|
||||
- injected deps over module resets
|
||||
- extension-owned tests for bundled plugin/provider/channel behavior
|
||||
5. After each change, rerun the same benchmark and the proving test lane. Record
|
||||
before/after wall time, Vitest duration, and max RSS when available.
|
||||
6. Run `pnpm check:changed`; run broader gates (`pnpm check`, `pnpm test`,
|
||||
`pnpm build`) when touched surfaces require them.
|
||||
7. Commit scoped changes with `scripts/committer "<conventional message>" <paths...>`.
|
||||
Push when requested. If CI is red, inspect with `gh run list/view`, fix, push,
|
||||
repeat until current CI is green or a blocker is proven unrelated.
|
||||
|
||||
## Output
|
||||
|
||||
End with the pushed commit(s), before/after timings, gates run, current CI state,
|
||||
and any remaining tail lanes that need separate optimization.
|
||||
@@ -1,6 +0,0 @@
|
||||
interface:
|
||||
display_name: "Optimize Tests"
|
||||
short_description: "Benchmark and speed up OpenClaw tests"
|
||||
default_prompt: "Use $optimizetests to benchmark slow OpenClaw tests, optimize imports and duplicated setup, move misplaced core coverage to extensions, verify gates, commit scoped changes, push, and keep CI green without adding shards or dropping coverage."
|
||||
policy:
|
||||
allow_implicit_invocation: false
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
name: openclaw-release-ci
|
||||
name: release-openclaw-ci
|
||||
description: "Run, watch, debug, and summarize OpenClaw full release CI, release checks, live provider gates, install/update proofs, and release-secret preflights."
|
||||
---
|
||||
|
||||
# OpenClaw Release CI
|
||||
|
||||
Use this with `$openclaw-release-maintainer` and `$openclaw-testing` when a release candidate needs full validation, install/update proof, live provider checks, or CI recovery.
|
||||
Use this with `$release-openclaw-maintainer` and `$openclaw-testing` when a release candidate needs full validation, install/update proof, live provider checks, or CI recovery.
|
||||
|
||||
## Guardrails
|
||||
|
||||
@@ -22,7 +22,7 @@ Use this with `$openclaw-release-maintainer` and `$openclaw-testing` when a rele
|
||||
Before full release validation:
|
||||
|
||||
```bash
|
||||
node .agents/skills/openclaw-release-ci/scripts/verify-provider-secrets.mjs --required openai,anthropic,fireworks
|
||||
node .agents/skills/release-openclaw-ci/scripts/verify-provider-secrets.mjs --required openai,anthropic,fireworks
|
||||
gh api rate_limit --jq '.resources.core'
|
||||
git status --short --branch
|
||||
git rev-parse HEAD
|
||||
@@ -35,6 +35,30 @@ The script prints only provider status and HTTP class, never tokens.
|
||||
|
||||
## Dispatch
|
||||
|
||||
Start product performance evidence as early as the release SHA exists, in
|
||||
parallel with other release work:
|
||||
|
||||
```bash
|
||||
gh workflow run openclaw-performance.yml \
|
||||
--repo openclaw/openclaw \
|
||||
--ref main \
|
||||
-f target_ref=<release-sha> \
|
||||
-f profile=release \
|
||||
-f repeat=3 \
|
||||
-f deep_profile=false \
|
||||
-f live_openai_candidate=false \
|
||||
-f fail_on_regression=false
|
||||
```
|
||||
|
||||
- Do not wait for full release validation to start this early perf signal.
|
||||
- Compare available Kova, gateway startup, and CLI startup metrics with earlier
|
||||
release evidence or clawgrit reports before publish/closeout.
|
||||
- Call out any regression in the release proof. Treat a major regression as a
|
||||
release blocker until it is fixed, waived by the operator, or proven to be
|
||||
infrastructure noise.
|
||||
- Full Release Validation also records advisory product-performance evidence;
|
||||
the early standalone run is for overlap and faster regression discovery.
|
||||
|
||||
Prefer the trusted workflow on `main`, target the exact release SHA:
|
||||
|
||||
```bash
|
||||
@@ -55,7 +79,7 @@ Use `release_profile=stable` unless the operator explicitly asks for the broad a
|
||||
Use the summary helper instead of repeated raw polling:
|
||||
|
||||
```bash
|
||||
node .agents/skills/openclaw-release-ci/scripts/release-ci-summary.mjs <full-release-run-id>
|
||||
node .agents/skills/release-openclaw-ci/scripts/release-ci-summary.mjs <full-release-run-id>
|
||||
```
|
||||
|
||||
Then watch only when useful:
|
||||
@@ -85,7 +109,8 @@ Record:
|
||||
|
||||
- release SHA
|
||||
- full parent run URL
|
||||
- child run IDs and conclusions: CI, Release Checks, Plugin Prerelease, NPM Telegram
|
||||
- child run IDs and conclusions: CI, Release Checks, Plugin Prerelease, NPM Telegram, Product Performance
|
||||
- performance comparison result versus earlier releases when available
|
||||
- targeted local proof commands
|
||||
- provider-secret preflight result
|
||||
- known gaps or unrelated failures
|
||||
@@ -1,4 +1,4 @@
|
||||
interface:
|
||||
display_name: "OpenClaw Release CI"
|
||||
short_description: "Verify and debug OpenClaw release validation runs"
|
||||
default_prompt: "Use $openclaw-release-ci to preflight provider secrets, watch full release validation, summarize child runs, and triage only failing release lanes."
|
||||
default_prompt: "Use $release-openclaw-ci to preflight provider secrets, watch full release validation, summarize child runs, and triage only failing release lanes."
|
||||
@@ -1,26 +1,23 @@
|
||||
---
|
||||
name: openclaw-mac-release
|
||||
name: release-openclaw-mac
|
||||
description: "Run or recover OpenClaw macOS release signing, notarization, appcast, and asset promotion."
|
||||
---
|
||||
|
||||
# OpenClaw Mac Release
|
||||
|
||||
Use with `$openclaw-release-maintainer`, `$openclaw-release-ci`, and `$one-password` when stable macOS assets, private mac preflight, notarization, appcast promotion, or mac release recovery is involved.
|
||||
Use with `$release-openclaw-maintainer`, `$release-openclaw-ci`, `$one-password`, and `$release-private` if it exists when stable macOS assets, private mac preflight, notarization, appcast promotion, or mac release recovery is involved.
|
||||
|
||||
## Credentials
|
||||
|
||||
- Canonical ASC item: vault `Molty`, title `API Key - App Store Connect - Personal - Release`.
|
||||
- Resolve Peter-owned ASC item refs, key ids, issuer ids, and service-token provenance from `$release-private`.
|
||||
- Fields: `private_key_p8`, `key_id`, `issuer_id`.
|
||||
- Current known good key id: `AKVLXW849T`.
|
||||
- Legacy mirror: vault `Private`, title `API Key - App Store Connect - Personal`; keep it synced for older refs.
|
||||
- Stale/revoked key symptom: `xcrun notarytool submit` fails with `HTTP status code: 401. Unauthenticated`.
|
||||
- Validate candidate ASC credentials with `xcrun notarytool history` before setting GitHub secrets.
|
||||
|
||||
## 1Password
|
||||
|
||||
- Use `$one-password`: all `op` work inside one persistent tmux session, no secret output.
|
||||
- Prefer `OP_SERVICE_ACCOUNT_TOKEN` from `~/.profile` for Molty reads.
|
||||
- Do not assume `MOLTY_OP_SERVICE_ACCOUNT_TOKEN` is alive; it has previously pointed at a deleted service account.
|
||||
- Use the service-token guidance from `$release-private` when available.
|
||||
- If a service token fails, run status-only checks: token present/length and `op whoami`; never print token values.
|
||||
- If desktop app auth is needed but Touch ID is unavailable, set `OP_BIOMETRIC_UNLOCK_ENABLED=false` for the manual `op account add --signin` path.
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
name: openclaw-release-maintainer
|
||||
name: release-openclaw-maintainer
|
||||
description: Prepare or verify OpenClaw stable/beta releases, changelogs, release notes, publish commands, and artifacts.
|
||||
---
|
||||
|
||||
# OpenClaw Release Maintainer
|
||||
|
||||
Use this skill for release and publish-time workflow. Keep ordinary development changes and GHSA-specific advisory work outside this skill.
|
||||
Use this skill for release and publish-time workflow. Load `$release-private` if it exists before resolving Peter-owned credential locators or private host topology. Keep ordinary development changes and GHSA-specific advisory work outside this skill.
|
||||
|
||||
## Respect release guardrails
|
||||
|
||||
@@ -23,7 +23,8 @@ Use this skill for release and publish-time workflow. Keep ordinary development
|
||||
green. Then branch from that commit so regular development can continue on
|
||||
`main` while release validation runs.
|
||||
- Before release branching, commit any dirty files in coherent groups, push,
|
||||
pull/rebase, then run `/changelog` on `main` and commit/push/pull that
|
||||
pull/rebase, then generate `CHANGELOG.md` on `main` from merged PRs and all
|
||||
direct commits since the last reachable release tag. Commit/push/pull that
|
||||
changelog rewrite immediately before creating the release branch.
|
||||
- During release planning, inspect both `src/plugins/compat/registry.ts` and
|
||||
`src/commands/doctor/shared/deprecation-compat.ts` before branching and again
|
||||
@@ -59,8 +60,17 @@ Use this skill for release and publish-time workflow. Keep ordinary development
|
||||
fixes that landed after the release branch cut and backport only important
|
||||
low-risk fixes. Operators may authorize up to 4 autonomous beta attempts;
|
||||
after 4 failed beta attempts, stop and report.
|
||||
- Use `/changelog` before version/tag preparation so the top changelog section
|
||||
is deduped and ordered by user impact.
|
||||
- As soon as the release candidate SHA exists, dispatch `OpenClaw Performance`
|
||||
with `target_ref=<release-sha>` in parallel with the other release work. Do
|
||||
not wait for full release validation to start the performance signal.
|
||||
- Before publish/closeout, compare available product performance metrics with
|
||||
earlier releases: Kova agent-turn/resource metrics, gateway startup
|
||||
ready/listen/RSS/CPU metrics, and CLI startup metrics from release evidence
|
||||
or clawgrit reports. Report regressions explicitly. A major regression is a
|
||||
release blocker unless the operator waives it or the data clearly proves
|
||||
infrastructure noise.
|
||||
- Generate the changelog before version/tag preparation so the top changelog
|
||||
section is deduped and ordered by user impact.
|
||||
- Do not create beta-specific `CHANGELOG.md` headings. Beta releases use the
|
||||
stable base version section, for example `v2026.4.20-beta.1` uses
|
||||
`## 2026.4.20` release notes.
|
||||
@@ -127,11 +137,25 @@ Use this skill for release and publish-time workflow. Keep ordinary development
|
||||
|
||||
## Build changelog-backed release notes
|
||||
|
||||
- `CHANGELOG.md` is release-owned. Normal PRs and direct `main` fixes should
|
||||
not edit it.
|
||||
- Before release branching or tagging, rewrite the target `CHANGELOG.md`
|
||||
section from commit history, not just from existing notes: scan commits since
|
||||
the last reachable release tag, add missed user-facing changes, dedupe
|
||||
overlapping entries, and sort each section from most to least interesting for
|
||||
users.
|
||||
section from history, not existing notes. Use the last reachable stable or
|
||||
beta release tag as the base, then inspect every commit through the target
|
||||
release SHA.
|
||||
- Include both merged PR commits and direct commits on `main`. Direct commits
|
||||
matter: infer notes from their subject, body, touched files, linked issues,
|
||||
tests, and nearby code when no PR body exists.
|
||||
- Prefer PR bodies, issue links, review proof, and commit bodies over commit
|
||||
subjects alone. If a commit fixed an issue directly, the commit body should
|
||||
name the user-visible behavior, affected surface, issue ref, and credited
|
||||
reporter/contributor when known.
|
||||
- Treat missing context as a release-note audit gap: inspect the diff and linked
|
||||
issue, draft the best accurate entry, and note the uncertainty for maintainer
|
||||
review rather than inventing impact.
|
||||
- Add missed user-facing changes, remove internal-only noise, dedupe overlapping
|
||||
PR/direct-commit entries, and sort each section from most to least interesting
|
||||
for users.
|
||||
- Changelog entries should be user-facing, not internal release-process notes.
|
||||
- GitHub release and prerelease bodies must use the full matching
|
||||
`CHANGELOG.md` version section, not highlights or an excerpt. When creating
|
||||
@@ -412,7 +436,7 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
- Hard rule: never run `op` directly in the main agent shell during release
|
||||
work. Any 1Password CLI use must happen inside that tmux session so prompts
|
||||
and alerts are contained and observable.
|
||||
- Use the 1Password item `op://Private/Npmjs` for npm credentials and OTP.
|
||||
- Use `$release-private` for the npm credentials and OTP item.
|
||||
Do not print passwords, tokens, or OTPs to the transcript; send them through
|
||||
tmux buffers, env vars scoped to the tmux command, or `expect` with
|
||||
`log_user 0`.
|
||||
@@ -540,34 +564,42 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
6. Create `release/YYYY.M.D` from that post-changelog `main` commit.
|
||||
7. Make every repo version location match the beta tag before creating it.
|
||||
8. Commit release preparation changes on the release branch and push the branch.
|
||||
9. Run the fast local beta preflight from the release branch before any npm
|
||||
preflight or publish. Keep expensive Docker, Parallels, and published-package
|
||||
install/update lanes for after the beta is live unless the operator asks to
|
||||
run them before beta publication.
|
||||
10. For beta releases, skip mac app build/sign/notarize unless beta scope or a
|
||||
9. Immediately dispatch Actions > `OpenClaw Performance` from `main` with
|
||||
`target_ref=<release-sha>`, `profile=release`, `repeat=3`, deep profiling
|
||||
off, live OpenAI off, and regression failure off. Let it run in parallel
|
||||
with preflight and validation work.
|
||||
10. Run the fast local beta preflight from the release branch before any npm
|
||||
preflight or publish. Keep expensive Docker, Parallels, and published-package
|
||||
install/update lanes for after the beta is live unless the operator asks to
|
||||
run them before beta publication.
|
||||
11. For beta releases, skip mac app build/sign/notarize unless beta scope or a
|
||||
release blocker specifically requires it. For stable releases, include the
|
||||
mac app, signing, notarization, and appcast path.
|
||||
11. Confirm the target npm version is not already published.
|
||||
12. Create and push the git tag from the release branch.
|
||||
13. Create or refresh the matching GitHub release.
|
||||
14. Dispatch Actions > `QA-Lab - All Lanes` against the release tag and wait
|
||||
12. Confirm the target npm version is not already published.
|
||||
13. Create and push the git tag from the release branch.
|
||||
14. Create or refresh the matching GitHub release.
|
||||
15. Dispatch Actions > `QA-Lab - All Lanes` against the release tag and wait
|
||||
for the mock parity, live Matrix, and live Telegram credentialed-channel
|
||||
lanes to pass.
|
||||
15. Start `.github/workflows/openclaw-npm-release.yml` from the release branch
|
||||
16. Start `.github/workflows/openclaw-npm-release.yml` from the release branch
|
||||
with `preflight_only=true`
|
||||
and choose the intended `npm_dist_tag` (`beta` default; `latest` only for
|
||||
an intentional direct stable publish). Wait for it to pass. Save that run id
|
||||
because the real publish requires it to reuse the prepared npm tarball.
|
||||
16. For stable releases, start `.github/workflows/macos-release.yml` in
|
||||
17. Before real publish, review the early performance run if it has completed.
|
||||
Compare against earlier release evidence or clawgrit reports where
|
||||
available. Call out minor regressions in the release proof; block on major
|
||||
regressions unless waived or proven noisy.
|
||||
18. For stable releases, start `.github/workflows/macos-release.yml` in
|
||||
`openclaw/openclaw` and wait for the public validation-only run to pass.
|
||||
17. For stable releases, start
|
||||
19. For stable releases, start
|
||||
`openclaw/releases-private/.github/workflows/openclaw-macos-validate.yml`
|
||||
with the same tag and wait for the private mac validation lane to pass.
|
||||
18. For stable releases, start
|
||||
20. For stable releases, start
|
||||
`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml`
|
||||
with `preflight_only=true` and wait for it to pass. Save that run id because
|
||||
the real publish requires it to reuse the notarized mac artifacts.
|
||||
19. If any preflight or validation run fails, fix the issue on a new commit,
|
||||
21. If any preflight or validation run fails, fix the issue on a new commit,
|
||||
delete the tag and matching GitHub release, recreate them from the fixed
|
||||
commit, and rerun all relevant preflights from scratch before continuing.
|
||||
Never reuse old preflight results after the commit changes. For pushed or
|
||||
@@ -575,15 +607,15 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
For preflight-only failures where npm did not publish the beta version,
|
||||
delete/recreate the same beta tag and prerelease at the fixed commit instead
|
||||
of skipping a prerelease number.
|
||||
20. Start `.github/workflows/openclaw-npm-release.yml` from the same branch with
|
||||
22. Start `.github/workflows/openclaw-npm-release.yml` from the same branch with
|
||||
the same tag for the real publish, choose `npm_dist_tag` (`beta` default,
|
||||
`latest` only when you intentionally want direct stable publish), keep it
|
||||
the same as the preflight run, and pass the successful npm
|
||||
`preflight_run_id`.
|
||||
21. Wait for `npm-release` approval from `@openclaw/openclaw-release-managers`.
|
||||
22. Run postpublish verification:
|
||||
23. Wait for `npm-release` approval from `@openclaw/openclaw-release-managers`.
|
||||
24. Run postpublish verification:
|
||||
`node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>`.
|
||||
23. Run the post-published beta verification roster. First scan current `main`
|
||||
25. Run the post-published beta verification roster. First scan current `main`
|
||||
for critical fixes that landed after the release branch cut; backport only
|
||||
important low-risk fixes before starting expensive lanes, or increment to
|
||||
the next beta if the fix must change the already-published package. If any
|
||||
@@ -597,10 +629,10 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
If a pre-npm lane fails before any tag/package leaves the machine, fix and
|
||||
rerun the same intended beta attempt. Repeat up to the operator's
|
||||
authorized beta-attempt limit, normally 4.
|
||||
24. Announce the beta/stable release on Discord best-effort using the configured secret workflow.
|
||||
25. If the operator requested beta only, stop after beta verification and the
|
||||
26. Announce the beta/stable release on Discord best-effort using the configured secret workflow.
|
||||
27. If the operator requested beta only, stop after beta verification and the
|
||||
announcement.
|
||||
26. If the stable release was published to `beta`, use the light stable
|
||||
28. If the stable release was published to `beta`, use the light stable
|
||||
promotion roster when the matching beta already carried the full confidence
|
||||
pass: published npm postpublish verify, Docker install/update smoke,
|
||||
macOS-only Parallels install/update smoke, and required QA signal.
|
||||
@@ -608,24 +640,24 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
`openclaw/releases-private/.github/workflows/openclaw-npm-dist-tags.yml`
|
||||
workflow to promote that stable version from `beta` to `latest`, then
|
||||
verify `latest` now points at that version.
|
||||
27. If the stable release was published directly to `latest` and `beta` should
|
||||
29. If the stable release was published directly to `latest` and `beta` should
|
||||
follow it, start that same private dist-tag workflow to point `beta` at the
|
||||
stable version, then verify both `latest` and `beta` point at that version.
|
||||
28. For stable releases, start
|
||||
30. For stable releases, start
|
||||
`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml`
|
||||
for the real publish with the successful private mac `preflight_run_id` and
|
||||
wait for success.
|
||||
29. Verify the successful real private mac run uploaded the `.zip`, `.dmg`,
|
||||
31. Verify the successful real private mac run uploaded the `.zip`, `.dmg`,
|
||||
and `.dSYM.zip` artifacts to the existing GitHub release in
|
||||
`openclaw/openclaw`.
|
||||
30. For stable releases, download `macos-appcast-<tag>` from the successful
|
||||
32. For stable releases, download `macos-appcast-<tag>` from the successful
|
||||
private mac run, update `appcast.xml` on `main`, and verify the feed. Merge
|
||||
or cherry-pick release branch changes back to `main` after stable succeeds.
|
||||
31. For beta releases, publish the mac assets only when intentionally requested;
|
||||
33. For beta releases, publish the mac assets only when intentionally requested;
|
||||
expect no shared production
|
||||
`appcast.xml` artifact and do not update the shared production feed unless a
|
||||
separate beta feed exists.
|
||||
32. After publish, verify npm and the attached release artifacts.
|
||||
34. After publish, verify npm and the attached release artifacts.
|
||||
|
||||
## GHSA advisory work
|
||||
|
||||
288
.agents/skills/release-openclaw-nightly/SKILL.md
Normal file
288
.agents/skills/release-openclaw-nightly/SKILL.md
Normal file
@@ -0,0 +1,288 @@
|
||||
---
|
||||
name: release-openclaw-nightly
|
||||
description: "OpenClaw Tideclaw alpha/nightly release automation: isolated branches, local fixes, release CI, branch retention, and forward-port to main."
|
||||
---
|
||||
|
||||
# Nightly Release
|
||||
|
||||
Use for Tideclaw/OpenClaw alpha/nightly release automation, manual alpha triggers, beta prep, release-branch repair, and post-release forward-port. Load `$release-private` if it exists before using Tideclaw host paths, cron ids, or Discord routing ids.
|
||||
|
||||
## Policy
|
||||
|
||||
- Alpha/nightly runs every 12h or by manual trigger.
|
||||
- Beta is human-triggered from Discord from a proven alpha/release branch.
|
||||
- Stable/latest always needs explicit human confirmation.
|
||||
- Never publish from a dirty checkout or directly from `main`.
|
||||
- Main can be busy or broken; alpha work must be isolated so transient main failures do not block a usable nightly.
|
||||
- Publish only after release-branch proof is green.
|
||||
- After a successful alpha, forward-port release-branch commits back to `main` and prove main CI green.
|
||||
- Forward-port PRs contain only reusable fixes needed to make nightly/release checks pass. They must not contain alpha version bumps, release notes, changelog release entries, tags, generated artifacts, or state-file updates.
|
||||
- Keep only alpha/nightly branches from the last 3 days, plus any branch with an active run, open PR, or release tag.
|
||||
- Never run broad env/token dumps. For GitHub writes on the Tideclaw host, use the Tideclaw `gh` write wrapper below.
|
||||
|
||||
## Identity
|
||||
|
||||
Tideclaw should commit under its own machine identity on release branches and forward-port branches:
|
||||
|
||||
```bash
|
||||
git config user.name "Tideclaw"
|
||||
git config user.email "tideclaw@openclaw.ai"
|
||||
```
|
||||
|
||||
This is good for auditability if commits are clearly machine-authored and gated by CI. Avoid direct pushes to protected `main`; forward-port via PR/automerge unless the repo policy explicitly allows the bot to push after green checks. Include human `Co-authored-by` only when a human supplied the patch or explicit commit text.
|
||||
|
||||
## Branch Shape
|
||||
|
||||
- Branch prefix: `tideclaw/alpha/`
|
||||
- Branch name: `tideclaw/alpha/YYYY-MM-DD-HHMMZ`
|
||||
- Base: current `origin/main` SHA at trigger time.
|
||||
- State file: resolve from `$release-private` on the Tideclaw host.
|
||||
- Release tag: `vYYYY.M.D-alpha.N`
|
||||
- npm dist-tag: `alpha`
|
||||
|
||||
Do not reuse old alpha branches for a new run. If rerunning the same base SHA, create a new timestamped branch and record why.
|
||||
|
||||
## Start
|
||||
|
||||
1. Work in the Tideclaw host checkout from `$release-private`.
|
||||
2. Fetch first:
|
||||
|
||||
```bash
|
||||
git fetch origin main --tags --prune
|
||||
git switch main
|
||||
git merge --ff-only origin/main
|
||||
BASE_SHA="$(git rev-parse origin/main)"
|
||||
BRANCH="tideclaw/alpha/$(date -u +%Y-%m-%d-%H%MZ)"
|
||||
git switch -c "$BRANCH" "$BASE_SHA"
|
||||
```
|
||||
|
||||
3. Read repo release docs/scripts before changing anything:
|
||||
- `AGENTS.md`
|
||||
- release docs under `docs/`
|
||||
- release scripts under `scripts/`
|
||||
- `.github/workflows/*release*`
|
||||
4. Compare `$BASE_SHA` with the last successful alpha state and current git/npm/GitHub alpha tags. If already released, report skip and do not publish.
|
||||
|
||||
Manual trigger:
|
||||
|
||||
```bash
|
||||
CRON_ID="<from release-private>"
|
||||
OPENCLAW_ALLOW_ROOT=1 openclaw cron run "$CRON_ID" --expect-final --timeout 21600000
|
||||
```
|
||||
|
||||
## Discord Alpha Trigger
|
||||
|
||||
Tideclaw may run alpha immediately from Discord when a maintainer mentions Tideclaw in `#releases` or `#maintainers`.
|
||||
|
||||
Accepted shapes:
|
||||
|
||||
```text
|
||||
@Tideclaw run alpha now
|
||||
@Tideclaw alpha release from main now
|
||||
@Tideclaw trigger alpha
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
1. Treat this as a manual alpha trigger equivalent to the alpha cron job.
|
||||
2. Start from current `origin/main` and create a fresh `tideclaw/alpha/YYYY-MM-DD-HHMMZ` branch.
|
||||
3. Follow the normal alpha workflow: reuse prior fixes, run local checks, fix on the alpha branch, run release CI, publish alpha after green gates, then forward-port reusable fixes via fixes-only PR.
|
||||
4. If another alpha/beta/stable release run is already active, report the active branch/run and stop.
|
||||
5. `#maintainers` trigger requires an explicit Tideclaw mention; do not react to unmentioned release chatter there.
|
||||
6. Resolve Discord role/user ids and live host hotfix notes from `$release-private`.
|
||||
|
||||
## Discord Beta Trigger
|
||||
|
||||
Tideclaw may run beta releases from `#releases` or mentioned `#maintainers` commands only when a maintainer sends an explicit beta trigger. Treat this as human approval for beta, not for stable/latest.
|
||||
|
||||
Accepted shapes:
|
||||
|
||||
```text
|
||||
@Tideclaw beta release from vYYYY.M.D-alpha.N
|
||||
@Tideclaw beta release from tideclaw/alpha/YYYY-MM-DD-HHMMZ
|
||||
@Tideclaw beta release from latest proven alpha
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
1. Require the words `beta release` and a source alpha tag/branch, or `latest proven alpha`.
|
||||
2. If the source is ambiguous, ask one clarifying question in `#releases` and stop.
|
||||
3. Verify the source alpha first: GitHub release, npm `alpha` package, release CI, recorded state file, and branch/tag SHA.
|
||||
4. Create a fresh beta branch `tideclaw/beta/YYYY-MM-DD-HHMMZ` from the proven alpha source, not directly from a moving `main`.
|
||||
5. Reuse/squash only stabilization fixes already proven on alpha. Do not import unrelated alpha release mechanics unless the beta release docs require them.
|
||||
6. Compute beta as `vYYYY.M.D-beta.N`, matching npm `--tag beta`.
|
||||
7. Run beta release validation/preflight/full release CI and fix failures on the beta branch.
|
||||
8. Publish beta only after green beta gates. Use GitHub Actions/OIDC, never direct npm publish from the host.
|
||||
9. Final Discord summary must include source alpha, beta tag/version, branch, fix commits, workflow run IDs, npm/GitHub proof, and any skipped/blocked reason.
|
||||
10. After beta publishes, forward-port reusable fixes to `main` using the same fixes-only PR rules below.
|
||||
|
||||
## Reuse Prior Fixes
|
||||
|
||||
Before running checks, mine recent Tideclaw alpha branches for fixes already made during previous release attempts:
|
||||
|
||||
1. Read the Tideclaw state file from `$release-private` for the last successful alpha branch and fix commit SHAs.
|
||||
2. List recent remote branches:
|
||||
|
||||
```bash
|
||||
git for-each-ref refs/remotes/origin/tideclaw/alpha --format='%(refname:short) %(committerdate:iso-strict)'
|
||||
```
|
||||
|
||||
3. Consider only Tideclaw alpha branches from the last 3 days plus the last successful alpha branch.
|
||||
4. For each candidate branch, inspect commits that are not in current `origin/main`:
|
||||
|
||||
```bash
|
||||
git log --no-merges --reverse --format='%H%x09%s' origin/main..origin/tideclaw/alpha/YYYY-MM-DD-HHMMZ
|
||||
```
|
||||
|
||||
5. Cherry-pick only real stabilization fixes that still apply to the new alpha branch. Prefer commits recorded as `fixCommitShas` in the state file.
|
||||
6. Skip version bumps, changelog release entries, tag artifacts, generated release notes, state-file-only commits, and one-off debug instrumentation.
|
||||
7. If a cherry-pick conflicts, inspect whether current main already contains an equivalent fix. If not, resolve minimally and keep the commit message clear.
|
||||
8. Record reused commit SHAs separately from newly authored fix SHAs in the alpha state and final Discord summary.
|
||||
|
||||
Use `git cherry`, `git range-diff`, and targeted test reruns to avoid duplicating fixes already present on `main`.
|
||||
|
||||
## Repair Loop
|
||||
|
||||
Use the branch as a release-candidate repair surface:
|
||||
|
||||
1. Run narrow local checks first: changed tests, release preflight, type/lint/build gates required by release docs.
|
||||
2. If local checks fail, fix on the alpha branch with minimal commits.
|
||||
3. Commit each coherent fix as Tideclaw.
|
||||
4. Re-run the failed local check after each fix.
|
||||
5. Do not hide failures by editing baselines, expected-failure lists, ignore files, or release inventory unless the release docs explicitly require it and the diff is justified.
|
||||
6. If a failure is flaky, rerun once; if still red, treat it as real.
|
||||
7. If the fix is clearly useful for main, keep it small and forward-portable. Avoid broad refactors during alpha stabilization.
|
||||
|
||||
Commit examples:
|
||||
|
||||
```bash
|
||||
git add <files>
|
||||
git commit -m "fix: stabilize alpha release preflight"
|
||||
git push -u origin "$BRANCH"
|
||||
```
|
||||
|
||||
## Release CI
|
||||
|
||||
After local proof:
|
||||
|
||||
1. Compute the next `vYYYY.M.D-alpha.N` from existing git tags, npm versions, and GitHub releases.
|
||||
2. Make the alpha branch package version and release metadata match that tag, commit it, and push the branch.
|
||||
3. Run release validation from the alpha branch, using GitHub CLI, not browser/fetch tools. On the Tideclaw host, bare `gh` is a read-only Codex sandbox wrapper; use `/usr/local/bin/gh-tideclaw-write` for write-capable commands such as `workflow run`, `run cancel`, and publish dispatch:
|
||||
|
||||
```bash
|
||||
GH="/usr/local/bin/gh-tideclaw-write"
|
||||
SHA="$(git rev-parse HEAD)"
|
||||
TAG="v$(node -p "require('./package.json').version")"
|
||||
BRANCH="$(git branch --show-current)"
|
||||
|
||||
"$GH" workflow run full-release-validation.yml --repo openclaw/openclaw --ref "$BRANCH" \
|
||||
-f ref="$BRANCH" \
|
||||
-f release_profile=beta \
|
||||
-f rerun_group=all
|
||||
|
||||
"$GH" workflow run openclaw-npm-release.yml --repo openclaw/openclaw --ref "$BRANCH" \
|
||||
-f tag="$SHA" \
|
||||
-f preflight_only=true \
|
||||
-f npm_dist_tag=alpha
|
||||
```
|
||||
|
||||
4. Watch the exact workflow run IDs and head SHA with `gh run list`, `gh run view`, and `gh api`. Read-only `gh` is fine for polling; use `$GH` only when a command mutates GitHub. Do not use Codex browser/fetch for GitHub API polling; prior Tideclaw runs failed there after successful preflight.
|
||||
5. For alpha, blocking gates are the ones Tideclaw can repair directly or that prove package safety: normal CI, plugin prerelease, npm preflight, package preparation, install smoke, tag/reachability, and publish verification. Treat cross-OS, live channel, QA Lab, package acceptance, long Docker E2E, and Telegram package E2E failures as advisory; report them in Discord and continue if the blocking gates are green.
|
||||
- If `rerun_group=all` is stuck only on advisory lanes after CI, plugin prerelease, npm preflight, package preparation, and install smoke are green, dispatch a focused Full Release Validation on the same head with `-f rerun_group=install-smoke`. Use that successful focused Full Release Validation run as the publish proof, and include the separate CI/plugin/full advisory run IDs in the Discord summary.
|
||||
6. If a blocking gate fails, fix on the alpha branch, push, and rerun only the failed or required release CI. If the commit changes, discard old preflight/full-validation run IDs and rerun them for the new head.
|
||||
7. After full validation and npm preflight are green on the same branch head, create and push the release tag from that exact commit:
|
||||
|
||||
```bash
|
||||
git tag -a "$TAG" "$SHA" -m "openclaw ${TAG#v}"
|
||||
git push origin "$TAG"
|
||||
```
|
||||
|
||||
8. Dispatch the publish wrapper from the same alpha branch. Use the successful npm preflight run ID and full release validation run ID from the same head SHA:
|
||||
|
||||
```bash
|
||||
"$GH" workflow run openclaw-release-publish.yml --repo openclaw/openclaw --ref "$BRANCH" \
|
||||
-f tag="$TAG" \
|
||||
-f preflight_run_id="$NPM_PREFLIGHT_RUN_ID" \
|
||||
-f full_release_validation_run_id="$FULL_RELEASE_VALIDATION_RUN_ID" \
|
||||
-f npm_dist_tag=alpha \
|
||||
-f plugin_publish_scope=all-publishable \
|
||||
-f publish_openclaw_npm=true \
|
||||
-f release_profile=beta \
|
||||
-f wait_for_clawhub=false
|
||||
```
|
||||
|
||||
9. Watch the publish wrapper plus child runs. If `openclaw-npm-release.yml` is waiting on the `npm-release` environment and Tideclaw cannot approve it, report that as the only blocker; do not call the release done.
|
||||
10. Do not publish npm directly from the host; use GitHub Actions/OIDC.
|
||||
|
||||
Important: `openclaw-npm-release.yml` with `preflight_only=true` only prepares artifacts. It does not publish. A successful alpha requires the later `openclaw-release-publish.yml` wrapper, a pushed git tag, npm `alpha` dist-tag proof, and a GitHub prerelease.
|
||||
|
||||
## Verify Published Alpha
|
||||
|
||||
Release is not done until all are true:
|
||||
|
||||
- GitHub tag exists.
|
||||
- GitHub Release exists and is marked prerelease.
|
||||
- Release body links npm version page, registry tarball, integrity, and CI/proof.
|
||||
- `npm view openclaw@<version>` shows the exact version, dist-tag `alpha`, tarball, integrity, and publish time.
|
||||
- Installed/package smoke follows repo release docs.
|
||||
- The Tideclaw state file from `$release-private` records version, tag, base SHA, branch, fix commit SHAs, workflow run IDs, npm integrity, and timestamp.
|
||||
|
||||
Final Discord summary in `#releases`:
|
||||
|
||||
- tag/version
|
||||
- base SHA
|
||||
- branch
|
||||
- fix commits
|
||||
- workflow run IDs
|
||||
- npm/GitHub proof
|
||||
- skipped/blocked reason if not released
|
||||
|
||||
Use Discord-safe Markdown links with angle-bracket targets. Never print secrets.
|
||||
|
||||
## Forward-Port
|
||||
|
||||
After a successful alpha, raise a fixes-only PR back to `main`:
|
||||
|
||||
1. Create/update a forward-port branch from current `origin/main`:
|
||||
|
||||
```bash
|
||||
git fetch origin main --prune
|
||||
git switch -c "tideclaw/forward-port/$(date -u +%Y-%m-%d-%H%MZ)" origin/main
|
||||
```
|
||||
|
||||
2. Cherry-pick only release-branch commits that are real fixes required to make nightly/release checks pass.
|
||||
3. Exclude alpha version bumps, changelog release entries, release notes, tag artifacts, generated release assets, state-file-only commits, and any commit whose only purpose was publishing the alpha.
|
||||
4. If a commit mixes a real fix with release/version changes, split it: replay only the fix hunks into a new commit on the forward-port branch.
|
||||
5. Resolve conflicts in favor of the minimal main-compatible fix.
|
||||
6. Run the relevant changed/local gate.
|
||||
7. Push and open a PR, or use the repo’s allowed bot merge path.
|
||||
8. Wait for required main CI to go green. If CI fails, fix on the forward-port branch and rerun.
|
||||
9. Report the PR/merge SHA and any commits intentionally not forward-ported.
|
||||
|
||||
If `origin/main` is independently red before the forward-port, document the unrelated failing check and still keep the forward-port PR green against its head when possible.
|
||||
|
||||
## Branch Retention
|
||||
|
||||
Before and after each run, prune old alpha branches:
|
||||
|
||||
1. List `origin/tideclaw/alpha/*`.
|
||||
2. Keep branches whose timestamp is within the last 3 days UTC.
|
||||
3. Keep branches referenced by a live workflow run, open PR, release tag, or state file.
|
||||
4. Delete only Tideclaw-owned alpha branches:
|
||||
|
||||
```bash
|
||||
git push origin --delete tideclaw/alpha/YYYY-MM-DD-HHMMZ
|
||||
```
|
||||
|
||||
Never delete human branches, beta branches, stable branches, or unknown prefixes.
|
||||
|
||||
## Stop Conditions
|
||||
|
||||
Stop and report clearly if:
|
||||
|
||||
- release docs/scripts disagree on versioning or publish path
|
||||
- required secrets/auth are unavailable
|
||||
- GitHub Actions cannot be dispatched or observed
|
||||
- a required release gate stays red after a real fix attempt
|
||||
- npm/GitHub state disagrees after publish
|
||||
- forward-port cannot be made green without a larger product decision
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: openclaw-pre-release-plugin-testing
|
||||
name: release-openclaw-plugin-testing
|
||||
description: Plan and run pre-release OpenClaw plugin validation across bundled plugins, package artifacts, lifecycle commands, doctor/fix, config round-trip, gateway startup, SDK compatibility, Docker E2E, Package Acceptance, and Testbox proof.
|
||||
---
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "OpenClaw Plugin Pre-Release Testing"
|
||||
short_description: "Plan plugin release validation"
|
||||
default_prompt: "Use $release-openclaw-plugin-testing to plan or run pre-release OpenClaw plugin validation across package, lifecycle, doctor, gateway, SDK, and live-ish proof."
|
||||
79
.agents/skills/technical-documentation/SKILL.md
Normal file
79
.agents/skills/technical-documentation/SKILL.md
Normal file
@@ -0,0 +1,79 @@
|
||||
---
|
||||
name: technical-documentation
|
||||
description: Build and review high-quality technical docs as well as agent instruction files in your repository.
|
||||
license: MIT
|
||||
metadata:
|
||||
source: "https://github.com/vincentkoc/dotskills"
|
||||
---
|
||||
|
||||
# Technical Documentation
|
||||
|
||||
## Purpose
|
||||
|
||||
Produce and review technical documentation that is clear, actionable, and maintainable for both humans and agents, including contributor-governance files and agent instruction files.
|
||||
|
||||
## When to use
|
||||
|
||||
- Creating or overhauling docs in an existing product/codebase (brownfield).
|
||||
- Building evergreen docs meant to stay accurate and reusable over time.
|
||||
- Reviewing doc diffs for structure, clarity, and operational correctness.
|
||||
- Running full-repo documentation audits that must include both governance files and product docs surfaces (`docs/`, `README*`, `.md/.mdx/.mdc`, Fern/Sphinx/Mintlify-style sources).
|
||||
- Updating or reviewing AGENTS.md and/or CONTRIBUTING.md to keep agent and contributor workflows aligned with current repo practices.
|
||||
- Improving repository onboarding/docs that include contribution instructions, issue templates, PR flow, and review gates.
|
||||
- Designing governance documentation strategy for repos with alias instruction files (for example `CLAUDE.md`, `AGENT.md`, `.cursorrules`, `.cursor/rules/*`, `.agent/`, `.agents/`, `.pi/`) where `AGENTS.md` is treated as canonical when present and aliases should be kept as compatibility surfaces.
|
||||
- Diagnosing agent-file drift where teams had to prompt iteratively to surface missing files, broken commands, or policy conflicts.
|
||||
- Applying repository-specific documentation overlays, including OpenClaw page-type, docs IA, preservation, and validation rules when present.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Classify task: `build` or `review`; context: `brownfield` or `evergreen`.
|
||||
2. Inventory full documentation scope early (governance + product docs): AGENTS/CONTRIBUTING/aliases plus docs directories, framework sources, and root/module READMEs.
|
||||
3. Detect multilingual scope (README/docs in multiple languages) and define required parity level.
|
||||
4. Read `references/agent-and-contributing.md` for agent instruction and `CONTRIBUTING.md` workflow rules (inventory, canonical/alias mapping, dual-mode balance, deliverable standards, and precedence/conflict handling).
|
||||
5. Read `references/principles.md` for the governing ruleset (Matt Palmer & OpenAI).
|
||||
6. For OpenClaw docs work, read `references/openclaw.md` before the build/review playbook.
|
||||
7. For build tasks, follow `references/build.md`.
|
||||
8. For review tasks, follow `references/review.md` and proactively detect issues without waiting for repeated prompts.
|
||||
9. For complex or high-risk tasks (build or review), it is acceptable to run longer, deeper, and more exhaustive investigations when needed for confidence.
|
||||
10. When available, use sub-agents for bounded parallel discovery/review work, then merge outputs into one coherent final deliverable.
|
||||
11. Use `references/tooling.md` when platform/tooling choices affect recommendations.
|
||||
12. Run a proactive issue sweep for both governance and docs-content surfaces, and fix high-confidence defects in the same pass unless explicitly asked for report-only mode.
|
||||
13. In brownfield mode, prioritize compatibility with current docs IA, tooling, and release state.
|
||||
14. In evergreen mode, prioritize timeless wording, update strategy, and durable structure.
|
||||
15. Return deliverables plus validation notes, parity status, and remaining gaps.
|
||||
|
||||
## Sub-agent orchestration guidance
|
||||
|
||||
Prefer sub-agents when the repo is large or the requested change set is broad; use them by default for repo-wide, multi-framework, or high-conflict work.
|
||||
|
||||
- `inventory-agent` -> `agents/inventory-agent.md` (`fast` / Claude `haiku`): file/config discovery, coverage map, and missing-path checks.
|
||||
- `governance-agent` -> `agents/governance-agent.md` (`thinking` / Claude `sonnet`): AGENTS/CONTRIBUTING/alias precedence, conflicts, and policy drift.
|
||||
- `docs-framework-agent` -> `agents/docs-framework-agent.md` (`thinking` / Claude `sonnet`): framework config, relative path base, and file-path vs URL-path mapping checks.
|
||||
- `synthesis-agent` -> `agents/synthesis-agent.md` (`long` / Claude `opus`): merge sub-agent outputs into one prioritized fix plan and unified precedence model.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Doc type (tutorial, how-to, reference, explanation) and audience.
|
||||
- File scope or diff scope.
|
||||
- Docs framework/tooling constraints (Fern, Mintlify, Sphinx, etc.).
|
||||
- Build/review mode and brownfield/evergreen intent.
|
||||
- Target agent and human compatibility intent.
|
||||
- Docs framework surfaces in scope (for example Fern, Sphinx, Mintlify, Markdown/MDX/MDC/RST/RSC files).
|
||||
- Desired investigation depth/time budget (quick pass vs exhaustive review).
|
||||
- Execution mode (`single-agent` or `sub-agent-assisted` when available).
|
||||
- Remediation mode (`apply-fixes` by default, or `report-only` when requested).
|
||||
- Multilingual scope: source-of-truth language, target locales, and parity expectations.
|
||||
- Repository-specific overlay constraints, if any.
|
||||
|
||||
## Outputs
|
||||
|
||||
- Updated draft or review findings with clear next actions.
|
||||
- Validation notes (what was checked, what remains).
|
||||
- Navigation/maintenance recommendations for long-term quality.
|
||||
- Governance-doc alignment summary when AGENTS/CONTRIBUTING were touched.
|
||||
- Agent instruction-surface map (primary file, alias files, Codex/Claude/Cursor handling plan).
|
||||
- Documentation-surface coverage map (what was reviewed under `/docs`, README hierarchy, and framework-specific source trees).
|
||||
- Autodetected issue list with applied fixes (or explicit report-only findings).
|
||||
- Delegation notes when sub-agents were used (scope delegated and how findings were merged).
|
||||
- Multilingual parity note (in-sync, partial with rationale, or intentionally divergent).
|
||||
- Repository-specific overlay notes when one was used.
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
name: docs-framework-agent
|
||||
description: Thinking-focused docs framework checker for config-relative paths and route/file mapping consistency.
|
||||
model: sonnet
|
||||
tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
permissionMode: default
|
||||
maxTurns: 10
|
||||
---
|
||||
|
||||
You are the docs-framework sub-agent for technical documentation.
|
||||
|
||||
Goals:
|
||||
|
||||
- validate framework config-driven docs behavior
|
||||
- prevent path-mapping drift between source files and published routes
|
||||
|
||||
Tasks:
|
||||
|
||||
- detect and read framework config first (Fern/Sphinx/Mintlify/custom)
|
||||
- resolve paths relative to the declaring file/config
|
||||
- validate both maps:
|
||||
- config -> file exists
|
||||
- config/nav/routing -> URL path is valid and consistent
|
||||
|
||||
Return:
|
||||
|
||||
- config files reviewed
|
||||
- path assumptions made
|
||||
- mismatches (`missing file`, `stale route`, `wrong base path`)
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
name: governance-agent
|
||||
description: Thinking-focused governance reviewer for AGENTS/CONTRIBUTING/alias precedence, conflict detection, and policy drift analysis.
|
||||
model: sonnet
|
||||
tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
permissionMode: default
|
||||
maxTurns: 10
|
||||
---
|
||||
|
||||
You are the governance sub-agent for technical documentation.
|
||||
|
||||
Goals:
|
||||
|
||||
- validate AGENTS/CONTRIBUTING/alias alignment and precedence
|
||||
- identify policy drift and conflicting instructions
|
||||
|
||||
Tasks:
|
||||
|
||||
- determine canonical instruction source and alias compatibility mapping
|
||||
- detect conflicts across nested scope files and tool-specific rule consumers
|
||||
- validate command examples against stated governance expectations
|
||||
|
||||
Return:
|
||||
|
||||
- precedence model
|
||||
- conflict list with severity
|
||||
- recommended low-risk remediations
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
name: inventory-agent
|
||||
description: Fast repo-surface discovery for technical documentation audits. Use for coverage mapping and missing-path detection before deeper review.
|
||||
model: haiku
|
||||
tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
- LS
|
||||
permissionMode: default
|
||||
maxTurns: 6
|
||||
---
|
||||
|
||||
You are the inventory sub-agent for technical documentation.
|
||||
|
||||
Goals:
|
||||
|
||||
- enumerate governance and docs-content surfaces in scope
|
||||
- detect missing files, broken references, and obvious command/path failures
|
||||
|
||||
Tasks:
|
||||
|
||||
- map `AGENTS.md`/`CONTRIBUTING.md`/aliases and docs surfaces (`docs/**`, README hierarchy, `.md/.mdx/.mdc/.rst/.rsc`)
|
||||
- list framework config files discovered (Fern/Sphinx/Mintlify or equivalent)
|
||||
- report hard failures only, with exact file paths
|
||||
|
||||
Return:
|
||||
|
||||
- coverage map
|
||||
- missing/broken path list
|
||||
- unresolved blockers
|
||||
10
.agents/skills/technical-documentation/agents/openai.yaml
Normal file
10
.agents/skills/technical-documentation/agents/openai.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
interface:
|
||||
display_name: "Technical Documentation"
|
||||
short_description: "Build and review technical documentation for brownfield and evergreen systems."
|
||||
icon_small: "./assets/icon.jpg"
|
||||
icon_large: "./assets/icon.jpg"
|
||||
brand_color: "#111827"
|
||||
default_prompt: "Build or review technical documentation with a clear, maintainable, and production-ready workflow."
|
||||
|
||||
policy:
|
||||
allow_implicit_invocation: true
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: synthesis-agent
|
||||
description: Long-context synthesis agent that merges sub-agent outputs into one prioritized and deduplicated documentation action plan.
|
||||
model: opus
|
||||
tools:
|
||||
- Read
|
||||
permissionMode: default
|
||||
maxTurns: 12
|
||||
---
|
||||
|
||||
You are the synthesis sub-agent for technical documentation.
|
||||
|
||||
Goal:
|
||||
|
||||
- merge sub-agent outputs into one coherent, non-duplicated action plan
|
||||
|
||||
Tasks:
|
||||
|
||||
- prioritize blockers first, then non-blocking improvements
|
||||
- normalize to one precedence model for governance decisions
|
||||
- remove duplicated recommendations and contradictory fixes
|
||||
- keep final output concise and execution-ready
|
||||
|
||||
Return:
|
||||
|
||||
- prioritized fix plan
|
||||
- validation summary (done vs pending)
|
||||
- explicit remaining gaps/blockers
|
||||
BIN
.agents/skills/technical-documentation/assets/icon.jpg
Normal file
BIN
.agents/skills/technical-documentation/assets/icon.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
@@ -0,0 +1,145 @@
|
||||
# AGENT and CONTRIBUTING Principles
|
||||
|
||||
This reference consolidates the core rules for agent-policy and contributor-governance docs.
|
||||
|
||||
You must:
|
||||
|
||||
1. Discover repo-level and nested instruction files with:
|
||||
`rg --files -g 'AGENTS.md' -g 'CONTRIBUTING.md' -g 'CLAUDE.md' -g 'AGENT.md' -g '.cursor/rules/*' -g '.cursorrules' -g '.agent/**' -g '.agents/**' -g '.pi/**' -g 'AGENTS.*.md'`
|
||||
2. Read the root and nearest-scope `AGENTS.md`/`CONTRIBUTING.md` pair before editing.
|
||||
3. If alias files exist, normalize to one canonical source (`AGENTS.md` preferred when present; otherwise nearest alias), plus compatibility pointers or explicit symlink notes.
|
||||
4. Document conflicting instructions and precedence decisions.
|
||||
|
||||
## GitHub + AGENTS baseline
|
||||
|
||||
Source: https://docs.github.com/en/communities/setting-up-your-project-for-healthy-contributions/setting-guidelines-for-repository-contributors
|
||||
Source: https://agents.md/
|
||||
Source: https://github.blog/ai-and-ml/github-copilot/how-to-write-a-great-agents-md-lessons-from-over-2500-repositories/
|
||||
Source: https://cobusgreyling.substack.com/p/what-is-agentsmd
|
||||
Source: https://www.infoq.com/news/2025/08/agents-md/
|
||||
|
||||
Use these as default operating principles:
|
||||
|
||||
1. Keep `CONTRIBUTING.md` discoverable and actionable (`.github`, root, or `docs`).
|
||||
2. Keep agent instructions concrete: real commands, real paths, clear boundaries.
|
||||
3. Use explicit behavior boundaries for agents: `Always`, `Ask first`, `Never`.
|
||||
4. Keep contributor and agent rules aligned with actual repository workflows.
|
||||
5. Ensure clear guidance is provided to agents on if, when and how to raise issues and pull requests.
|
||||
|
||||
## Canonical and alias policy
|
||||
|
||||
Source: https://agents.md/
|
||||
Source: https://github.blog/ai-and-ml/github-copilot/how-to-write-a-great-agents-md-lessons-from-over-2500-repositories/
|
||||
|
||||
1. Treat `AGENTS.md` as canonical when present.
|
||||
2. If `AGENTS.md` is absent, treat the nearest alias file as canonical.
|
||||
3. Keep compatibility surfaces explicit: `AGENTS.md`, `AGENT.md`, `.cursorrules`, `.cursor/rules/*`, `.agent/`, `.agents/`, `.pi/`.
|
||||
4. If aliases are used, document how they map back to canonical policy (or symlink when supported).
|
||||
5. When repos use `.agents/` as canonical rule storage, keep `.cursor` as a compatibility symlink to `.agents` for Cursor rule auto-loading.
|
||||
6. Keep policy DRY: store one shared policy core and expose it via aliases/symlinks instead of duplicating rule text.
|
||||
|
||||
## Context-awareness by agent platform
|
||||
|
||||
Source: https://github.com/vercel-labs/agent-skills/blob/main/AGENTS.md
|
||||
Source: https://github.com/openai/codex/blob/main/AGENTS.md
|
||||
|
||||
1. For Cursor and Claude-style glob consumers, keep rule files narrow and bounded.
|
||||
2. Avoid over-referencing large path sets that inflate context for glob-based agents.
|
||||
3. For Codex-style workflows, prefer explicit file references and deterministic commands.
|
||||
4. Keep long runbooks outside top-level policy files; link to scoped docs.
|
||||
5. Ensure all agents have a happy path regardless so ensuring everything works across Codex, Claude and other coding agents.
|
||||
|
||||
## Symlink and compatibility operations
|
||||
|
||||
1. Preferred layout for multi-agent compatibility:
|
||||
- canonical rule directory: `.agents/`
|
||||
- Cursor compatibility path: `.cursor -> .agents` symlink
|
||||
- canonical policy doc: `AGENTS.md` pointing to `.agents` paths where relevant
|
||||
2. Validate symlink state before finalizing changes:
|
||||
- if `.agents/` exists and `.cursor` is missing, create `.cursor` symlink to `.agents`
|
||||
- if `.cursor` is a symlink to another target, fix target or document why it must differ
|
||||
- if `.cursor` is a real directory/file, treat as migration conflict and ask before replacement
|
||||
3. Validate rule payload through the canonical directory:
|
||||
- rules: `.agents/rules/*.mdc` with valid frontmatter (`description`, `globs`, `alwaysApply` as needed)
|
||||
- commands: `.agents/commands/*.md` when command routing is used
|
||||
- MCP config: `.agents/mcp.json` when MCP is in scope
|
||||
4. Keep Codex behavior explicit:
|
||||
- `AGENTS.md` is primary for Codex repository instructions
|
||||
- `.cursor` compatibility is for Cursor auto-loading and does not replace canonical AGENTS policy
|
||||
5. Record applied symlink fixes and unresolved compatibility gaps in validation notes.
|
||||
|
||||
## Dual-mode and deliverable standards
|
||||
|
||||
Source: https://github.blog/ai-and-ml/github-copilot/how-to-write-a-great-agents-md-lessons-from-over-2500-repositories/
|
||||
Source: https://agents.md/
|
||||
Source: https://github.com/openai/codex/blob/main/AGENTS.md
|
||||
Source: https://github.com/vercel-labs/agent-skills/blob/main/AGENTS.md
|
||||
|
||||
1. Author one shared policy core (same commands, boundaries, and precedence) for all agents.
|
||||
2. For Cursor/Claude-style agents, expose that core through glob-driven and bounded files (small `AGENTS.md`/rule surface).
|
||||
3. For Codex, expose that same core through explicit file references with precise scope.
|
||||
4. Where styles diverge, prefer the smallest common structure that satisfies both and avoid duplicating policy text.
|
||||
5. Treat AGENTS/CONTRIBUTING as first-class deliverables when in scope.
|
||||
6. Preserve required structure, constraints, and examples from existing files.
|
||||
7. Align wording and commands with active repository instructions.
|
||||
|
||||
## Proactive issue discovery and remediation
|
||||
|
||||
Source: https://github.blog/ai-and-ml/github-copilot/how-to-write-a-great-agents-md-lessons-from-over-2500-repositories/
|
||||
Source: https://github.com/openai/codex/blob/main/AGENTS.md
|
||||
Source: https://github.com/vercel-labs/agent-skills/blob/main/AGENTS.md
|
||||
|
||||
1. Run a conflict matrix review across AGENTS/aliases/CONTRIBUTING and related command/rule docs before finalizing.
|
||||
2. Treat the following as high-priority defects: missing referenced files, non-existent setup commands, command scope mismatches, and branch/commit policy conflicts.
|
||||
3. Do not stop at caveat-only notes when a low-risk fix is clear; apply the fix in the same pass.
|
||||
4. If a canonical entry file is missing (for example a directory `README.md` that docs depend on), create a minimal actionable file and update references.
|
||||
5. Long-running investigations are acceptable when needed to uncover cross-file drift, especially in agent-instruction ecosystems.
|
||||
|
||||
## Discovery
|
||||
|
||||
1. Agents prefer simple terminal commands so having a well defined `make *` or `npm run *` is ideal
|
||||
2. Agents can discover terminal commands through shell completion so providing shell completion helps
|
||||
|
||||
## CONTRIBUTING size and scope control
|
||||
|
||||
Source: https://contributing.md/how-to-build-contributing-md/
|
||||
Source: https://blog.codacy.com/best-practices-to-manage-an-open-source-project
|
||||
Source: https://mozillascience.github.io/working-open-workshop/contributing/
|
||||
Source: https://github.com/openclaw/openclaw/blob/main/CONTRIBUTING.md
|
||||
|
||||
1. Keep root `CONTRIBUTING.md` focused on setup, issue flow, PR flow, testing, and review gates.
|
||||
2. Use issue/PR template links instead of embedding every process detail inline.
|
||||
3. When the file grows too large, split by domain and link from root.
|
||||
4. Move any large content into docs if avalible (for example Mintlify/Fern/Sphinx workflows) to avoid large contributor guide.
|
||||
5. Optimize for agent/machine readability as well as humans.
|
||||
|
||||
## Example repos to emulate
|
||||
|
||||
Source: https://github.com/openclaw/openclaw/blob/main/AGENTS.md
|
||||
Source: https://github.com/openclaw/openclaw/blob/main/CONTRIBUTING.md
|
||||
Source: https://github.com/openclaw/openclaw/blob/main/VISION.md
|
||||
Source: https://github.com/openai/codex/blob/main/AGENTS.md
|
||||
Source: https://github.com/processing/p5.js/blob/main/AGENTS.md
|
||||
Source: https://github.com/vercel-labs/agent-skills/blob/main/AGENTS.md
|
||||
Source: https://github.com/agentsmd/agents.md/blob/main/AGENTS.md
|
||||
Source: https://github.com/rails/rails/blob/main/CONTRIBUTING.md
|
||||
Source: https://github.com/kubernetes/kubernetes/blob/master/CONTRIBUTING.md
|
||||
Source: https://github.com/atom/atom/blob/master/CONTRIBUTING.md
|
||||
Source: https://github.com/github/docs/blob/main/CONTRIBUTING.md
|
||||
Source: https://github.com/facebook/react/blob/main/CONTRIBUTING.md
|
||||
|
||||
1. OpenClaw: strong real-world alias policy and AGENTS/CONTRIBUTING/VISION cohesion.
|
||||
2. OpenAI Codex: strict command discipline and explicit scope control.
|
||||
3. p5.js: explicit AI-policy guardrails in agent instructions.
|
||||
4. Vercel + agentsmd spec: compact, context-efficient AGENTS patterns.
|
||||
5. Rails/Kubernetes/Atom/GitHub Docs/React: contributor guidance patterns at different project scales.
|
||||
|
||||
## Practical merge policy
|
||||
|
||||
When these rules conflict:
|
||||
|
||||
1. Preserve contributor and reader task success first.
|
||||
2. Preserve instruction clarity and unambiguous boundaries second.
|
||||
3. Preserve long-term maintainability and context-efficiency third.
|
||||
4. Add extra agent optimization only if it does not reduce human clarity or there is explict need.
|
||||
5. Use your judgement as the expert.
|
||||
116
.agents/skills/technical-documentation/references/build.md
Normal file
116
.agents/skills/technical-documentation/references/build.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# Build Docs Playbook
|
||||
|
||||
Read `principles.md` first, then follow this execution flow.
|
||||
|
||||
## 1. Detect and align agent instruction and governance instructions
|
||||
|
||||
- Use `references/agent-and-contributing.md` as the source of truth for inventory, canonical/alias mapping, and precedence/conflict handling.
|
||||
- Apply the symlink compatibility policy when in scope (`.agents` canonical directory with `.cursor` compatibility symlink when required by tooling).
|
||||
- Long-running and extensive build investigations are acceptable when needed to resolve ambiguous or conflicting documentation sources.
|
||||
- When available, use sub-agents for bounded parallel inventory/cross-check tasks and merge results into one canonical decision set.
|
||||
- Capture required constraints before writing:
|
||||
- nested-agent rules, command/test requirements, PR workflow, and style checks.
|
||||
- Use the same command and validation expectations in proposed snippets and examples.
|
||||
|
||||
## 2. Inventory product documentation surfaces (not governance only)
|
||||
|
||||
- For repo-wide builds, include docs content surfaces in addition to AGENTS/CONTRIBUTING.
|
||||
- Inventory docs files and frameworks in scope (examples): `README*.md`, `docs/**`, `**/*.md`, `**/*.mdx`, `**/*.mdc`, `**/*.rst`, `**/*.rsc`, Fern/Mintlify config, Sphinx `conf.py`.
|
||||
- Build a coverage map before drafting so governance and product docs are both represented.
|
||||
- If scope is ambiguous, default to broader docs discovery first, then narrow intentionally.
|
||||
|
||||
## 3. Framework config and path mapping rules
|
||||
|
||||
- Detect framework/config first (for example Fern config, Sphinx `conf.py`, Mintlify config, or equivalent).
|
||||
- Resolve every referenced path relative to the file/config that declares it, not assumed repo root.
|
||||
- Treat filesystem paths and published URL routes as separate mappings; do not infer one from the other without config evidence.
|
||||
- Validate both layers:
|
||||
- config -> file exists on disk
|
||||
- config/nav/routing -> URL path is consistent and reachable
|
||||
- Record path-mapping assumptions and mismatches in handoff (`missing file`, `stale route`, `wrong base path`).
|
||||
|
||||
## 4. Define intent and success
|
||||
|
||||
- Audience, prerequisites, and job-to-be-done.
|
||||
- Expected reader outcome immediately after completion.
|
||||
- Doc type: tutorial, how-to, reference, explanation.
|
||||
- Success criteria: what must be true after publish.
|
||||
|
||||
## 5. Build structure before prose
|
||||
|
||||
- Follow the funnel: what/why, quickstart, next steps.
|
||||
- Keep headings informative and scannable.
|
||||
- Open each section with the takeaway sentence.
|
||||
- Add decision points with concrete branch guidance.
|
||||
- For OpenClaw docs work, choose a page type from `references/openclaw.md` before drafting.
|
||||
- Keep task-critical OpenClaw configuration inline; link exhaustive defaults, enums, schemas, generated references, and rare debugging workflows.
|
||||
|
||||
## 6. Build AGENTS.md and CONTRIBUTING.md intentionally
|
||||
|
||||
- Keep AGENTS.md structure consistent with `agents.md` ecosystem patterns:
|
||||
- include YAML frontmatter when present in repo style (`name`, `description`).
|
||||
- state persona scope and explicit instruction boundaries: `Always`, `Ask first`, `Never`.
|
||||
- include concrete commands and representative code examples.
|
||||
- For CONTRIBUTING.md, prioritize issue triage flow, PR expectations, setup/test commands, and review gates.
|
||||
- Add `Code of Conduct`, `Testing`, `Local checks`, and `PR expectations` sections when missing but required by the repo.
|
||||
- If CONTRIBUTING.md is becoming too large, split by scope into linked docs (for example, framework/tool-specific setup and release workflows) and keep the root file as a concise entry point.
|
||||
- Keep cross-file consistency: links from CONTRIBUTING.md to AGENTS.md (and vice versa) should be accurate and non-circular.
|
||||
- If multiple AGENTS.md files exist, document the directory-level scope and avoid conflicting advice.
|
||||
- If a required canonical entry file is missing (for example referenced `README.md` under a major directory), create the file in the same pass instead of adding a caveat-only note.
|
||||
- For new entry files, keep them minimal and actionable: purpose, prerequisites, concrete run commands, and pointers to deeper docs.
|
||||
|
||||
## 7. Keep agent context tight
|
||||
|
||||
- Author once, expose twice:
|
||||
- keep one shared policy core and avoid duplicating guidance in separate agent-specific files.
|
||||
- publish that core through bounded glob-friendly files for Cursor/Claude plus explicit path references for Codex.
|
||||
- For Cursor and Claude-style agents, avoid broad references. Use minimal globbing and narrow rule files that each serve one concern (for example, repo-wide setup, test rules, security checks).
|
||||
- Keep AGENTS and alias files short-to-medium; move detailed runbooks to linked docs.
|
||||
- For Codex, prefer explicit file references and concrete paths for exact reuse.
|
||||
- Avoid adding unrelated historical or process details to avoid token/context drift during future tool reads.
|
||||
|
||||
## 8. Brownfield build mode
|
||||
|
||||
- Match existing terminology, navigation, and component patterns.
|
||||
- Preserve existing IA unless there is a documented migration plan.
|
||||
- For rewrites, include a migration note from old to new paths.
|
||||
- Prefer smallest safe change set that improves utility.
|
||||
|
||||
## 9. Evergreen build mode
|
||||
|
||||
- Prefer stable concepts over release-tied narrative.
|
||||
- Isolate volatile details under clearly marked version sections.
|
||||
- Include maintenance signals: owners, refresh triggers, stale criteria.
|
||||
- Include lifecycle notes: deprecation and replacement paths.
|
||||
|
||||
## 10. Writing constraints
|
||||
|
||||
- Use precise language and short, imperative instructions.
|
||||
- Keep code examples copy-ready and self-contained.
|
||||
- Include common failure modes and safe defaults.
|
||||
- Avoid placeholder guidance that cannot be executed.
|
||||
|
||||
## 11. Agent and automation readiness
|
||||
|
||||
- Keep key facts in text (not image-only).
|
||||
- Prefer structured lists/tables when choices matter.
|
||||
- Add links and anchors that allow deterministic navigation.
|
||||
- Document what can be checked automatically in CI.
|
||||
|
||||
## 12. Build validation
|
||||
|
||||
- Validate commands and snippets where possible.
|
||||
- Verify links and references in changed sections.
|
||||
- Run a reference existence sweep for every path/command you introduced.
|
||||
- Verify docs-framework consistency when in scope (for example Sphinx/Fern config and referenced doc paths).
|
||||
- For OpenClaw docs work, apply the validation checklist in `references/openclaw.md`.
|
||||
|
||||
## 13. Multilingual parity mode (when applicable)
|
||||
|
||||
- Pick one source-of-truth language for technical accuracy and release timing.
|
||||
- Define parity target: full parity, staged parity, or intentional divergence per section.
|
||||
- Keep structure aligned across locales (headings, anchors, section order) when possible.
|
||||
- Preserve command/code correctness first; localize explanatory text second.
|
||||
- If parity is not feasible, add a visible note with missing scope and expected sync window.
|
||||
- Run a locale parity check for changed sections (added/removed steps, warnings, prerequisites).
|
||||
- Record unresolved checks explicitly in handoff.
|
||||
128
.agents/skills/technical-documentation/references/openclaw.md
Normal file
128
.agents/skills/technical-documentation/references/openclaw.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# OpenClaw Documentation Overlay
|
||||
|
||||
Use this reference only for OpenClaw docs work. It layers OpenClaw-specific page
|
||||
types, navigation, preservation, and validation rules on top of the general
|
||||
technical-documentation skill.
|
||||
|
||||
## Reader Model
|
||||
|
||||
- Lead with the task the reader is trying to complete.
|
||||
- Give one recommended path before alternatives.
|
||||
- Keep main docs focused on the common path; move dense contracts and rare
|
||||
debugging detail to linked reference or troubleshooting pages.
|
||||
- Explain production risks exactly where the reader can make the mistake.
|
||||
- Link concepts, guides, references, CLI pages, SDK docs, testing, and
|
||||
troubleshooting so readers can continue without rereading.
|
||||
|
||||
## Page Types
|
||||
|
||||
Choose the page type before writing or reviewing:
|
||||
|
||||
- Overview: route readers to the right product area, integration path, or guide.
|
||||
- Quickstart: get a new user to a working result with the fewest safe steps.
|
||||
- Topic page: explain a major OpenClaw entity or surface end to end.
|
||||
- Guide: walk through one workflow from prerequisites to production readiness.
|
||||
- API/SDK/CLI reference: define every object, method, command, option, response,
|
||||
error, enum, default, and version rule in scope.
|
||||
- Testing guide: show sandbox setup, fixtures, simulated failures, and live-mode
|
||||
differences.
|
||||
- Troubleshooting guide: map observable symptoms to checks, causes, and fixes.
|
||||
- Governance file: keep agent/contributor policy concrete, scoped, and aligned
|
||||
with current OpenClaw repo behavior.
|
||||
|
||||
## Topic Pages
|
||||
|
||||
Use this shape for major-entity pages:
|
||||
|
||||
1. Title naming the entity or surface.
|
||||
2. Unheaded opening that says what it is, what it owns, and what it does not own.
|
||||
3. Requirements, only when setup needs accounts, versions, permissions, plugins,
|
||||
operating systems, or credentials.
|
||||
4. Quickstart with the recommended path and smallest reliable verification.
|
||||
5. Configuration with task-critical options inline and exhaustive details linked
|
||||
to reference docs.
|
||||
6. Major subtopics organized by reader intent, not under a generic "Subtopics"
|
||||
heading.
|
||||
7. Troubleshooting with observable failures and concrete checks.
|
||||
8. Related links to guides, references, commands, concepts, and adjacent topics.
|
||||
|
||||
## Guides
|
||||
|
||||
Use this shape for workflow pages:
|
||||
|
||||
1. Title naming the outcome, not the implementation detail.
|
||||
2. Opening that states what the reader can accomplish.
|
||||
3. Before you begin: accounts, keys, permissions, versions, tools, and
|
||||
assumptions.
|
||||
4. Choose a path, only when the reader must decide.
|
||||
5. Steps with verb-led headings, commands, expected output, and checks.
|
||||
6. Test with the smallest reliable proof that the workflow works.
|
||||
7. Production readiness: security, retries, limits, observability, migrations,
|
||||
and cleanup.
|
||||
8. Troubleshooting near the workflow that causes the failures.
|
||||
9. See also links to concepts, references, SDK docs, and adjacent guides.
|
||||
|
||||
## Docs IA And Navigation
|
||||
|
||||
- Read `docs/docs.json` before navigation changes.
|
||||
- Keep topic pages and common workflows on the main reader path.
|
||||
- Put exhaustive contracts, generated references, maintainer-only detail, and
|
||||
support material under `Reference` or another clearly scoped support page.
|
||||
- Keep generated `plugins/reference/*` children and redirect-only pages out of
|
||||
visible navigation unless explicitly required.
|
||||
- For moved pages, include a keep/drop/move/destination matrix in the handoff.
|
||||
- Add "Read when" hints for docs-list routing when creating or changing pages
|
||||
that participate in the docs index.
|
||||
|
||||
## Source-Backed Content
|
||||
|
||||
- CLI docs must match current flags, output, errors, and examples.
|
||||
- API/SDK docs must include fields, defaults, enum values, constraints, nullable
|
||||
behavior, lifecycle states, errors, and recovery guidance.
|
||||
- Config docs must align exported types, schema/help output, metadata, baselines,
|
||||
and current docs.
|
||||
- Dependency-backed behavior must be verified from upstream docs, source, or
|
||||
types before documenting defaults, timing, errors, or API behavior.
|
||||
- Separate current behavior, shipped behavior, planned behavior, and maintainer
|
||||
intent.
|
||||
|
||||
## Examples
|
||||
|
||||
- Prefer complete copy-pasteable commands and snippets.
|
||||
- Use realistic variable names and values.
|
||||
- Mark placeholders with angle-bracket names such as `<API_KEY>`.
|
||||
- Show expected success output when it helps verification.
|
||||
- Keep one conceptual unit per code block and use language-specific fences.
|
||||
- Avoid examples that hide setup, auth, error handling, or cleanup.
|
||||
- Never expose real secrets, live config, phone numbers, private videos, or
|
||||
credentials.
|
||||
|
||||
## Preservation Reviews
|
||||
|
||||
For rewrites or splits:
|
||||
|
||||
- Identify source units before rewriting: headings, paragraphs, tables, examples,
|
||||
CLI/API contracts, warnings, and troubleshooting facts.
|
||||
- Map each retained unit to a destination page or section.
|
||||
- Do not treat a broad "covered" row as proof for dense source material; use
|
||||
line- or claim-level evidence when the source unit is dense.
|
||||
- For dropped content, state whether it is obsolete, duplicated elsewhere,
|
||||
unsupported, or moved to a reference/support page.
|
||||
- When a docs-audit artifact is used, verify it is mapped audit data with
|
||||
non-empty `mappings[]`, not only inventory or reindexed JSON.
|
||||
|
||||
## Validation
|
||||
|
||||
Choose the narrowest proof that covers the touched surface:
|
||||
|
||||
- `pnpm docs:list`
|
||||
- `pnpm docs:check-mdx`
|
||||
- `pnpm docs:check-links`
|
||||
- `pnpm docs:check-i18n-glossary`
|
||||
- `pnpm format:docs:check` or `pnpm lint:docs`
|
||||
- `git diff --check`
|
||||
- generated-doc or inventory checks when generated references, plugin catalogs,
|
||||
labeler, or docs scripts changed
|
||||
- behavior tests or command probes when docs claim runtime behavior
|
||||
|
||||
If proof is blocked, say exactly which command was not run and why.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Documentation Principles
|
||||
|
||||
This reference consolidates the core rules used by this skill.
|
||||
|
||||
## Matt Palmer: 8 rules for better docs
|
||||
|
||||
Source: https://mattpalmer.io/posts/2025/10/8-rules-for-better-docs/
|
||||
|
||||
Use these as default operating principles:
|
||||
|
||||
1. Write for humans, optimize for agents.
|
||||
2. Start with a funnel: what/why, quickstart, next steps.
|
||||
3. Use Diataxis to scaffold content.
|
||||
4. Write with AI, but structure for agents.
|
||||
5. Offload routine docs operations to background agents.
|
||||
6. Automate quality with CI.
|
||||
7. Automate scaffolding and repetitive workflow tasks.
|
||||
8. Make contribution easy and visible.
|
||||
|
||||
## OpenAI cookbook: what makes documentation good
|
||||
|
||||
Source: https://cookbook.openai.com/articles/what_makes_documentation_good
|
||||
|
||||
Key quality constraints:
|
||||
|
||||
- Prefer specific and accurate terminology over niche jargon.
|
||||
- Keep examples self-contained and minimize dependencies.
|
||||
- Prioritize high-value topics over edge-case depth.
|
||||
- Do not teach unsafe patterns (for example, exposed secrets).
|
||||
- Open with context that helps readers orient quickly.
|
||||
- Apply empathy and override rigid rules when it clearly improves outcomes.
|
||||
|
||||
## Practical merge policy
|
||||
|
||||
When these rules conflict:
|
||||
|
||||
1. Preserve reader task success first.
|
||||
2. Preserve structural clarity second.
|
||||
3. Preserve long-term maintainability third.
|
||||
4. Add agent optimization only if it does not reduce human clarity.
|
||||
|
||||
For agent-instructions and contributor-governance specifics (AGENTS/aliases/CONTRIBUTING), use `references/agent-and-contributing.md` as the detailed additional source of truth.
|
||||
|
||||
When the target repo or request is OpenClaw-specific, layer `references/openclaw.md` on top of these general rules. Otherwise ignore that repo-specific overlay.
|
||||
|
||||
## Execution policy for this skill
|
||||
|
||||
- Long-running and extensive investigations are allowed for both build and review work when needed to resolve ambiguity or cross-file drift.
|
||||
- Use sub-agents when available for bounded parallel discovery, verification, or cross-source comparison.
|
||||
- Keep one merged outcome: sub-agent outputs must be normalized into a single consistent recommendation/fix set.
|
||||
|
||||
## Multilingual parity rule
|
||||
|
||||
When docs exist in multiple languages, target cross-locale parity for task-critical content (steps, warnings, prerequisites, and limits). If full parity is not possible, publish explicit parity status and sync intent.
|
||||
121
.agents/skills/technical-documentation/references/review.md
Normal file
121
.agents/skills/technical-documentation/references/review.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# Review Docs Playbook
|
||||
|
||||
Read `principles.md` first, then apply this checklist.
|
||||
|
||||
## 1. Scope and classification
|
||||
|
||||
- Identify doc type and target audience.
|
||||
- Confirm brownfield vs evergreen intent.
|
||||
- Confirm expected outcome for the reader.
|
||||
- For full-repo reviews, explicitly include both governance surfaces and product-doc surfaces (`docs/`, README trees, `.md/.mdx/.mdc`, `.rst/.rsc`, framework docs configs).
|
||||
- For OpenClaw docs reviews, apply `references/openclaw.md` for page type, docs IA, preservation, examples, and validation checks.
|
||||
|
||||
## 2. Investigation behavior
|
||||
|
||||
- Proactively find issues and risks without waiting for repeated prompts.
|
||||
- If there are signals of deeper problems, continue investigation beyond the first pass.
|
||||
- Long-running and extensive investigations are acceptable when needed for confidence and correctness.
|
||||
- When available, use sub-agents for bounded parallel discovery (for example file-inventory, command validation, or cross-doc consistency checks), then merge to one final issue set.
|
||||
- When no issues are found, state that explicitly and call out residual risks or validation gaps.
|
||||
- Default to `apply-fixes` for high-confidence documentation defects unless the user explicitly requests `report-only`.
|
||||
- Do not stop at AGENTS/CONTRIBUTING checks when the task is documentation-wide; continue into docs-content and docs-framework surfaces.
|
||||
|
||||
## 3. Governance surface review
|
||||
|
||||
- Use `references/agent-and-contributing.md` as the source of truth for inventory, canonical/alias mapping, and precedence/conflict handling.
|
||||
For AGENTS.md:
|
||||
|
||||
- confirm persona intent, scope, and command/tool boundaries are explicit.
|
||||
- check frontmatter style matches repo conventions when present.
|
||||
- ensure `Always`, `Ask first`, and `Never` boundaries are present when expected.
|
||||
- require concrete command examples and repo-specific paths to avoid ambiguity.
|
||||
|
||||
For CONTRIBUTING.md:
|
||||
|
||||
- verify issue/PR workflow is complete and actionable.
|
||||
- ensure local setup, lint/test commands, and review criteria are accurate.
|
||||
- ensure governance does not conflict with nested AGENTS instructions.
|
||||
- flag oversized files that should be split into linked section docs (for example tool-specific setup and release docs).
|
||||
|
||||
For agent-platform awareness:
|
||||
|
||||
- confirm references are minimal and scoped for Cursor/Claude glob behavior.
|
||||
- confirm Codex-facing guidance uses explicit file references.
|
||||
- confirm both surfaces represent the same shared policy core (commands, boundaries, and precedence), not divergent guidance.
|
||||
- audit `.agents`/`.cursor` compatibility behavior:
|
||||
- verify canonical rule directory and symlink state match repo policy
|
||||
- verify symlink target integrity and platform/tooling expectations
|
||||
- verify AGENTS policy references remain canonical for Codex even when `.cursor` compatibility exists
|
||||
- check for context bloat from duplicated policy statements across agent and contributor files.
|
||||
- check for conflicting rules, skills and agent instructions
|
||||
- check for conflicting information in agent instructions vs codebase
|
||||
- check for broken or missing referenced files (for example README/index files named as canonical entry points).
|
||||
- check for setup/command drift (for example non-existent install commands, root-level commands that should be module-scoped).
|
||||
|
||||
## 4. Product documentation surface review
|
||||
|
||||
- Verify docs IA coverage across root/module `README*` files and `docs/**` trees.
|
||||
- Review framework-native docs sources in scope (for example Fern, Mintlify, Sphinx, MkDocs) and ensure guidance matches actual source-of-truth files.
|
||||
- Check `.md/.mdx/.mdc/.rst/.rsc` for stale commands, missing prerequisites, and broken cross-links.
|
||||
- Confirm referenced doc paths and anchors exist.
|
||||
- Flag docs that should be split/merged to improve discoverability and maintenance.
|
||||
- For OpenClaw docs, check `docs/docs.json`, docs-list routing hints, main path versus `Reference` placement, and generated-reference visibility.
|
||||
- For OpenClaw rewrites or page splits, require source-backed keep/drop/move/destination coverage for important claims, warnings, examples, commands, fields, and troubleshooting facts.
|
||||
|
||||
## 5. Framework config and path mapping checks
|
||||
|
||||
- Detect and read framework config first (for example Fern config, Sphinx `conf.py`, Mintlify config, or equivalent).
|
||||
- Resolve path references relative to the declaring file/config.
|
||||
- Treat filesystem paths and published URL routes as separate maps; verify both.
|
||||
- Flag path-map drift explicitly (`missing file`, `stale route`, `wrong base path`).
|
||||
|
||||
## 6. Structural review
|
||||
|
||||
- Funnel check: what/why, quickstart, next steps.
|
||||
- Validate heading flow and navigation discoverability.
|
||||
- Flag critical content trapped in images or buried sections.
|
||||
- Check Diataxis alignment and split mixed-purpose sections.
|
||||
- For OpenClaw docs, confirm the content matches an explicit page type from `references/openclaw.md`.
|
||||
|
||||
## 7. Writing quality review
|
||||
|
||||
- Check for concise, scannable paragraphs.
|
||||
- Remove ambiguous pronouns and undefined terms.
|
||||
- Verify examples are executable and scoped correctly.
|
||||
- Verify tone is directive, technical, and non-hand-wavy.
|
||||
|
||||
## 8. Brownfield review mode
|
||||
|
||||
- Verify compatibility with existing docs IA and conventions.
|
||||
- Verify anchors, redirects, and cross-doc links remain valid.
|
||||
- Flag regressions in onboarding and task completion paths.
|
||||
- Ensure changed terminology is intentionally propagated.
|
||||
|
||||
## 9. Evergreen review mode
|
||||
|
||||
- Flag date-stamped or brittle wording without version scope.
|
||||
- Check ownership and refresh signals are present.
|
||||
- Ensure recommendations remain valid after routine product evolution.
|
||||
- Flag missing deprecation/migration guidance.
|
||||
|
||||
## 10. Tooling and platform review
|
||||
|
||||
Read `tooling.md` if platform fit is uncertain.
|
||||
|
||||
- Check whether content uses platform primitives effectively.
|
||||
- Flag structure that fights the chosen docs platform.
|
||||
- Recommend targeted platform-aware improvements.
|
||||
|
||||
## 11. Multilingual parity review (when applicable)
|
||||
|
||||
- Confirm declared source-of-truth language and expected parity policy.
|
||||
- Compare changed sections across locales for step/order/warning drift.
|
||||
- Flag missing updates to prerequisites, version notes, limits, and safety guidance.
|
||||
- Allow intentional divergence only when rationale is explicit and user-impact is low.
|
||||
- Require a reader-visible status note when locale parity is partial.
|
||||
|
||||
## 12. Output format
|
||||
|
||||
1. Blocking issues (file + required fix)
|
||||
2. Non-blocking improvements
|
||||
3. Validation notes (done vs pending)
|
||||
32
.agents/skills/technical-documentation/references/tooling.md
Normal file
32
.agents/skills/technical-documentation/references/tooling.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Documentation Tooling Guide
|
||||
|
||||
Source: https://www.mintlify.com/blog/top-7-api-documentation-tools-of-2025
|
||||
|
||||
Use this file when deciding build/review expectations for doc platforms.
|
||||
|
||||
## Tool-selection checkpoints
|
||||
|
||||
- Existing stack lock-in: do not force migration for minor gains.
|
||||
- API workflow depth: generated references, OpenAPI support, testability.
|
||||
- Collaboration model: docs-as-code, review workflow, versioning.
|
||||
- Runtime quality: search, navigation, and copy-ready code snippets.
|
||||
- AI readiness: structured content, stable URLs, machine-friendly layout yet human readable.
|
||||
- Human readiness: reading complexity, reading UX, navigation depth, minimize jargon.
|
||||
|
||||
## Apply in brownfield mode
|
||||
|
||||
- Prioritize compatibility with the current platform.
|
||||
- Use available components and style conventions before introducing new patterns.
|
||||
- Propose migration only when current constraints block critical outcomes.
|
||||
|
||||
## Apply in evergreen mode
|
||||
|
||||
- Favor platforms and templates that make routine updates low-friction.
|
||||
- Standardize section templates to reduce drift.
|
||||
- Capture ownership, update cadence, and stale-content detection rules.
|
||||
|
||||
## Review implications
|
||||
|
||||
- Check whether content uses platform primitives correctly (tabs, callouts, endpoint blocks).
|
||||
- Flag docs that are technically correct but hard to scan in the chosen platform.
|
||||
- Recommend platform-specific improvements only when they reduce cognitive load.
|
||||
4
.github/actions/docker-e2e-plan/action.yml
vendored
4
.github/actions/docker-e2e-plan/action.yml
vendored
@@ -123,14 +123,14 @@ runs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker pull "${OPENCLAW_DOCKER_E2E_BARE_IMAGE}"
|
||||
bash scripts/ci-docker-pull-retry.sh "${OPENCLAW_DOCKER_E2E_BARE_IMAGE}"
|
||||
|
||||
- name: Pull shared functional Docker E2E image
|
||||
if: inputs.hydrate-artifacts == 'true' && steps.plan.outputs.needs_functional_image == '1'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker pull "${OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE}"
|
||||
bash scripts/ci-docker-pull-retry.sh "${OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE}"
|
||||
|
||||
- name: Validate Docker E2E credentials
|
||||
if: inputs.hydrate-artifacts == 'true'
|
||||
|
||||
27
.github/actions/setup-node-env/action.yml
vendored
27
.github/actions/setup-node-env/action.yml
vendored
@@ -26,11 +26,23 @@ inputs:
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Normalize container toolcache
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -d /__t && ! -e /opt/hostedtoolcache ]]; then
|
||||
mkdir -p /opt
|
||||
ln -s /__t /opt/hostedtoolcache
|
||||
fi
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
check-latest: false
|
||||
shell: bash
|
||||
env:
|
||||
REQUESTED_NODE_VERSION: ${{ inputs.node-version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source "$GITHUB_ACTION_PATH/../setup-pnpm-store-cache/ensure-node.sh"
|
||||
openclaw_ensure_node "$REQUESTED_NODE_VERSION"
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: ./.github/actions/setup-pnpm-store-cache
|
||||
@@ -40,9 +52,10 @@ runs:
|
||||
|
||||
- name: Setup Bun
|
||||
if: inputs.install-bun == 'true'
|
||||
uses: oven-sh/setup-bun@v2.2.0
|
||||
with:
|
||||
bun-version: "1.3.13"
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm install -g bun@1.3.13
|
||||
|
||||
- name: Runtime versions
|
||||
shell: bash
|
||||
|
||||
@@ -14,7 +14,7 @@ inputs:
|
||||
required: false
|
||||
default: ""
|
||||
use-actions-cache:
|
||||
description: Whether pnpm/action-setup should cache the pnpm store.
|
||||
description: Whether actions/cache should cache the pnpm store.
|
||||
required: false
|
||||
default: "true"
|
||||
outputs:
|
||||
@@ -47,12 +47,42 @@ runs:
|
||||
openclaw_ensure_node "$requested_node"
|
||||
|
||||
- name: Setup pnpm from packageManager
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093
|
||||
shell: bash
|
||||
env:
|
||||
COREPACK_ENABLE_DOWNLOAD_PROMPT: "0"
|
||||
PACKAGE_MANAGER_FILE: ${{ inputs.package-manager-file }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
package_manager="$(node -e "const fs = require('node:fs'); const path = require('node:path'); const pkg = JSON.parse(fs.readFileSync(path.resolve(process.argv[1]), 'utf8')); process.stdout.write(pkg.packageManager || '')" "$PACKAGE_MANAGER_FILE")"
|
||||
case "$package_manager" in
|
||||
pnpm@*) ;;
|
||||
*)
|
||||
echo "::error::Expected packageManager to pin pnpm, got '${package_manager:-<empty>}'"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
corepack enable
|
||||
corepack prepare "$package_manager" --activate
|
||||
|
||||
- name: Resolve pnpm store path
|
||||
id: pnpm-store
|
||||
if: ${{ inputs.use-actions-cache == 'true' && runner.os != 'Windows' }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
store_path="$(pnpm store path --silent)"
|
||||
node -e "require('node:fs').mkdirSync(process.argv[1], { recursive: true })" "$store_path"
|
||||
echo "path=$store_path" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore pnpm store cache
|
||||
if: ${{ inputs.use-actions-cache == 'true' && runner.os != 'Windows' }}
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
package_json_file: ${{ inputs.package-manager-file }}
|
||||
run_install: false
|
||||
cache: ${{ inputs.use-actions-cache }}
|
||||
cache_dependency_path: ${{ inputs.lockfile-path }}
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: pnpm-store-${{ runner.os }}-${{ inputs.node-version }}-${{ hashFiles(inputs.lockfile-path) }}
|
||||
restore-keys: |
|
||||
pnpm-store-${{ runner.os }}-${{ inputs.node-version }}-
|
||||
pnpm-store-${{ runner.os }}-
|
||||
|
||||
- name: Record pnpm version
|
||||
id: pnpm-version
|
||||
|
||||
@@ -28,9 +28,17 @@ openclaw_active_node_version() {
|
||||
|
||||
openclaw_prepend_node_bin() {
|
||||
local node_bin_dir="$1"
|
||||
export PATH="$node_bin_dir:$PATH"
|
||||
local shell_node_bin_dir="$node_bin_dir"
|
||||
if command -v cygpath >/dev/null 2>&1; then
|
||||
shell_node_bin_dir="$(cygpath -u "$node_bin_dir" 2>/dev/null || printf '%s' "$node_bin_dir")"
|
||||
fi
|
||||
export PATH="$shell_node_bin_dir:$PATH"
|
||||
if [[ -n "${GITHUB_PATH:-}" ]]; then
|
||||
echo "$node_bin_dir" >> "$GITHUB_PATH"
|
||||
local github_node_bin_dir="$shell_node_bin_dir"
|
||||
if command -v cygpath >/dev/null 2>&1; then
|
||||
github_node_bin_dir="$(cygpath -w "$shell_node_bin_dir" 2>/dev/null || printf '%s' "$shell_node_bin_dir")"
|
||||
fi
|
||||
echo "$github_node_bin_dir" >> "$GITHUB_PATH"
|
||||
fi
|
||||
hash -r
|
||||
}
|
||||
@@ -43,6 +51,7 @@ openclaw_find_toolcache_node() {
|
||||
"${RUNNER_TOOL_CACHE:-}" \
|
||||
"${AGENT_TOOLSDIRECTORY:-}" \
|
||||
"${ACTIONS_RUNNER_TOOL_CACHE:-}" \
|
||||
"${OPENCLAW_CONTAINER_TOOL_CACHE:-/__t}" \
|
||||
"/opt/hostedtoolcache" \
|
||||
"/home/runner/_work/_tool" \
|
||||
"/Users/runner/hostedtoolcache" \
|
||||
@@ -68,6 +77,56 @@ openclaw_find_toolcache_node() {
|
||||
return 1
|
||||
}
|
||||
|
||||
openclaw_resolve_node_download_version() {
|
||||
local requested_node="$1"
|
||||
if [[ "$requested_node" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
[[ "$requested_node" == v* ]] && printf '%s\n' "$requested_node" || printf 'v%s\n' "$requested_node"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local prefix="${requested_node#v}"
|
||||
prefix="${prefix%%[xX]*}"
|
||||
prefix="v${prefix}"
|
||||
[[ "$prefix" == *. ]] || prefix="${prefix}."
|
||||
curl -fsSL https://nodejs.org/dist/index.json |
|
||||
OPENCLAW_NODE_PREFIX="$prefix" python3 -c 'import json, os, sys
|
||||
prefix = os.environ["OPENCLAW_NODE_PREFIX"]
|
||||
for item in json.load(sys.stdin):
|
||||
version = item.get("version", "")
|
||||
if version.startswith(prefix):
|
||||
print(version)
|
||||
break
|
||||
'
|
||||
}
|
||||
|
||||
openclaw_node_download_platform() {
|
||||
local os_name arch_name
|
||||
os_name="$(uname -s)"
|
||||
arch_name="$(uname -m)"
|
||||
case "$os_name:$arch_name" in
|
||||
Linux:x86_64) printf 'linux-x64\n' ;;
|
||||
Linux:aarch64 | Linux:arm64) printf 'linux-arm64\n' ;;
|
||||
Darwin:x86_64) printf 'darwin-x64\n' ;;
|
||||
Darwin:arm64) printf 'darwin-arm64\n' ;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
openclaw_download_node() {
|
||||
local requested_node="$1"
|
||||
local version platform archive_url install_root
|
||||
version="$(openclaw_resolve_node_download_version "$requested_node")"
|
||||
platform="$(openclaw_node_download_platform)" || return 1
|
||||
install_root="${RUNNER_TEMP:-/tmp}/openclaw-node-${version}-${platform}"
|
||||
archive_url="https://nodejs.org/dist/${version}/node-${version}-${platform}.tar.xz"
|
||||
mkdir -p "$install_root"
|
||||
echo "Downloading Node ${version} from ${archive_url}"
|
||||
curl -fsSL "$archive_url" | tar -xJ -C "$install_root" --strip-components=1
|
||||
openclaw_prepend_node_bin "$install_root/bin"
|
||||
}
|
||||
|
||||
openclaw_ensure_node() {
|
||||
local requested_node="${1:-}"
|
||||
requested_node="${requested_node#v}"
|
||||
@@ -86,6 +145,8 @@ openclaw_ensure_node() {
|
||||
if [[ -n "$node_bin" ]]; then
|
||||
echo "Using Node $("$node_bin" -p 'process.versions.node') from $node_bin"
|
||||
openclaw_prepend_node_bin "$(dirname "$node_bin")"
|
||||
else
|
||||
openclaw_download_node "$requested_node" || true
|
||||
fi
|
||||
|
||||
active_node_version="$(openclaw_active_node_version)"
|
||||
|
||||
2
.github/codex/prompts/docs-agent.md
vendored
2
.github/codex/prompts/docs-agent.md
vendored
@@ -12,7 +12,7 @@ Hard limits:
|
||||
- Do not change production code, tests, package metadata, generated baselines, lockfiles, or CI config.
|
||||
- Keep changes minimal and factual.
|
||||
- Use "plugin/plugins" in user-facing docs/UI/changelog; `extensions/` is only the internal workspace layout.
|
||||
- Do not add a changelog entry unless the docs update describes a user-facing behavior/API change from the triggering commit.
|
||||
- Do not add `CHANGELOG.md` entries during normal docs work. Capture user-facing release-note context in the PR body or commit message instead.
|
||||
|
||||
Allowed paths:
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM 30s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
-c "http.extraheader=AUTHORIZATION: basic ${auth_header}" \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
|
||||
3
.github/workflows/ci-check-testbox.yml
vendored
3
.github/workflows/ci-check-testbox.yml
vendored
@@ -59,7 +59,7 @@ jobs:
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM 30s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
-c "http.extraheader=AUTHORIZATION: basic ${auth_header}" \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
@@ -132,6 +132,5 @@ jobs:
|
||||
- name: Run Testbox
|
||||
uses: useblacksmith/run-testbox@5ca05834db1d3813554d1dd109e5f2087a8d7cbc
|
||||
if: success()
|
||||
continue-on-error: true
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
220
.github/workflows/ci.yml
vendored
220
.github/workflows/ci.yml
vendored
@@ -76,13 +76,16 @@ jobs:
|
||||
android_matrix: ${{ steps.manifest.outputs.android_matrix }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.target_ref || github.sha }}
|
||||
fetch-depth: 1
|
||||
fetch-tags: false
|
||||
persist-credentials: true
|
||||
submodules: false
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_REF: ${{ inputs.target_ref || github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git init "$GITHUB_WORKSPACE"
|
||||
git -C "$GITHUB_WORKSPACE" config gc.auto 0
|
||||
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$GITHUB_WORKSPACE" fetch --no-tags --depth=1 origin "+${CHECKOUT_REF}:refs/remotes/origin/checkout"
|
||||
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
- name: Resolve checkout SHA
|
||||
id: checkout_ref
|
||||
@@ -299,13 +302,16 @@ jobs:
|
||||
PRE_COMMIT_HOME: .cache/pre-commit-security-fast
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.target_ref || github.sha }}
|
||||
fetch-depth: 1
|
||||
fetch-tags: false
|
||||
persist-credentials: true
|
||||
submodules: false
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_REF: ${{ inputs.target_ref || github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git init "$GITHUB_WORKSPACE"
|
||||
git -C "$GITHUB_WORKSPACE" config gc.auto 0
|
||||
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$GITHUB_WORKSPACE" fetch --no-tags --depth=1 origin "+${CHECKOUT_REF}:refs/remotes/origin/checkout"
|
||||
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
- name: Ensure security base commit
|
||||
if: github.event_name != 'workflow_dispatch'
|
||||
@@ -335,22 +341,20 @@ jobs:
|
||||
fi
|
||||
echo "PRE_COMMIT_CONFIG_PATH=$trusted_config" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Setup Python
|
||||
- name: Resolve Python runtime
|
||||
id: setup-python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Restore pre-commit cache
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: .cache/pre-commit-security-fast
|
||||
key: pre-commit-security-fast-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }}
|
||||
restore-keys: |
|
||||
pre-commit-security-fast-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 --version
|
||||
version="$(python3 - <<'PY'
|
||||
import platform
|
||||
print(platform.python_version())
|
||||
PY
|
||||
)"
|
||||
echo "python-version=${version}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Install pre-commit
|
||||
run: python -m pip install --disable-pip-version-check pre-commit==4.2.0
|
||||
run: python3 -m pip install --disable-pip-version-check pre-commit==4.2.0
|
||||
|
||||
- name: Detect committed private keys
|
||||
run: pre-commit run --config "${PRE_COMMIT_CONFIG_PATH:-.pre-commit-config.yaml}" --all-files detect-private-key
|
||||
@@ -383,10 +387,12 @@ jobs:
|
||||
pre-commit run --config "${PRE_COMMIT_CONFIG_PATH:-.pre-commit-config.yaml}" zizmor --files "${workflow_files[@]}"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24.x"
|
||||
check-latest: false
|
||||
env:
|
||||
REQUESTED_NODE_VERSION: "24.x"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source .github/actions/setup-pnpm-store-cache/ensure-node.sh
|
||||
openclaw_ensure_node "$REQUESTED_NODE_VERSION"
|
||||
|
||||
- name: Audit production dependencies
|
||||
run: node scripts/pre-commit/pnpm-audit-prod.mjs --audit-level=high
|
||||
@@ -411,7 +417,6 @@ jobs:
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
CHECKOUT_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -427,10 +432,10 @@ jobs:
|
||||
reset_checkout_dir
|
||||
git init "$workdir" >/dev/null
|
||||
git config --global --add safe.directory "$workdir"
|
||||
git -C "$workdir" remote add origin "https://x-access-token:${CHECKOUT_TOKEN}@github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM 30s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
@@ -513,7 +518,24 @@ jobs:
|
||||
run: pnpm test:build:singleton
|
||||
|
||||
- name: Check CLI startup memory
|
||||
run: pnpm test:startup:memory
|
||||
shell: bash
|
||||
run: |
|
||||
set +e
|
||||
pnpm test:startup:memory
|
||||
status=$?
|
||||
if [[ -f .artifacts/startup-memory/summary.md ]]; then
|
||||
cat .artifacts/startup-memory/summary.md >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
exit "$status"
|
||||
|
||||
- name: Upload startup memory report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: startup-memory
|
||||
path: .artifacts/startup-memory/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: Run built artifact checks
|
||||
id: built_artifact_checks
|
||||
@@ -619,7 +641,6 @@ jobs:
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
CHECKOUT_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -635,10 +656,10 @@ jobs:
|
||||
reset_checkout_dir
|
||||
git init "$workdir" >/dev/null
|
||||
git config --global --add safe.directory "$workdir"
|
||||
git -C "$workdir" remote add origin "https://x-access-token:${CHECKOUT_TOKEN}@github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM 30s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
@@ -706,7 +727,6 @@ jobs:
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
CHECKOUT_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -722,10 +742,10 @@ jobs:
|
||||
reset_checkout_dir
|
||||
git init "$workdir" >/dev/null
|
||||
git config --global --add safe.directory "$workdir"
|
||||
git -C "$workdir" remote add origin "https://x-access-token:${CHECKOUT_TOKEN}@github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM 30s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
@@ -787,7 +807,6 @@ jobs:
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
CHECKOUT_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -803,10 +822,10 @@ jobs:
|
||||
reset_checkout_dir
|
||||
git init "$workdir" >/dev/null
|
||||
git config --global --add safe.directory "$workdir"
|
||||
git -C "$workdir" remote add origin "https://x-access-token:${CHECKOUT_TOKEN}@github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM 30s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
@@ -865,7 +884,6 @@ jobs:
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
CHECKOUT_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -881,10 +899,10 @@ jobs:
|
||||
reset_checkout_dir
|
||||
git init "$workdir" >/dev/null
|
||||
git config --global --add safe.directory "$workdir"
|
||||
git -C "$workdir" remote add origin "https://x-access-token:${CHECKOUT_TOKEN}@github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM 30s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
@@ -941,7 +959,6 @@ jobs:
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
CHECKOUT_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -957,10 +974,10 @@ jobs:
|
||||
reset_checkout_dir
|
||||
git init "$workdir" >/dev/null
|
||||
git config --global --add safe.directory "$workdir"
|
||||
git -C "$workdir" remote add origin "https://x-access-token:${CHECKOUT_TOKEN}@github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM 30s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
@@ -1064,7 +1081,6 @@ jobs:
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
CHECKOUT_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -1080,10 +1096,10 @@ jobs:
|
||||
reset_checkout_dir
|
||||
git init "$workdir" >/dev/null
|
||||
git config --global --add safe.directory "$workdir"
|
||||
git -C "$workdir" remote add origin "https://x-access-token:${CHECKOUT_TOKEN}@github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM 30s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
@@ -1195,7 +1211,6 @@ jobs:
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
CHECKOUT_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -1211,10 +1226,10 @@ jobs:
|
||||
reset_checkout_dir
|
||||
git init "$workdir" >/dev/null
|
||||
git config --global --add safe.directory "$workdir"
|
||||
git -C "$workdir" remote add origin "https://x-access-token:${CHECKOUT_TOKEN}@github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM 30s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
@@ -1345,7 +1360,6 @@ jobs:
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
CHECKOUT_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -1361,10 +1375,10 @@ jobs:
|
||||
reset_checkout_dir
|
||||
git init "$workdir" >/dev/null
|
||||
git config --global --add safe.directory "$workdir"
|
||||
git -C "$workdir" remote add origin "https://x-access-token:${CHECKOUT_TOKEN}@github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM 30s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
@@ -1391,12 +1405,13 @@ jobs:
|
||||
install-bun: "false"
|
||||
|
||||
- name: Checkout ClawHub docs source
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: openclaw/clawhub
|
||||
path: clawhub-source
|
||||
fetch-depth: 1
|
||||
persist-credentials: true
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git init clawhub-source
|
||||
git -C clawhub-source config gc.auto 0
|
||||
git -C clawhub-source remote add origin "https://github.com/openclaw/clawhub.git"
|
||||
git -C clawhub-source fetch --no-tags --depth=1 origin "+HEAD:refs/remotes/origin/checkout"
|
||||
git -C clawhub-source checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
- name: Check docs
|
||||
env:
|
||||
@@ -1412,11 +1427,16 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
persist-credentials: true
|
||||
submodules: false
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git init "$GITHUB_WORKSPACE"
|
||||
git -C "$GITHUB_WORKSPACE" config gc.auto 0
|
||||
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$GITHUB_WORKSPACE" fetch --no-tags --depth=1 origin "+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
|
||||
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
@@ -1455,11 +1475,16 @@ jobs:
|
||||
matrix: ${{ fromJson(needs.preflight.outputs.checks_windows_matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
persist-credentials: true
|
||||
submodules: false
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git init "$GITHUB_WORKSPACE"
|
||||
git -C "$GITHUB_WORKSPACE" config gc.auto 0
|
||||
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$GITHUB_WORKSPACE" fetch --no-tags --depth=1 origin "+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
|
||||
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
- name: Try to exclude workspace from Windows Defender (best-effort)
|
||||
shell: pwsh
|
||||
@@ -1481,10 +1506,12 @@ jobs:
|
||||
}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.x
|
||||
check-latest: false
|
||||
env:
|
||||
REQUESTED_NODE_VERSION: "24.x"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source .github/actions/setup-pnpm-store-cache/ensure-node.sh
|
||||
openclaw_ensure_node "$REQUESTED_NODE_VERSION"
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: ./.github/actions/setup-pnpm-store-cache
|
||||
@@ -1548,11 +1575,16 @@ jobs:
|
||||
matrix: ${{ fromJson(needs.preflight.outputs.macos_node_matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
persist-credentials: true
|
||||
submodules: false
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git init "$GITHUB_WORKSPACE"
|
||||
git -C "$GITHUB_WORKSPACE" config gc.auto 0
|
||||
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$GITHUB_WORKSPACE" fetch --no-tags --depth=1 origin "+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
|
||||
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
@@ -1589,11 +1621,16 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
persist-credentials: true
|
||||
submodules: false
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git init "$GITHUB_WORKSPACE"
|
||||
git -C "$GITHUB_WORKSPACE" config gc.auto 0
|
||||
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$GITHUB_WORKSPACE" fetch --no-tags --depth=1 origin "+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
|
||||
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
- name: Install XcodeGen / SwiftLint / SwiftFormat
|
||||
run: brew install xcodegen swiftlint swiftformat
|
||||
@@ -1693,7 +1730,6 @@ jobs:
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
CHECKOUT_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -1709,10 +1745,10 @@ jobs:
|
||||
reset_checkout_dir
|
||||
git init "$workdir" >/dev/null
|
||||
git config --global --add safe.directory "$workdir"
|
||||
git -C "$workdir" remote add origin "https://x-access-token:${CHECKOUT_TOKEN}@github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM 30s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
|
||||
30
.github/workflows/crabbox-hydrate.yml
vendored
30
.github/workflows/crabbox-hydrate.yml
vendored
@@ -141,7 +141,13 @@ jobs:
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "docker not found; installing fallback engine"
|
||||
curl -fsSL https://get.docker.com | sudo sh
|
||||
curl --fail --show-error --location \
|
||||
--connect-timeout "${OPENCLAW_CRABBOX_HYDRATE_DOWNLOAD_CONNECT_TIMEOUT_SECONDS:-15}" \
|
||||
--max-time "${OPENCLAW_CRABBOX_HYDRATE_DOWNLOAD_TIMEOUT_SECONDS:-300}" \
|
||||
--retry "${OPENCLAW_CRABBOX_HYDRATE_DOWNLOAD_RETRIES:-3}" \
|
||||
--retry-delay "${OPENCLAW_CRABBOX_HYDRATE_DOWNLOAD_RETRY_DELAY_SECONDS:-5}" \
|
||||
--retry-all-errors \
|
||||
https://get.docker.com | sudo sh
|
||||
fi
|
||||
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
@@ -166,7 +172,12 @@ jobs:
|
||||
esac
|
||||
buildx_version="${DOCKER_BUILDX_VERSION:-v0.15.1}"
|
||||
mkdir -p "$HOME/.docker/cli-plugins"
|
||||
curl -fsSL \
|
||||
curl --fail --show-error --location \
|
||||
--connect-timeout "${OPENCLAW_CRABBOX_HYDRATE_DOWNLOAD_CONNECT_TIMEOUT_SECONDS:-15}" \
|
||||
--max-time "${OPENCLAW_CRABBOX_HYDRATE_DOWNLOAD_TIMEOUT_SECONDS:-300}" \
|
||||
--retry "${OPENCLAW_CRABBOX_HYDRATE_DOWNLOAD_RETRIES:-3}" \
|
||||
--retry-delay "${OPENCLAW_CRABBOX_HYDRATE_DOWNLOAD_RETRY_DELAY_SECONDS:-5}" \
|
||||
--retry-all-errors \
|
||||
"https://github.com/docker/buildx/releases/download/${buildx_version}/buildx-${buildx_version}.linux-${buildx_arch}" \
|
||||
-o "$HOME/.docker/cli-plugins/docker-buildx"
|
||||
chmod 0755 "$HOME/.docker/cli-plugins/docker-buildx"
|
||||
@@ -307,7 +318,13 @@ jobs:
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "docker not found; installing fallback engine"
|
||||
curl -fsSL https://get.docker.com | sudo sh
|
||||
curl --fail --show-error --location \
|
||||
--connect-timeout "${OPENCLAW_CRABBOX_HYDRATE_DOWNLOAD_CONNECT_TIMEOUT_SECONDS:-15}" \
|
||||
--max-time "${OPENCLAW_CRABBOX_HYDRATE_DOWNLOAD_TIMEOUT_SECONDS:-300}" \
|
||||
--retry "${OPENCLAW_CRABBOX_HYDRATE_DOWNLOAD_RETRIES:-3}" \
|
||||
--retry-delay "${OPENCLAW_CRABBOX_HYDRATE_DOWNLOAD_RETRY_DELAY_SECONDS:-5}" \
|
||||
--retry-all-errors \
|
||||
https://get.docker.com | sudo sh
|
||||
fi
|
||||
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
@@ -332,7 +349,12 @@ jobs:
|
||||
esac
|
||||
buildx_version="${DOCKER_BUILDX_VERSION:-v0.15.1}"
|
||||
mkdir -p "$HOME/.docker/cli-plugins"
|
||||
curl -fsSL \
|
||||
curl --fail --show-error --location \
|
||||
--connect-timeout "${OPENCLAW_CRABBOX_HYDRATE_DOWNLOAD_CONNECT_TIMEOUT_SECONDS:-15}" \
|
||||
--max-time "${OPENCLAW_CRABBOX_HYDRATE_DOWNLOAD_TIMEOUT_SECONDS:-300}" \
|
||||
--retry "${OPENCLAW_CRABBOX_HYDRATE_DOWNLOAD_RETRIES:-3}" \
|
||||
--retry-delay "${OPENCLAW_CRABBOX_HYDRATE_DOWNLOAD_RETRY_DELAY_SECONDS:-5}" \
|
||||
--retry-all-errors \
|
||||
"https://github.com/docker/buildx/releases/download/${buildx_version}/buildx-${buildx_version}.linux-${buildx_arch}" \
|
||||
-o "$HOME/.docker/cli-plugins/docker-buildx"
|
||||
chmod 0755 "$HOME/.docker/cli-plugins/docker-buildx"
|
||||
|
||||
180
.github/workflows/full-release-validation.yml
vendored
180
.github/workflows/full-release-validation.yml
vendored
@@ -58,6 +58,7 @@ on:
|
||||
- qa-parity
|
||||
- qa-live
|
||||
- npm-telegram
|
||||
- performance
|
||||
live_suite_filter:
|
||||
description: Optional exact live/E2E suite id, or comma-separated QA live lanes such as qa-live-matrix,qa-live-telegram; blank runs all selected live suites
|
||||
required: false
|
||||
@@ -181,6 +182,11 @@ jobs:
|
||||
else
|
||||
echo "- Normal CI: skipped by rerun group"
|
||||
fi
|
||||
if [[ "$RERUN_GROUP" == "all" || "$RERUN_GROUP" == "performance" ]]; then
|
||||
echo "- Product performance: \`OpenClaw Performance\` with \`target_ref=${TARGET_SHA}\`"
|
||||
else
|
||||
echo "- Product performance: skipped by rerun group"
|
||||
fi
|
||||
if [[ "$RERUN_GROUP" == "all" || "$RERUN_GROUP" == "plugin-prerelease" ]]; then
|
||||
echo "- Plugin prerelease: \`Plugin Prerelease\` with \`target_ref=${TARGET_SHA}\`"
|
||||
else
|
||||
@@ -239,7 +245,7 @@ jobs:
|
||||
DOCKER_BUILDKIT: "1"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
timeout --foreground --kill-after=30s 35m docker build \
|
||||
timeout --kill-after=30s 35m docker build \
|
||||
--target runtime-assets \
|
||||
--build-arg OPENCLAW_EXTENSIONS="diagnostics-otel,codex" \
|
||||
.
|
||||
@@ -281,7 +287,7 @@ jobs:
|
||||
printf '%s\n' "$output"
|
||||
return 0
|
||||
fi
|
||||
if [[ "$output" == *"Bad credentials"* || "$output" == *"HTTP 401"* || "$output" == *"secondary rate limit"* || "$output" == *"API rate limit"* ]]; then
|
||||
if [[ "$output" == *"Bad credentials"* || "$output" == *"HTTP 401"* || "$output" == *"secondary rate limit"* || "$output" == *"API rate limit"* || "$output" == *"Sorry. Your account was suspended"* ]]; then
|
||||
echo "::warning::gh $* failed on attempt ${attempt}: ${output}" >&2
|
||||
sleep $((attempt * 10))
|
||||
continue
|
||||
@@ -411,7 +417,7 @@ jobs:
|
||||
printf '%s\n' "$output"
|
||||
return 0
|
||||
fi
|
||||
if [[ "$output" == *"Bad credentials"* || "$output" == *"HTTP 401"* || "$output" == *"secondary rate limit"* || "$output" == *"API rate limit"* ]]; then
|
||||
if [[ "$output" == *"Bad credentials"* || "$output" == *"HTTP 401"* || "$output" == *"secondary rate limit"* || "$output" == *"API rate limit"* || "$output" == *"Sorry. Your account was suspended"* ]]; then
|
||||
echo "::warning::gh $* failed on attempt ${attempt}: ${output}" >&2
|
||||
sleep $((attempt * 10))
|
||||
continue
|
||||
@@ -551,7 +557,7 @@ jobs:
|
||||
printf '%s\n' "$output"
|
||||
return 0
|
||||
fi
|
||||
if [[ "$output" == *"Bad credentials"* || "$output" == *"HTTP 401"* || "$output" == *"secondary rate limit"* || "$output" == *"API rate limit"* ]]; then
|
||||
if [[ "$output" == *"Bad credentials"* || "$output" == *"HTTP 401"* || "$output" == *"secondary rate limit"* || "$output" == *"API rate limit"* || "$output" == *"Sorry. Your account was suspended"* ]]; then
|
||||
echo "::warning::gh $* failed on attempt ${attempt}: ${output}" >&2
|
||||
sleep $((attempt * 10))
|
||||
continue
|
||||
@@ -853,7 +859,7 @@ jobs:
|
||||
printf '%s\n' "$output"
|
||||
return 0
|
||||
fi
|
||||
if [[ "$output" == *"Bad credentials"* || "$output" == *"HTTP 401"* || "$output" == *"secondary rate limit"* || "$output" == *"API rate limit"* ]]; then
|
||||
if [[ "$output" == *"Bad credentials"* || "$output" == *"HTTP 401"* || "$output" == *"secondary rate limit"* || "$output" == *"API rate limit"* || "$output" == *"Sorry. Your account was suspended"* ]]; then
|
||||
echo "::warning::gh $* failed on attempt ${attempt}: ${output}" >&2
|
||||
sleep $((attempt * 10))
|
||||
continue
|
||||
@@ -938,9 +944,127 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
performance:
|
||||
name: Run product performance evidence
|
||||
needs: [resolve_target, docker_runtime_assets_preflight]
|
||||
if: ${{ always() && needs.resolve_target.result == 'success' && contains(fromJSON('["all","performance"]'), inputs.rerun_group) && (inputs.rerun_group != 'all' || needs.docker_runtime_assets_preflight.result == 'success') }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 120
|
||||
outputs:
|
||||
run_id: ${{ steps.dispatch.outputs.run_id }}
|
||||
url: ${{ steps.dispatch.outputs.url }}
|
||||
conclusion: ${{ steps.dispatch.outputs.conclusion }}
|
||||
steps:
|
||||
- name: Dispatch and monitor OpenClaw Performance
|
||||
id: dispatch
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TARGET_SHA: ${{ needs.resolve_target.outputs.sha }}
|
||||
CHILD_WORKFLOW_REF: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
gh_with_retry() {
|
||||
local output status attempt
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
set +e
|
||||
output="$(gh "$@" 2>&1)"
|
||||
status=$?
|
||||
set -e
|
||||
if [[ "$status" -eq 0 ]]; then
|
||||
printf '%s\n' "$output"
|
||||
return 0
|
||||
fi
|
||||
if [[ "$output" == *"Bad credentials"* || "$output" == *"HTTP 401"* || "$output" == *"secondary rate limit"* || "$output" == *"API rate limit"* || "$output" == *"Sorry. Your account was suspended"* ]]; then
|
||||
echo "::warning::gh $* failed on attempt ${attempt}: ${output}" >&2
|
||||
sleep $((attempt * 10))
|
||||
continue
|
||||
fi
|
||||
printf '%s\n' "$output" >&2
|
||||
return "$status"
|
||||
done
|
||||
printf '%s\n' "$output" >&2
|
||||
return "$status"
|
||||
}
|
||||
|
||||
{
|
||||
echo "### Product performance"
|
||||
echo
|
||||
echo "- Target SHA: \`${TARGET_SHA}\`"
|
||||
echo "- Profile: \`release\`"
|
||||
echo "- Repeat: \`3\`"
|
||||
echo "- Deep profile: \`false\`"
|
||||
echo "- Live OpenAI candidate: \`false\`"
|
||||
echo "- Release impact: advisory"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
before_json="$(gh_with_retry run list --workflow openclaw-performance.yml --event workflow_dispatch --limit 100 --json databaseId --jq '[.[].databaseId]')"
|
||||
|
||||
gh_with_retry workflow run openclaw-performance.yml \
|
||||
--ref "$CHILD_WORKFLOW_REF" \
|
||||
-f target_ref="$TARGET_SHA" \
|
||||
-f profile=release \
|
||||
-f repeat=3 \
|
||||
-f deep_profile=false \
|
||||
-f live_openai_candidate=false \
|
||||
-f fail_on_regression=false
|
||||
|
||||
run_id=""
|
||||
for _ in $(seq 1 60); do
|
||||
run_id="$(
|
||||
BEFORE_IDS="$before_json" gh_with_retry run list --workflow openclaw-performance.yml --event workflow_dispatch --limit 50 --json databaseId,createdAt \
|
||||
--jq 'map(select(.databaseId as $id | (env.BEFORE_IDS | fromjson | index($id) | not))) | sort_by(.createdAt) | reverse | .[0].databaseId // empty'
|
||||
)"
|
||||
if [[ -n "$run_id" ]]; then
|
||||
break
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
|
||||
if [[ -z "$run_id" ]]; then
|
||||
echo "::warning::Could not find dispatched run for openclaw-performance.yml."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Dispatched openclaw-performance.yml: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
|
||||
echo "run_id=${run_id}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
cancel_child() {
|
||||
if [[ -n "${run_id:-}" ]]; then
|
||||
echo "Cancelling child workflow openclaw-performance.yml: ${run_id}" >&2
|
||||
gh run cancel "$run_id" >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
trap cancel_child EXIT INT TERM
|
||||
|
||||
poll_count=0
|
||||
while true; do
|
||||
status="$(gh_with_retry run view "$run_id" --json status --jq '.status')"
|
||||
if [[ "$status" == "completed" ]]; then
|
||||
break
|
||||
fi
|
||||
poll_count=$((poll_count + 1))
|
||||
if (( poll_count % 10 == 0 )); then
|
||||
echo "Still waiting on openclaw-performance.yml: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
|
||||
gh_with_retry run view "$run_id" --json jobs --jq '.jobs[] | select(.status != "completed") | {name, status, url}' || true
|
||||
fi
|
||||
sleep 30
|
||||
done
|
||||
trap - EXIT INT TERM
|
||||
|
||||
conclusion="$(gh_with_retry run view "$run_id" --json conclusion --jq '.conclusion')"
|
||||
url="$(gh_with_retry run view "$run_id" --json url --jq '.url')"
|
||||
echo "openclaw-performance.yml finished with ${conclusion}: ${url}"
|
||||
echo "url=${url}" >> "$GITHUB_OUTPUT"
|
||||
echo "conclusion=${conclusion}" >> "$GITHUB_OUTPUT"
|
||||
if [[ "$conclusion" != "success" ]]; then
|
||||
echo "::warning::OpenClaw Performance is advisory and ended with ${conclusion}: ${url}"
|
||||
gh_with_retry run view "$run_id" --json jobs --jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url}' || true
|
||||
fi
|
||||
|
||||
summary:
|
||||
name: Verify full validation
|
||||
needs: [resolve_target, docker_runtime_assets_preflight, normal_ci, plugin_prerelease, release_checks, npm_telegram]
|
||||
needs: [resolve_target, docker_runtime_assets_preflight, normal_ci, plugin_prerelease, release_checks, npm_telegram, performance]
|
||||
if: always()
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
@@ -952,10 +1076,12 @@ jobs:
|
||||
PLUGIN_PRERELEASE_RUN_ID: ${{ needs.plugin_prerelease.outputs.run_id }}
|
||||
RELEASE_CHECKS_RUN_ID: ${{ needs.release_checks.outputs.run_id }}
|
||||
NPM_TELEGRAM_RUN_ID: ${{ needs.npm_telegram.outputs.run_id }}
|
||||
PERFORMANCE_RUN_ID: ${{ needs.performance.outputs.run_id }}
|
||||
NORMAL_CI_RESULT: ${{ needs.normal_ci.result }}
|
||||
PLUGIN_PRERELEASE_RESULT: ${{ needs.plugin_prerelease.result }}
|
||||
RELEASE_CHECKS_RESULT: ${{ needs.release_checks.result }}
|
||||
NPM_TELEGRAM_RESULT: ${{ needs.npm_telegram.result }}
|
||||
PERFORMANCE_RESULT: ${{ needs.performance.result }}
|
||||
DOCKER_RUNTIME_ASSETS_PREFLIGHT_RESULT: ${{ needs.docker_runtime_assets_preflight.result }}
|
||||
RERUN_GROUP: ${{ inputs.rerun_group }}
|
||||
TARGET_SHA: ${{ needs.resolve_target.outputs.sha }}
|
||||
@@ -963,6 +1089,29 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
gh_with_retry() {
|
||||
local output status attempt
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
set +e
|
||||
output="$(gh "$@" 2>&1)"
|
||||
status=$?
|
||||
set -e
|
||||
if [[ "$status" -eq 0 ]]; then
|
||||
printf '%s\n' "$output"
|
||||
return 0
|
||||
fi
|
||||
if [[ "$output" == *"Bad credentials"* || "$output" == *"HTTP 401"* || "$output" == *"secondary rate limit"* || "$output" == *"API rate limit"* || "$output" == *"Sorry. Your account was suspended"* ]]; then
|
||||
echo "::warning::gh $* failed on attempt ${attempt}: ${output}" >&2
|
||||
sleep $((attempt * 10))
|
||||
continue
|
||||
fi
|
||||
printf '%s\n' "$output" >&2
|
||||
return "$status"
|
||||
done
|
||||
printf '%s\n' "$output" >&2
|
||||
return "$status"
|
||||
}
|
||||
|
||||
release_check_blocking_job() {
|
||||
case "$1" in
|
||||
"resolve_target" | \
|
||||
@@ -1019,7 +1168,7 @@ jobs:
|
||||
fi
|
||||
|
||||
local run_json status conclusion url attempt head_sha
|
||||
run_json="$(gh run view "$run_id" --json status,conclusion,url,attempt,headSha,jobs)"
|
||||
run_json="$(gh_with_retry run view "$run_id" --json status,conclusion,url,attempt,headSha,jobs)"
|
||||
status="$(jq -r '.status' <<< "$run_json")"
|
||||
conclusion="$(jq -r '.conclusion' <<< "$run_json")"
|
||||
url="$(jq -r '.url' <<< "$run_json")"
|
||||
@@ -1066,7 +1215,7 @@ jobs:
|
||||
fi
|
||||
|
||||
local run_json row
|
||||
run_json="$(gh run view "$run_id" --json status,conclusion,url,createdAt,updatedAt,headSha)"
|
||||
run_json="$(gh_with_retry run view "$run_id" --json status,conclusion,url,createdAt,updatedAt,headSha)"
|
||||
row="$(
|
||||
jq -r --arg label "$label" '
|
||||
def ts: fromdateiso8601;
|
||||
@@ -1088,6 +1237,7 @@ jobs:
|
||||
append_child_row "plugin_prerelease" "$PLUGIN_PRERELEASE_RUN_ID" "$PLUGIN_PRERELEASE_RESULT"
|
||||
append_child_row "release_checks" "$RELEASE_CHECKS_RUN_ID" "$RELEASE_CHECKS_RESULT"
|
||||
append_child_row "npm_telegram" "$NPM_TELEGRAM_RUN_ID" "$NPM_TELEGRAM_RESULT"
|
||||
append_child_row "product_performance" "$PERFORMANCE_RUN_ID" "$PERFORMANCE_RESULT"
|
||||
}
|
||||
|
||||
summarize_child_timing() {
|
||||
@@ -1101,7 +1251,7 @@ jobs:
|
||||
echo
|
||||
echo "### Slowest jobs: ${label}"
|
||||
echo
|
||||
gh run view "$run_id" --json jobs --jq '
|
||||
gh_with_retry run view "$run_id" --json jobs --jq '
|
||||
def ts: fromdateiso8601;
|
||||
"| Job | Result | Minutes |",
|
||||
"| --- | --- | ---: |",
|
||||
@@ -1118,7 +1268,7 @@ jobs:
|
||||
echo
|
||||
echo "### Longest queues: ${label}"
|
||||
echo
|
||||
gh api --paginate "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/jobs?per_page=100" --jq ".jobs[] | @json" | jq -sr '
|
||||
gh_with_retry api --paginate "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/jobs?per_page=100" --jq ".jobs[] | @json" | jq -sr '
|
||||
def ts: fromdateiso8601;
|
||||
"| Job | Result | Queue minutes | Run minutes |",
|
||||
"| --- | --- | ---: | ---: |",
|
||||
@@ -1147,7 +1297,7 @@ jobs:
|
||||
fi
|
||||
|
||||
local run_json status conclusion artifacts_json
|
||||
run_json="$(gh run view "$run_id" --json status,conclusion,url,jobs)"
|
||||
run_json="$(gh_with_retry run view "$run_id" --json status,conclusion,url,jobs)"
|
||||
status="$(jq -r '.status' <<< "$run_json")"
|
||||
conclusion="$(jq -r '.conclusion' <<< "$run_json")"
|
||||
if [[ "$status" == "completed" && "$conclusion" == "success" ]]; then
|
||||
@@ -1170,7 +1320,7 @@ jobs:
|
||||
echo
|
||||
echo "Artifacts:"
|
||||
artifacts_json="$(
|
||||
gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/artifacts?per_page=100" 2>/dev/null || true
|
||||
gh_with_retry api "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/artifacts?per_page=100" 2>/dev/null || true
|
||||
)"
|
||||
if [[ -n "${artifacts_json// }" ]]; then
|
||||
jq -r '
|
||||
@@ -1246,6 +1396,7 @@ jobs:
|
||||
summarize_child_timing "plugin_prerelease" "$PLUGIN_PRERELEASE_RUN_ID"
|
||||
summarize_child_timing "release_checks" "$RELEASE_CHECKS_RUN_ID"
|
||||
summarize_child_timing "npm_telegram" "$NPM_TELEGRAM_RUN_ID"
|
||||
summarize_child_timing "product_performance" "$PERFORMANCE_RUN_ID"
|
||||
|
||||
if [[ "$failed" != "0" ]]; then
|
||||
summarize_failed_child "normal_ci" "$NORMAL_CI_RUN_ID"
|
||||
@@ -1343,6 +1494,7 @@ jobs:
|
||||
PLUGIN_PRERELEASE_RUN_ID: ${{ needs.plugin_prerelease.outputs.run_id }}
|
||||
RELEASE_CHECKS_RUN_ID: ${{ needs.release_checks.outputs.run_id }}
|
||||
NPM_TELEGRAM_RUN_ID: ${{ needs.npm_telegram.outputs.run_id }}
|
||||
PERFORMANCE_RUN_ID: ${{ needs.performance.outputs.run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
manifest_dir="${RUNNER_TEMP}/full-release-validation"
|
||||
@@ -1361,6 +1513,7 @@ jobs:
|
||||
--arg pluginPrereleaseRunId "$PLUGIN_PRERELEASE_RUN_ID" \
|
||||
--arg releaseChecksRunId "$RELEASE_CHECKS_RUN_ID" \
|
||||
--arg npmTelegramRunId "$NPM_TELEGRAM_RUN_ID" \
|
||||
--arg performanceRunId "$PERFORMANCE_RUN_ID" \
|
||||
'{
|
||||
version: 1,
|
||||
workflowName: $workflowName,
|
||||
@@ -1376,7 +1529,8 @@ jobs:
|
||||
normalCi: $normalCiRunId,
|
||||
pluginPrerelease: $pluginPrereleaseRunId,
|
||||
releaseChecks: $releaseChecksRunId,
|
||||
npmTelegram: $npmTelegramRunId
|
||||
npmTelegram: $npmTelegramRunId,
|
||||
productPerformance: $performanceRunId
|
||||
}
|
||||
}' > "${manifest_dir}/full-release-validation-manifest.json"
|
||||
|
||||
|
||||
35
.github/workflows/install-smoke.yml
vendored
35
.github/workflows/install-smoke.yml
vendored
@@ -121,7 +121,7 @@ jobs:
|
||||
# builder stalls; an explicit buildx invocation fails closed instead.
|
||||
- name: Build root Dockerfile smoke image
|
||||
run: |
|
||||
timeout 45m docker buildx build \
|
||||
timeout --kill-after=30s 45m docker buildx build \
|
||||
--progress=plain \
|
||||
--load \
|
||||
--build-arg OPENCLAW_EXTENSIONS=matrix \
|
||||
@@ -132,7 +132,7 @@ jobs:
|
||||
|
||||
- name: Run root Dockerfile CLI smoke
|
||||
run: |
|
||||
docker run --rm --entrypoint sh openclaw-dockerfile-smoke:local -lc '
|
||||
timeout --kill-after=30s 20m docker run --rm --entrypoint sh openclaw-dockerfile-smoke:local -lc '
|
||||
which openclaw &&
|
||||
openclaw --version &&
|
||||
node -e "
|
||||
@@ -163,7 +163,7 @@ jobs:
|
||||
|
||||
- name: Smoke test Dockerfile with matrix extension build arg
|
||||
run: |
|
||||
docker run --rm --entrypoint sh openclaw-ext-smoke:local -lc '
|
||||
timeout --kill-after=30s 20m docker run --rm --entrypoint sh openclaw-ext-smoke:local -lc '
|
||||
which openclaw &&
|
||||
openclaw --version &&
|
||||
node -e "
|
||||
@@ -235,7 +235,7 @@ jobs:
|
||||
IMAGE_REF: ${{ needs.preflight.outputs.dockerfile_image }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if timeout 180s docker pull "$IMAGE_REF"; then
|
||||
if timeout --kill-after=30s 180s docker pull "$IMAGE_REF"; then
|
||||
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Using existing root Dockerfile smoke image: \`$IMAGE_REF\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
@@ -256,7 +256,7 @@ jobs:
|
||||
env:
|
||||
IMAGE_REF: ${{ needs.preflight.outputs.dockerfile_image }}
|
||||
run: |
|
||||
timeout 45m docker buildx build \
|
||||
timeout --kill-after=30s 45m docker buildx build \
|
||||
--progress=plain \
|
||||
--push \
|
||||
--build-arg OPENCLAW_EXTENSIONS=matrix \
|
||||
@@ -320,13 +320,13 @@ jobs:
|
||||
- name: Pull root Dockerfile smoke image
|
||||
env:
|
||||
IMAGE_REF: ${{ needs.root_dockerfile_image.outputs.image_ref }}
|
||||
run: timeout 600s docker pull "$IMAGE_REF"
|
||||
run: timeout --kill-after=30s 600s docker pull "$IMAGE_REF"
|
||||
|
||||
- name: Run root Dockerfile CLI smoke
|
||||
env:
|
||||
IMAGE_REF: ${{ needs.root_dockerfile_image.outputs.image_ref }}
|
||||
run: |
|
||||
docker run --rm --entrypoint sh "$IMAGE_REF" -lc '
|
||||
timeout --kill-after=30s 20m docker run --rm --entrypoint sh "$IMAGE_REF" -lc '
|
||||
which openclaw &&
|
||||
openclaw --version &&
|
||||
node -e "
|
||||
@@ -359,7 +359,7 @@ jobs:
|
||||
env:
|
||||
IMAGE_REF: ${{ needs.root_dockerfile_image.outputs.image_ref }}
|
||||
run: |
|
||||
docker run --rm --entrypoint sh "$IMAGE_REF" -lc '
|
||||
timeout --kill-after=30s 20m docker run --rm --entrypoint sh "$IMAGE_REF" -lc '
|
||||
which openclaw &&
|
||||
openclaw --version &&
|
||||
node -e "
|
||||
@@ -426,7 +426,7 @@ jobs:
|
||||
- name: Pull root Dockerfile smoke image
|
||||
env:
|
||||
IMAGE_REF: ${{ needs.root_dockerfile_image.outputs.image_ref }}
|
||||
run: timeout 600s docker pull "$IMAGE_REF"
|
||||
run: timeout --kill-after=30s 600s docker pull "$IMAGE_REF"
|
||||
|
||||
- name: Set up Blacksmith Docker Builder
|
||||
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
|
||||
@@ -435,7 +435,7 @@ jobs:
|
||||
|
||||
- name: Build installer smoke image
|
||||
run: |
|
||||
timeout 20m docker buildx build \
|
||||
timeout --kill-after=30s 20m docker buildx build \
|
||||
--progress=plain \
|
||||
--load \
|
||||
-t openclaw-install-smoke:local \
|
||||
@@ -444,7 +444,7 @@ jobs:
|
||||
|
||||
- name: Build installer non-root image
|
||||
run: |
|
||||
timeout 20m docker buildx build \
|
||||
timeout --kill-after=30s 20m docker buildx build \
|
||||
--progress=plain \
|
||||
--load \
|
||||
-t openclaw-install-nonroot:local \
|
||||
@@ -475,13 +475,22 @@ jobs:
|
||||
|
||||
- name: Run Rocky Linux installer smoke
|
||||
run: |
|
||||
timeout 20m docker run --rm \
|
||||
timeout --kill-after=30s 20m docker run --rm \
|
||||
-e OPENCLAW_NO_ONBOARD=1 \
|
||||
-e OPENCLAW_NO_PROMPT=1 \
|
||||
-v "$PWD/scripts/install.sh:/tmp/install.sh:ro" \
|
||||
rockylinux:9@sha256:d7be1c094cc5845ee815d4632fe377514ee6ebcf8efaed6892889657e5ddaaa6 \
|
||||
bash -lc 'dnf install -y -q ca-certificates tar gzip xz findutils which sudo >/dev/null && bash /tmp/install.sh --install-method npm --version latest --no-onboard --no-prompt --verify && openclaw --version'
|
||||
|
||||
- name: Run Rocky Linux CLI installer smoke
|
||||
run: |
|
||||
timeout --kill-after=30s 20m docker run --rm \
|
||||
-e OPENCLAW_NO_ONBOARD=1 \
|
||||
-e OPENCLAW_NO_PROMPT=1 \
|
||||
-v "$PWD/scripts/install-cli.sh:/tmp/install-cli.sh:ro" \
|
||||
rockylinux:9@sha256:d7be1c094cc5845ee815d4632fe377514ee6ebcf8efaed6892889657e5ddaaa6 \
|
||||
bash -lc 'dnf install -y -q ca-certificates tar gzip xz findutils which sudo >/dev/null && bash /tmp/install-cli.sh --prefix /tmp/openclaw-cli --version latest --no-onboard && /tmp/openclaw-cli/bin/openclaw --version'
|
||||
|
||||
bun_global_install_smoke:
|
||||
needs: [preflight, root_dockerfile_image]
|
||||
if: needs.preflight.outputs.run_full_install_smoke == 'true' && needs.preflight.outputs.run_bun_global_install_smoke == 'true'
|
||||
@@ -503,7 +512,7 @@ jobs:
|
||||
- name: Pull root Dockerfile smoke image
|
||||
env:
|
||||
IMAGE_REF: ${{ needs.root_dockerfile_image.outputs.image_ref }}
|
||||
run: timeout 600s docker pull "$IMAGE_REF"
|
||||
run: timeout --kill-after=30s 600s docker pull "$IMAGE_REF"
|
||||
|
||||
- name: Setup Node environment for Bun smoke
|
||||
uses: ./.github/actions/setup-node-env
|
||||
|
||||
@@ -48,6 +48,7 @@ env:
|
||||
OPENCLAW_BUILD_PRIVATE_QA: "1"
|
||||
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1"
|
||||
CRABBOX_REF: main
|
||||
CRABBOX_CAPACITY_REGIONS: eu-west-1,eu-west-2,eu-central-1,us-east-1,us-west-2
|
||||
MANTIS_OUTPUT_DIR: .artifacts/qa-e2e/mantis/telegram-desktop-proof
|
||||
|
||||
jobs:
|
||||
@@ -422,7 +423,7 @@ jobs:
|
||||
{
|
||||
printf '%s\n' 'Defaults env_keep += "CODEX_HOME CODEX_INTERNAL_ORIGINATOR_OVERRIDE"'
|
||||
printf '%s\n' 'Defaults env_keep += "BASELINE_REF BASELINE_SHA CANDIDATE_REF CANDIDATE_SHA"'
|
||||
printf '%s\n' 'Defaults env_keep += "CRABBOX_ACCESS_CLIENT_ID CRABBOX_ACCESS_CLIENT_SECRET CRABBOX_COORDINATOR CRABBOX_COORDINATOR_TOKEN CRABBOX_LEASE_ID CRABBOX_PROVIDER"'
|
||||
printf '%s\n' 'Defaults env_keep += "CRABBOX_ACCESS_CLIENT_ID CRABBOX_ACCESS_CLIENT_SECRET CRABBOX_COORDINATOR CRABBOX_COORDINATOR_TOKEN CRABBOX_LEASE_ID CRABBOX_PROVIDER CRABBOX_CAPACITY_REGIONS"'
|
||||
printf '%s\n' 'Defaults env_keep += "GH_TOKEN MANTIS_CANDIDATE_TRUST MANTIS_INSTRUCTIONS MANTIS_OUTPUT_DIR MANTIS_PR_NUMBER"'
|
||||
printf '%s\n' 'Defaults env_keep += "OPENCLAW_BUILD_PRIVATE_QA OPENCLAW_ENABLE_PRIVATE_QA_CLI OPENCLAW_QA_CONVEX_SECRET_CI OPENCLAW_QA_CONVEX_SITE_URL OPENCLAW_QA_CREDENTIAL_OWNER_ID OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR_TOKEN"'
|
||||
printf '%s\n' 'Defaults env_keep += "OPENCLAW_TELEGRAM_USER_CRABBOX_BIN OPENCLAW_TELEGRAM_USER_CRABBOX_PROVIDER OPENCLAW_TELEGRAM_USER_DRIVER_SCRIPT OPENCLAW_TELEGRAM_USER_PROOF_CMD"'
|
||||
@@ -451,6 +452,7 @@ jobs:
|
||||
CRABBOX_ACCESS_CLIENT_SECRET: ${{ secrets.CRABBOX_ACCESS_CLIENT_SECRET }}
|
||||
CRABBOX_COORDINATOR: ${{ secrets.CRABBOX_COORDINATOR || secrets.OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR }}
|
||||
CRABBOX_COORDINATOR_TOKEN: ${{ secrets.CRABBOX_COORDINATOR_TOKEN || secrets.OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR_TOKEN }}
|
||||
CRABBOX_CAPACITY_REGIONS: ${{ env.CRABBOX_CAPACITY_REGIONS }}
|
||||
CRABBOX_LEASE_ID: ${{ needs.resolve_request.outputs.lease_id }}
|
||||
CRABBOX_PROVIDER: ${{ needs.resolve_request.outputs.crabbox_provider }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -492,8 +494,11 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
status=0
|
||||
mapfile -d '' session_files < <(sudo find .artifacts/qa-e2e -path '*/telegram-user-crabbox/*/session.json' -type f -print0)
|
||||
mapfile -d '' session_files < <(sudo find .artifacts/qa-e2e -name session.json -type f -print0)
|
||||
for session_file in "${session_files[@]}"; do
|
||||
if ! sudo -u codex node -e 'const fs = require("fs"); const session = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); process.exit(session.command === "telegram-user-crabbox-session" ? 0 : 1);' "$session_file"; then
|
||||
continue
|
||||
fi
|
||||
lease_file="${session_file%/session.json}/.session/lease.json"
|
||||
if [[ ! -f "$lease_file" ]]; then
|
||||
continue
|
||||
@@ -508,8 +513,11 @@ jobs:
|
||||
status=1
|
||||
fi
|
||||
done
|
||||
mapfile -d '' lease_files < <(sudo find .artifacts/qa-e2e -path '*/telegram-user-crabbox/*/.session/lease.json' -type f -print0)
|
||||
mapfile -d '' lease_files < <(sudo find .artifacts/qa-e2e -path '*/.session/lease.json' -type f -print0)
|
||||
for lease_file in "${lease_files[@]}"; do
|
||||
if ! sudo -u codex node -e 'const fs = require("fs"); const lease = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); process.exit(lease.kind === "telegram-user" ? 0 : 1);' "$lease_file"; then
|
||||
continue
|
||||
fi
|
||||
if ! sudo -u codex env \
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI="$OPENCLAW_QA_CONVEX_SECRET_CI" \
|
||||
OPENCLAW_QA_CONVEX_SITE_URL="$OPENCLAW_QA_CONVEX_SITE_URL" \
|
||||
|
||||
@@ -553,6 +553,15 @@ jobs:
|
||||
use-actions-cache: "false"
|
||||
|
||||
- name: Download candidate artifact
|
||||
id: download_candidate
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: openclaw-cross-os-release-checks-candidate-${{ github.run_id }}
|
||||
path: ${{ runner.temp }}/openclaw-cross-os-release-checks/candidate
|
||||
|
||||
- name: Retry candidate artifact download
|
||||
if: ${{ steps.download_candidate.outcome == 'failure' }}
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: openclaw-cross-os-release-checks-candidate-${{ github.run_id }}
|
||||
@@ -560,11 +569,38 @@ jobs:
|
||||
|
||||
- name: Download baseline artifact
|
||||
if: ${{ matrix.suite == 'packaged-upgrade' }}
|
||||
id: download_baseline
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: openclaw-cross-os-release-checks-baseline-${{ github.run_id }}
|
||||
path: ${{ runner.temp }}/openclaw-cross-os-release-checks/baseline
|
||||
|
||||
- name: Retry baseline artifact download
|
||||
if: ${{ matrix.suite == 'packaged-upgrade' && steps.download_baseline.outcome == 'failure' }}
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: openclaw-cross-os-release-checks-baseline-${{ github.run_id }}
|
||||
path: ${{ runner.temp }}/openclaw-cross-os-release-checks/baseline
|
||||
|
||||
- name: Verify release-check inputs
|
||||
shell: bash
|
||||
env:
|
||||
CANDIDATE_TGZ: ${{ runner.temp }}/openclaw-cross-os-release-checks/candidate/${{ needs.prepare.outputs.candidate_file_name }}
|
||||
BASELINE_TGZ: ${{ runner.temp }}/openclaw-cross-os-release-checks/baseline/${{ needs.prepare.outputs.baseline_file_name }}
|
||||
OUTPUT_DIR: ${{ runner.temp }}/openclaw-cross-os-release-checks/${{ matrix.artifact_name }}-${{ matrix.suite }}
|
||||
SUITE: ${{ matrix.suite }}
|
||||
run: |
|
||||
mkdir -p "${OUTPUT_DIR}"
|
||||
if [[ ! -f "${CANDIDATE_TGZ}" ]]; then
|
||||
echo "::error::candidate artifact missing: ${CANDIDATE_TGZ}"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${SUITE}" == "packaged-upgrade" ]] && [[ ! -f "${BASELINE_TGZ}" ]]; then
|
||||
echo "::error::baseline artifact missing: ${BASELINE_TGZ}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Run cross-OS release checks
|
||||
shell: bash
|
||||
env:
|
||||
@@ -615,7 +651,8 @@ jobs:
|
||||
if [[ -f "${SUMMARY_PATH}" ]]; then
|
||||
cat "${SUMMARY_PATH}" >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "No summary generated." >> "$GITHUB_STEP_SUMMARY"
|
||||
mkdir -p "$(dirname "${SUMMARY_PATH}")"
|
||||
echo "No summary generated." | tee "${SUMMARY_PATH}" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Upload release-check artifacts
|
||||
|
||||
@@ -102,6 +102,11 @@ on:
|
||||
- beta
|
||||
- stable
|
||||
- full
|
||||
use_github_hosted_runners:
|
||||
description: Use GitHub-hosted runners instead of Blacksmith runners
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
advisory:
|
||||
description: Treat failures as advisory for the caller
|
||||
required: false
|
||||
@@ -208,6 +213,11 @@ on:
|
||||
required: false
|
||||
default: stable
|
||||
type: string
|
||||
use_github_hosted_runners:
|
||||
description: Use GitHub-hosted runners instead of Blacksmith runners
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
secrets:
|
||||
OPENAI_API_KEY:
|
||||
required: false
|
||||
@@ -474,7 +484,7 @@ jobs:
|
||||
needs: validate_selected_ref
|
||||
if: inputs.include_live_suites && !inputs.live_models_only && (inputs.live_suite_filter == '' || inputs.live_suite_filter == 'live-cache')
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }}
|
||||
runs-on: ${{ inputs.use_github_hosted_runners && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
@@ -511,7 +521,7 @@ jobs:
|
||||
set -euo pipefail
|
||||
for attempt in 1 2; do
|
||||
echo "live-cache attempt ${attempt}/2"
|
||||
if timeout --foreground --kill-after=30s 8m pnpm test:live:cache; then
|
||||
if timeout --kill-after=30s 8m pnpm test:live:cache; then
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$attempt" == "2" ]]; then
|
||||
@@ -524,7 +534,7 @@ jobs:
|
||||
needs: validate_selected_ref
|
||||
if: inputs.include_repo_e2e && inputs.live_suite_filter == ''
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }}
|
||||
runs-on: ${{ inputs.use_github_hosted_runners && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: ${{ inputs.release_test_profile == 'full' && 90 || 60 }}
|
||||
env:
|
||||
OPENCLAW_VITEST_MAX_WORKERS: "2"
|
||||
@@ -556,7 +566,7 @@ jobs:
|
||||
needs: validate_selected_ref
|
||||
if: inputs.include_repo_e2e && (inputs.live_suite_filter == '' || inputs.live_suite_filter == 'openshell-e2e')
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }}
|
||||
runs-on: ${{ inputs.use_github_hosted_runners && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: ${{ matrix.timeout_minutes }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -630,7 +640,7 @@ jobs:
|
||||
if: inputs.include_release_path_suites && inputs.docker_lanes == ''
|
||||
name: Docker E2E (${{ matrix.label }})
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
|
||||
runs-on: ${{ inputs.use_github_hosted_runners && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: ${{ matrix.timeout_minutes }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -921,7 +931,7 @@ jobs:
|
||||
needs: validate_selected_ref
|
||||
if: inputs.docker_lanes != ''
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-4vcpu-ubuntu-2404' }}
|
||||
runs-on: ${{ inputs.use_github_hosted_runners && 'ubuntu-24.04' || 'blacksmith-4vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
groups_json: ${{ steps.groups.outputs.groups_json }}
|
||||
@@ -950,7 +960,7 @@ jobs:
|
||||
if: inputs.docker_lanes != ''
|
||||
name: Docker E2E targeted lanes (${{ matrix.group.label }})
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
|
||||
runs-on: ${{ inputs.use_github_hosted_runners && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -1182,7 +1192,7 @@ jobs:
|
||||
if: inputs.include_openwebui && !inputs.include_release_path_suites && inputs.docker_lanes == ''
|
||||
name: Docker E2E (openwebui)
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
|
||||
runs-on: ${{ inputs.use_github_hosted_runners && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
@@ -1308,7 +1318,7 @@ jobs:
|
||||
needs: validate_selected_ref
|
||||
if: inputs.include_release_path_suites || inputs.include_openwebui || inputs.docker_lanes != ''
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
|
||||
runs-on: ${{ inputs.use_github_hosted_runners && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: ${{ inputs.release_test_profile == 'full' && 90 || 60 }}
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -1424,7 +1434,7 @@ jobs:
|
||||
fi
|
||||
echo "Validating Docker E2E package tarball: $target"
|
||||
started_at="$(date +%s)"
|
||||
timeout --foreground 5m node scripts/check-openclaw-package-tarball.mjs "$target"
|
||||
timeout --kill-after=30s 5m node scripts/check-openclaw-package-tarball.mjs "$target"
|
||||
finished_at="$(date +%s)"
|
||||
echo "Docker E2E package tarball validation finished in $((finished_at - started_at))s."
|
||||
digest="$(sha256sum "$target" | awk '{print $1}')"
|
||||
@@ -1551,7 +1561,7 @@ jobs:
|
||||
needs: validate_selected_ref
|
||||
if: inputs.include_live_suites && (inputs.live_suite_filter == '' || startsWith(inputs.live_suite_filter, 'live-') || startsWith(inputs.live_suite_filter, 'docker-live-models'))
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
|
||||
runs-on: ${{ inputs.use_github_hosted_runners && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -1624,7 +1634,7 @@ jobs:
|
||||
needs: [validate_selected_ref, prepare_live_test_image]
|
||||
if: inputs.include_live_suites && inputs.live_model_providers == '' && (inputs.live_suite_filter == '' || inputs.live_suite_filter == 'docker-live-models')
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
|
||||
runs-on: ${{ inputs.use_github_hosted_runners && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -1768,14 +1778,14 @@ jobs:
|
||||
|
||||
- name: Run Docker live model sweep
|
||||
if: contains(matrix.profiles, inputs.release_test_profile)
|
||||
run: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-models-docker.sh
|
||||
run: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 35m bash .release-harness/scripts/test-live-models-docker.sh
|
||||
|
||||
validate_live_models_docker_targeted:
|
||||
name: Docker live models (selected providers)
|
||||
needs: [validate_selected_ref, prepare_live_test_image]
|
||||
if: inputs.include_live_suites && inputs.live_model_providers != '' && (inputs.live_suite_filter == '' || inputs.live_suite_filter == 'docker-live-models')
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
|
||||
runs-on: ${{ inputs.use_github_hosted_runners && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
@@ -1943,13 +1953,13 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Run Docker live model sweep
|
||||
run: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-models-docker.sh
|
||||
run: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 35m bash .release-harness/scripts/test-live-models-docker.sh
|
||||
|
||||
validate_live_provider_suites:
|
||||
needs: validate_selected_ref
|
||||
if: inputs.include_live_suites && !inputs.live_models_only && (inputs.live_suite_filter == '' || (startsWith(inputs.live_suite_filter, 'native-live-') && !startsWith(inputs.live_suite_filter, 'native-live-extensions-media') && inputs.live_suite_filter != 'native-live-extensions-a-k'))
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }}
|
||||
runs-on: ${{ inputs.use_github_hosted_runners && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: ${{ matrix.timeout_minutes }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -2251,6 +2261,7 @@ jobs:
|
||||
env:
|
||||
OPENCLAW_LIVE_COMMAND: ${{ matrix.command }}
|
||||
OPENCLAW_LIVE_SUITE_ADVISORY: ${{ matrix.advisory }}
|
||||
shell: bash
|
||||
run: |
|
||||
set +e
|
||||
bash .release-harness/scripts/ci-live-command-retry.sh
|
||||
@@ -2270,7 +2281,7 @@ jobs:
|
||||
needs: [validate_selected_ref, prepare_live_test_image]
|
||||
if: inputs.include_live_suites && !inputs.live_models_only && (inputs.live_suite_filter == '' || startsWith(inputs.live_suite_filter, 'live-'))
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
|
||||
runs-on: ${{ inputs.use_github_hosted_runners && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: ${{ matrix.timeout_minutes }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -2278,32 +2289,32 @@ jobs:
|
||||
include:
|
||||
- suite_id: live-gateway-docker
|
||||
label: Docker live gateway OpenAI
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=openai OPENCLAW_LIVE_GATEWAY_MODELS=openai/gpt-5.5 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=300000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
command: OPENCLAW_LIVE_GATEWAY_THINKING=low OPENCLAW_LIVE_GATEWAY_PROVIDERS=openai OPENCLAW_LIVE_GATEWAY_MODELS=openai/gpt-5.5 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=600000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
timeout_minutes: 40
|
||||
profile_env_only: false
|
||||
profiles: beta minimum stable full
|
||||
- suite_id: live-gateway-anthropic-docker
|
||||
label: Docker live gateway Anthropic
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=anthropic OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=anthropic OPENCLAW_LIVE_GATEWAY_MODELS=anthropic/claude-sonnet-4-6 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
timeout_minutes: 40
|
||||
profile_env_only: false
|
||||
profiles: stable full
|
||||
- suite_id: live-gateway-google-docker
|
||||
label: Docker live gateway Google
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=google OPENCLAW_LIVE_GATEWAY_MODELS=google/gemini-3.1-pro-preview,google/gemini-3-flash-preview OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=google OPENCLAW_LIVE_GATEWAY_MODELS=google/gemini-3.1-pro-preview,google/gemini-3-flash-preview OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
timeout_minutes: 40
|
||||
profile_env_only: false
|
||||
profiles: stable full
|
||||
- suite_id: live-gateway-minimax-docker
|
||||
label: Docker live gateway MiniMax
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
timeout_minutes: 40
|
||||
profile_env_only: false
|
||||
profiles: stable full
|
||||
- suite_id: live-gateway-advisory-docker-deepseek-fireworks
|
||||
suite_group: live-gateway-advisory-docker
|
||||
label: Docker live gateway advisory DeepSeek/Fireworks
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=deepseek,fireworks OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=deepseek,fireworks OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
timeout_minutes: 40
|
||||
profile_env_only: false
|
||||
advisory: true
|
||||
@@ -2311,7 +2322,7 @@ jobs:
|
||||
- suite_id: live-gateway-advisory-docker-opencode-openrouter
|
||||
suite_group: live-gateway-advisory-docker
|
||||
label: Docker live gateway advisory OpenCode/OpenRouter
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=opencode-go,openrouter OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=opencode-go,openrouter OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
timeout_minutes: 40
|
||||
profile_env_only: false
|
||||
advisory: true
|
||||
@@ -2319,32 +2330,32 @@ jobs:
|
||||
- suite_id: live-gateway-advisory-docker-xai-zai
|
||||
suite_group: live-gateway-advisory-docker
|
||||
label: Docker live gateway advisory xAI/Z.ai
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=xai,zai OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=xai,zai OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
timeout_minutes: 40
|
||||
profile_env_only: false
|
||||
advisory: true
|
||||
profiles: full
|
||||
- suite_id: live-cli-backend-docker
|
||||
label: Docker live CLI backend
|
||||
command: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 45m bash .release-harness/scripts/test-live-cli-backend-docker.sh
|
||||
command: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 45m bash .release-harness/scripts/test-live-cli-backend-docker.sh
|
||||
timeout_minutes: 50
|
||||
profile_env_only: false
|
||||
profiles: stable full
|
||||
- suite_id: live-acp-bind-docker
|
||||
label: Docker live ACP bind
|
||||
command: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 45m bash .release-harness/scripts/test-live-acp-bind-docker.sh
|
||||
command: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 45m bash .release-harness/scripts/test-live-acp-bind-docker.sh
|
||||
timeout_minutes: 50
|
||||
profile_env_only: false
|
||||
profiles: stable full
|
||||
- suite_id: live-codex-harness-docker
|
||||
label: Docker live Codex harness
|
||||
command: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-codex-harness-docker.sh
|
||||
command: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 35m bash .release-harness/scripts/test-live-codex-harness-docker.sh
|
||||
timeout_minutes: 40
|
||||
profile_env_only: false
|
||||
profiles: stable full
|
||||
- suite_id: live-subagent-announce-docker
|
||||
label: Docker live subagent announce
|
||||
command: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 20m bash .release-harness/scripts/test-live-subagent-announce-docker.sh
|
||||
command: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 20m bash .release-harness/scripts/test-live-subagent-announce-docker.sh
|
||||
timeout_minutes: 25
|
||||
profile_env_only: false
|
||||
profiles: stable full
|
||||
@@ -2469,6 +2480,7 @@ jobs:
|
||||
env:
|
||||
OPENCLAW_LIVE_COMMAND: ${{ matrix.command }}
|
||||
OPENCLAW_LIVE_SUITE_ADVISORY: ${{ matrix.advisory }}
|
||||
shell: bash
|
||||
run: |
|
||||
set +e
|
||||
bash .release-harness/scripts/ci-live-command-retry.sh
|
||||
@@ -2488,7 +2500,7 @@ jobs:
|
||||
needs: validate_selected_ref
|
||||
if: inputs.include_live_suites && !inputs.live_models_only && (inputs.live_suite_filter == '' || startsWith(inputs.live_suite_filter, 'native-live-extensions-media') || inputs.live_suite_filter == 'native-live-extensions-a-k')
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }}
|
||||
runs-on: ${{ inputs.use_github_hosted_runners && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }}
|
||||
container:
|
||||
image: ghcr.io/openclaw/openclaw-live-media-runner:ubuntu-24.04
|
||||
credentials:
|
||||
@@ -2656,6 +2668,7 @@ jobs:
|
||||
if: contains(matrix.profiles, inputs.release_test_profile) && (inputs.live_suite_filter == '' || inputs.live_suite_filter == matrix.suite_id || (inputs.live_suite_filter == 'native-live-extensions-media-video' && startsWith(matrix.suite_id, 'native-live-extensions-media-video-')))
|
||||
env:
|
||||
OPENCLAW_LIVE_SUITE_ADVISORY: ${{ matrix.advisory }}
|
||||
shell: bash
|
||||
run: |
|
||||
set +e
|
||||
${{ matrix.command }}
|
||||
|
||||
83
.github/workflows/openclaw-performance.yml
vendored
83
.github/workflows/openclaw-performance.yml
vendored
@@ -307,7 +307,36 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
report_md="${report_json%.json}.md"
|
||||
effective_status="$status"
|
||||
if [[ "$FAIL_ON_REGRESSION" == "true" && "$status" != "0" ]]; then
|
||||
if REPORT_JSON="$report_json" node <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const report = JSON.parse(fs.readFileSync(process.env.REPORT_JSON, "utf8"));
|
||||
const statuses = report.summary?.statuses ?? {};
|
||||
const nonPassStatuses = Object.entries(statuses)
|
||||
.filter(([status, count]) => status !== "PASS" && Number(count) > 0);
|
||||
const baselineRegressionCount =
|
||||
Number(report.baseline?.comparison?.regressionCount ?? report.gate?.baseline?.regressionCount ?? 0);
|
||||
const gate = report.gate;
|
||||
const toleratedPartial =
|
||||
gate?.verdict === "PARTIAL" &&
|
||||
Number(gate.blockingCount ?? 0) === 0 &&
|
||||
baselineRegressionCount === 0 &&
|
||||
nonPassStatuses.length === 0;
|
||||
if (!toleratedPartial) {
|
||||
process.exit(1);
|
||||
}
|
||||
NODE
|
||||
then
|
||||
effective_status=0
|
||||
{
|
||||
echo "Kova returned a partial release-gate verdict for filtered performance coverage, but all selected scenarios passed and no baseline regression was reported."
|
||||
echo
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
fi
|
||||
echo "status=$status" >> "$GITHUB_OUTPUT"
|
||||
echo "effective_status=$effective_status" >> "$GITHUB_OUTPUT"
|
||||
echo "report_json=$report_json" >> "$GITHUB_OUTPUT"
|
||||
echo "report_md=$report_md" >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -344,8 +373,43 @@ jobs:
|
||||
EOF
|
||||
cat "$summary_path" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
if [[ "$FAIL_ON_REGRESSION" == "true" && "$status" != "0" ]]; then
|
||||
exit "$status"
|
||||
if [[ "$FAIL_ON_REGRESSION" == "true" && "$effective_status" != "0" ]]; then
|
||||
exit "$effective_status"
|
||||
fi
|
||||
|
||||
- name: Fetch previous source performance baseline
|
||||
if: ${{ steps.lane.outputs.run == 'true' && matrix.lane == 'mock-provider' && steps.clawgrit.outputs.present == 'true' }}
|
||||
env:
|
||||
CLAWGRIT_REPORTS_TOKEN: ${{ secrets.CLAWGRIT_REPORTS_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
reports_root=".artifacts/clawgrit-baseline"
|
||||
mkdir -p "$reports_root"
|
||||
git -C "$reports_root" init -b main
|
||||
git -C "$reports_root" remote add origin "https://x-access-token:${CLAWGRIT_REPORTS_TOKEN}@github.com/openclaw/clawgrit-reports.git"
|
||||
if ! git -C "$reports_root" fetch --depth=1 origin main; then
|
||||
echo "No previous source performance baseline could be fetched." >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
git -C "$reports_root" checkout -B main FETCH_HEAD
|
||||
ref_slug="$(printf '%s' "${TESTED_REF}" | tr -c 'A-Za-z0-9._-' '-')"
|
||||
pointer="${reports_root}/openclaw-performance/${ref_slug}/latest-mock-provider.json"
|
||||
if [[ ! -f "$pointer" ]]; then
|
||||
echo "No previous source performance baseline exists for ${TESTED_REF}." >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
if ! latest_path="$(node -e "const fs=require('node:fs'); const data=JSON.parse(fs.readFileSync(process.argv[1],'utf8')); const value=String(data.path || ''); if (!/^openclaw-performance\\/[A-Za-z0-9._-]+\\/[0-9]+-[0-9]+\\/mock-provider$/u.test(value)) process.exit(1); process.stdout.write(value);" "$pointer")"; then
|
||||
echo "Previous source performance baseline pointer is invalid." >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
baseline_source="${reports_root}/${latest_path}/source"
|
||||
if [[ -d "$baseline_source" ]]; then
|
||||
baseline_source="$(realpath "$baseline_source")"
|
||||
echo "SOURCE_PERF_BASELINE_DIR=$baseline_source" >> "$GITHUB_ENV"
|
||||
echo "Using source performance baseline: ${latest_path}/source" >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "Previous source performance baseline has no source directory." >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Run OpenClaw source performance probes
|
||||
@@ -359,7 +423,7 @@ jobs:
|
||||
fi
|
||||
|
||||
mkdir -p "$SOURCE_PERF_DIR/mock-hello"
|
||||
if ! node -e "const fs=require('node:fs'); const scripts=require('./package.json').scripts||{}; process.exit(scripts['test:gateway:cpu-scenarios'] && scripts.openclaw && fs.existsSync('scripts/bench-cli-startup.ts') ? 0 : 1)"; then
|
||||
if ! node -e "const fs=require('node:fs'); const scripts=require('./package.json').scripts||{}; process.exit(scripts['test:gateway:cpu-scenarios'] && scripts['test:extensions:memory'] && scripts.openclaw && fs.existsSync('scripts/bench-cli-startup.ts') && fs.existsSync('scripts/profile-extension-memory.mjs') ? 0 : 1)"; then
|
||||
cat > "$SOURCE_PERF_DIR/index.md" <<EOF
|
||||
# OpenClaw Source Performance
|
||||
|
||||
@@ -371,7 +435,7 @@ jobs:
|
||||
|
||||
- Tested ref: ${TESTED_REF}
|
||||
- Tested SHA: ${TESTED_SHA}
|
||||
- Required scripts: test:gateway:cpu-scenarios, openclaw, scripts/bench-cli-startup.ts
|
||||
- Required scripts: test:gateway:cpu-scenarios, test:extensions:memory, openclaw, scripts/bench-cli-startup.ts, scripts/profile-extension-memory.mjs
|
||||
EOF
|
||||
cat "$SOURCE_PERF_DIR/index.md" >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
@@ -391,6 +455,9 @@ jobs:
|
||||
--startup-case fiftyPlugins \
|
||||
--startup-case fiftyStartupLazyPlugins
|
||||
|
||||
pnpm test:extensions:memory \
|
||||
-- --json "$SOURCE_PERF_DIR/extension-memory.json"
|
||||
|
||||
for run_index in $(seq 1 "$source_runs"); do
|
||||
run_dir="$SOURCE_PERF_DIR/mock-hello/run-$(printf '%03d' "$run_index")"
|
||||
pnpm openclaw qa suite \
|
||||
@@ -460,9 +527,13 @@ jobs:
|
||||
cleanup_gateway
|
||||
trap - EXIT
|
||||
|
||||
node "$PERFORMANCE_HELPER_DIR/scripts/openclaw-performance-source-summary.mjs" \
|
||||
summary_args=(node "$PERFORMANCE_HELPER_DIR/scripts/openclaw-performance-source-summary.mjs" \
|
||||
--source-dir "$SOURCE_PERF_DIR" \
|
||||
--output "$SOURCE_PERF_DIR/index.md"
|
||||
--output "$SOURCE_PERF_DIR/index.md")
|
||||
if [[ -n "${SOURCE_PERF_BASELINE_DIR:-}" && -d "$SOURCE_PERF_BASELINE_DIR" ]]; then
|
||||
summary_args+=(--baseline-source-dir "$SOURCE_PERF_BASELINE_DIR")
|
||||
fi
|
||||
"${summary_args[@]}"
|
||||
|
||||
cat "$SOURCE_PERF_DIR/index.md" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
|
||||
2
.github/workflows/plugin-prerelease.yml
vendored
2
.github/workflows/plugin-prerelease.yml
vendored
@@ -344,7 +344,7 @@ jobs:
|
||||
OPENCLAW_EXTENSION_BATCH_PARALLEL: 2
|
||||
OPENCLAW_VITEST_MAX_WORKERS: 1
|
||||
OPENCLAW_EXTENSION_BATCH: ${{ matrix.extensions_csv }}
|
||||
run: pnpm test:extensions:batch -- "$OPENCLAW_EXTENSION_BATCH"
|
||||
run: pnpm test:extensions:batch "$OPENCLAW_EXTENSION_BATCH" -- --exclude extensions/codex/src/app-server/run-attempt.test.ts
|
||||
|
||||
plugin-prerelease-inspector:
|
||||
permissions:
|
||||
|
||||
4
.github/workflows/sandbox-common-smoke.yml
vendored
4
.github/workflows/sandbox-common-smoke.yml
vendored
@@ -42,7 +42,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
docker build -t openclaw-sandbox-smoke-base:bookworm-slim - <<'EOF'
|
||||
timeout --kill-after=30s 5m docker build -t openclaw-sandbox-smoke-base:bookworm-slim - <<'EOF'
|
||||
FROM debian:bookworm-slim
|
||||
RUN useradd --create-home --shell /bin/bash sandbox
|
||||
USER sandbox
|
||||
@@ -63,5 +63,5 @@ jobs:
|
||||
FINAL_USER=sandbox \
|
||||
scripts/sandbox-common-setup.sh
|
||||
|
||||
u="$(docker run --rm openclaw-sandbox-common-smoke:bookworm-slim sh -lc 'id -un')"
|
||||
u="$(timeout --kill-after=30s 2m docker run --rm openclaw-sandbox-common-smoke:bookworm-slim sh -lc 'id -un')"
|
||||
test "$u" = "sandbox"
|
||||
|
||||
2
.github/workflows/tui-pty.yml
vendored
2
.github/workflows/tui-pty.yml
vendored
@@ -38,4 +38,4 @@ jobs:
|
||||
install-bun: "false"
|
||||
|
||||
- name: Run TUI PTY tests
|
||||
run: timeout 120s node scripts/run-vitest.mjs run --config test/vitest/vitest.tui-pty.config.ts
|
||||
run: timeout --kill-after=30s 120s node scripts/run-vitest.mjs run --config test/vitest/vitest.tui-pty.config.ts
|
||||
|
||||
4
.github/workflows/website-installer-sync.yml
vendored
4
.github/workflows/website-installer-sync.yml
vendored
@@ -75,14 +75,14 @@ jobs:
|
||||
|
||||
- name: install.sh in Docker
|
||||
run: |
|
||||
docker run --rm \
|
||||
timeout --kill-after=30s 20m docker run --rm \
|
||||
-v "$PWD/scripts/install.sh:/tmp/install.sh:ro" \
|
||||
node:24-bookworm-slim \
|
||||
bash -lc 'bash /tmp/install.sh --version latest && openclaw --version'
|
||||
|
||||
- name: install-cli.sh in Docker
|
||||
run: |
|
||||
docker run --rm \
|
||||
timeout --kill-after=30s 20m docker run --rm \
|
||||
-e OPENCLAW_NO_ONBOARD=1 \
|
||||
-e OPENCLAW_NO_PROMPT=1 \
|
||||
-v "$PWD/scripts/install-cli.sh:/tmp/install-cli.sh:ro" \
|
||||
|
||||
33
.github/workflows/workflow-sanity.yml
vendored
33
.github/workflows/workflow-sanity.yml
vendored
@@ -26,7 +26,16 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git init "$GITHUB_WORKSPACE"
|
||||
git -C "$GITHUB_WORKSPACE" config gc.auto 0
|
||||
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$GITHUB_WORKSPACE" fetch --no-tags --depth=1 origin "+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
|
||||
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
- name: Fail on tabs in workflow files
|
||||
run: |
|
||||
@@ -58,7 +67,16 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git init "$GITHUB_WORKSPACE"
|
||||
git -C "$GITHUB_WORKSPACE" config gc.auto 0
|
||||
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$GITHUB_WORKSPACE" fetch --no-tags --depth=1 origin "+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
|
||||
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
- name: Install actionlint
|
||||
shell: bash
|
||||
@@ -90,7 +108,16 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git init "$GITHUB_WORKSPACE"
|
||||
git -C "$GITHUB_WORKSPACE" config gc.auto 0
|
||||
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$GITHUB_WORKSPACE" fetch --no-tags --depth=1 origin "+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
|
||||
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -128,7 +128,8 @@ mantis/
|
||||
!.agents/skills/control-ui-e2e/**
|
||||
!.agents/skills/gitcrawl/
|
||||
!.agents/skills/gitcrawl/**
|
||||
!.agents/skills/openclaw-docs/**
|
||||
!.agents/skills/technical-documentation/
|
||||
!.agents/skills/technical-documentation/**
|
||||
!.agents/skills/openclaw-refactor-docs/
|
||||
!.agents/skills/openclaw-refactor-docs/**
|
||||
!.agents/skills/openclaw-debugging/
|
||||
@@ -167,6 +168,8 @@ mantis/
|
||||
!.agents/skills/tag-duplicate-prs-issues/**
|
||||
!.agents/skills/autoreview/
|
||||
!.agents/skills/autoreview/**
|
||||
.agents/skills/**/__pycache__/
|
||||
.agents/skills/**/*.py[cod]
|
||||
|
||||
# Agent credentials and memory (NEVER COMMIT)
|
||||
/memory/
|
||||
|
||||
56
AGENTS.md
56
AGENTS.md
@@ -23,10 +23,11 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- ClawSweeper-owned schema, labels, close reasons, protected-label gates, maintainer-item gates, and mutation rules live in `openclaw/clawsweeper`.
|
||||
- Review workers read this full root `AGENTS.md` before judging; no reliance on search snippets, `head`, partial ranges, local excerpts, or truncated copies. Then read every scoped `AGENTS.md` that owns touched paths.
|
||||
- Optional integrations, providers, channels, skill bundles, MCP surfaces, and service workflows route to plugins, ClawHub, or owner repos when current seams suffice. Keep core items for missing core/plugin APIs, bundled regressions, security/core hardening, or maintainer product decisions.
|
||||
- Plugin APIs, provider routing, auth/session state, persisted preferences, config loading, migrations, setup, startup checks, and fallback behavior are compatibility/upgrade-sensitive. Treat config breaks, removed fallbacks, fail-closed changes, or new operator action as merge risk even with green CI.
|
||||
- Plugin APIs, provider routing, auth/session state, persisted preferences, config loading, config/default additions, migrations, setup, startup checks, and fallback behavior are compatibility/upgrade-sensitive. Treat config breaks, new config/default surfaces, removed fallbacks, fail-closed changes, stricter validation, or new operator action as merge risk even with green CI when they can affect existing users, upgrades, provider/plugin behavior, or maintainer operations.
|
||||
- For PRs that add, remove, or change config/default surfaces with possible compatibility, upgrade, provider/plugin, operator, setup, startup, or fallback impact, ClawSweeper review should emit a `reviewMetrics` entry when practical. The metric should name the count and direction of the changes, such as added, changed, or removed config/default surfaces, and explain why the metric matters before merge. When the metric indicates concrete merge risk, also surface the concern in `risks`, use `mergeRiskLabels` when the risk matches the label rubric, make `bestSolution` name the desired pre-merge state, and ensure `labelJustifications` explain the specific reason rather than restating the label.
|
||||
- Review whole decision surfaces, not only the touched runtime, provider, channel, harness, plugin seam, or context path. Check sibling Codex/Pi-style runtimes, provider/model routing, channel delivery, gateway/protocol, plugin SDK, and context-management paths when relevant.
|
||||
- One-sided fixes need sibling-surface proof, an explanation for why siblings are unaffected, or explicit follow-up work.
|
||||
- User-facing `fix`, `feat`, and `perf` changes need `CHANGELOG.md` before landing; contributor PR authors are not blocked solely on maintainer-owned changelog work. Never request thanks for bot/forbidden handles: `@openclaw`, `@clawsweeper`, `@codex`, `@steipete`.
|
||||
- Changelog findings: see Docs / Changelog.
|
||||
- Public ClawSweeper comments prefer `https://docs.openclaw.ai/...` when a public docs page exists; structured evidence still cites repo files, lines, SHAs.
|
||||
- Findings need current source, shipped/current behavior, tests/CI evidence, and dependency contract proof when dependency-backed behavior is involved. Validation is judged against touched and sibling surfaces plus this file's commands; real behavior proof matters for user-visible changes, with Telegram/Desktop proof for Telegram-visible behavior when feasible.
|
||||
- Prefer findings for concrete behavior regressions, missing changed-surface proof, owner-boundary violations, security/API contract issues, or docs/config mismatches.
|
||||
@@ -55,11 +56,19 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Internal bundled plugins ship in core dist; bundled-only facade loader ok only for them.
|
||||
- External official plugins own package/deps and are excluded from core dist; core uses registry-aware `facade-runtime` or generic contracts.
|
||||
- Externalizing a bundled plugin: update package excludes, official catalogs, docs, tests, and prove core runtime paths resolve installed plugin roots before root-dep removal.
|
||||
- Legacy config repair belongs in `openclaw doctor --fix`, not startup/load-time core migrations. Runtime paths use canonical contracts.
|
||||
- Runtime reads canonical config only. No silent compat for old/malformed config keys. If a config change invalidates existing files, add a matching `openclaw doctor --fix` migration. Core/auth config repairs live in core doctor; plugin-owned config repairs live in that plugin's doctor contract (`legacyConfigRules` / `normalizeCompatibilityConfig`).
|
||||
- Fix shape: default to clean bounded refactor, not smallest patch. Move ownership to right boundary; delete stale abstractions, duplicate policy, dead branches, wrappers, fallback stacks.
|
||||
- Fix observed local failures with generic product rules; do not hardcode names, ids, log phrases, or user examples in prod code unless they are an explicit contract.
|
||||
- Tests may use observed examples, but prod literals need a short contract reason.
|
||||
- Compatibility is opt-in. "Shipped" means reachable from a release Git tag; main/GitHub/PR/unreleased code is not shipped.
|
||||
- Refactor default: one canonical path. Delete the old path unless user explicitly wants compat or the shipped public contract is obvious and cited.
|
||||
- Keep old behavior only for an explicit public API/config/plugin SDK/data contract, tagged upgrade path, security/migration boundary, dependency contract, or observed prod state.
|
||||
- If unsure, ask before preserving compat. Do not keep aliases, shims, fallback stacks, stale names, or obsolete tests just in case.
|
||||
- Tests alone do not make internals contracts. If compat stays, name the contract and migration/removal plan in code, test, or PR.
|
||||
- Lean code is a goal. No internal shims, aliases, legacy names, broad fallbacks, or defensive branches just to reduce diff or handle unrealistic edge cases.
|
||||
- Handle real production states, shipped upgrade paths, security boundaries, and dependency contracts. Public/hostile/observed malformed input gets care; hypothetical malformed input does not.
|
||||
- Public plugin SDK/API is the compat exception. New API first, old path only via named compat/deprecation metadata, docs, warnings when useful, tests for old+new, planned removal.
|
||||
- Handle real production states, tagged upgrade paths, security boundaries, and dependency contracts. Public/hostile/observed malformed input gets care; hypothetical malformed input does not.
|
||||
- Deprecate shipped public contracts only.
|
||||
- Plugin SDK exception: shipped external API gets new API first plus named compat/deprecation, small tests/docs if useful, removal plan.
|
||||
- Migrate internal/bundled callers to modern API in the same change. Do not let internal compat become permanent architecture.
|
||||
- Channels are implementation under `src/channels/**`; plugin authors get SDK seams. Providers own auth/catalog/runtime hooks; core owns generic loop.
|
||||
- Hot paths should carry prepared facts forward: provider id, model ref, channel id, target, capability family, attachment class. Do not rediscover with broad plugin/provider/channel/capability loaders.
|
||||
@@ -67,7 +76,8 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Gateway/plugin metadata is process-stable: installs, manifests, catalogs, generated paths, bundled metadata. Changes require restart or explicit owner reload/install/doctor flow.
|
||||
- Runtime hot paths: no freshness polling (`stat`/`realpath`/JSON reread/hash). Reuse current snapshots, install records, discovery, lookup tables, root scopes, resolved paths.
|
||||
- Process-local metadata caches ok when lifecycle-owned and bounded/single-slot. Freshness exceptions need named owner + tests.
|
||||
- Inline code comments: brief notes for tricky, bug-prone, or previously buggy logic.
|
||||
- Inline comments: preserve reviewer context at the code site. Use for cross-path/state invariants, platform/dependency caps, deterministic ordering, compact encoded state, lifecycle ordering, ownership boundaries, session/id adoption, queue-depth symmetry, fallbacks, or intentional caller differences.
|
||||
- Comment shape: 1-3 short lines; state why the branch/helper exists, what contract it protects, and the bad outcome if removed. Cite nearby constants/helpers when useful. No syntax narration, PR/user-specific lore, or obvious mechanics.
|
||||
- Gateway protocol changes: additive first; incompatible needs versioning/docs/client follow-through.
|
||||
- Protocol version bumps: explicit owner confirmation only; never automatic/generated.
|
||||
- Config contract: exported types, schema/help, metadata, baselines, docs aligned. Retired public keys stay retired; compat in raw migration/doctor only.
|
||||
@@ -79,7 +89,6 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Runtime: Node 22.19+; Node 24 recommended. Keep Node + Bun paths working.
|
||||
- Package manager/runtime: repo defaults only. No swaps without approval.
|
||||
- Install: `pnpm install` (keep Bun lock/patches aligned if touched).
|
||||
- Sharp/Homebrew libvips source-build fail: `SHARP_IGNORE_GLOBAL_LIBVIPS=1 pnpm install`.
|
||||
- CLI: `pnpm openclaw ...` or `pnpm dev`; build: `pnpm build`.
|
||||
- Tests in a normal source checkout: `pnpm test <path-or-filter> [vitest args...]`, `pnpm test:changed`, `pnpm test:serial`, `pnpm test:coverage`; never raw `vitest`.
|
||||
- Tests in a Codex worktree or linked/sparse checkout: avoid direct local `pnpm test*`; use `node scripts/run-vitest.mjs <path-or-filter>` for tiny explicit-file proof, or Crabbox/Testbox for anything broader.
|
||||
@@ -116,7 +125,6 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Do not leave associated issues open for hypothetical future repros. Close with rationale; ask for a new issue or reopen only if concrete new evidence appears. Close comment states: decision, why, supported alternative, and what evidence would change the decision.
|
||||
- PR review answer: bug/behavior, URL(s), affected surface, provenance for regressions when traceable, best-fix judgment, evidence from code/tests/CI/current or shipped behavior.
|
||||
- Issue/PR final answer: last line is the full GitHub URL.
|
||||
- Changelog: PR landings/fixes need one unless pure test/internal. Do not mention missing changelog as a review finding; Codex handles it during fix/landing.
|
||||
- PR verification: before merge, post exact local commands, CI/Testbox run IDs, before/after proof when used, and known proof gaps.
|
||||
- Issue fixed on `main` with proof: comment proof + commit/PR, then close.
|
||||
- After landing or requested close/sweep: search duplicates; comment proof + canonical commit/PR/release before closing.
|
||||
@@ -124,8 +132,10 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- `ship` that fixes an issue: after push, comment proof + commit link, then close the issue.
|
||||
- GH comments with backticks, `$`, or shell snippets: use heredoc/body file, not inline double-quoted `--body`.
|
||||
- PR create: real body required. Include Summary + Verification; mention refs, behavior, and proof.
|
||||
- PR create/refresh: keep PR branches takeover-ready. Use a branch maintainers can push to, or for fork PRs ensure `maintainer_can_modify` / GitHub's `Allow edits by maintainers` is enabled unless explicitly told otherwise or GitHub's Actions/secrets warning makes that unsafe.
|
||||
- GitHub issue/PR create: read `$agent-transcript`; ask about sanitized transcript logs when available.
|
||||
- Real behavior proof section is parsed. Use exact `field: value` labels: `Behavior addressed`, `Real environment tested`, `Exact steps or command run after this patch`, `Evidence after fix`, `Observed result after fix`, `What was not tested`.
|
||||
- PR artifacts/screenshots: attach to PR/comment/external artifact store. Do not commit `.github/pr-assets`.
|
||||
- PR artifacts/screenshots: attach to PR/comment/external artifact store. Never push screenshots, videos, proof images, or proof assets to OpenClaw or any product repo branch, including temp artifact branches. Use Crabbox artifact publishing plus the manifest URL. Do not commit `.github/pr-assets`.
|
||||
- CI polling: exact SHA, relevant checks only, minimal fields. Skip routine noise (`Auto response`, `Labeler`, docs agents, performance/stale). Logs only after failure/completion or concrete need.
|
||||
- Maintainers: may skip/ignore `Real behavior proof` when local tests or Crabbox verified behavior; record proof in PR verification.
|
||||
- `/landpr`: use `~/.codex/prompts/landpr.md`; do not idle on `auto-response` or `check-docs`.
|
||||
@@ -140,10 +150,22 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Calls should be boring: complex decisions happen above; call args/object fields are names, literals, or simple property reads.
|
||||
- Prefer early returns over nested condition pyramids. Split code into gather -> normalize -> decide -> act.
|
||||
- Use named intermediates only for domain meaning or readability; avoid temp-variable soup.
|
||||
- Code size matters. Prefer small clear code; maintainability includes not growing LOC without payoff.
|
||||
- Refactors should delete about as much local complexity as they add. If LOC grows, the new ownership/API needs to clearly pay for it.
|
||||
- Before adding helpers/files, check whether existing code can absorb the behavior with less new surface.
|
||||
- Keep APIs narrow: export only current caller needs; keep types/helpers local by default.
|
||||
- Return the smallest useful shape. Avoid broad result objects, flags, metadata unless callers use them.
|
||||
- Avoid adapter layers that only rename fields. Move real responsibility or leave code local.
|
||||
- Inline simple one-use objects/spreads when clearer. Extract only when it removes duplication or hard logic.
|
||||
- Tests prove behavior/regressions, not every internal branch.
|
||||
- For non-trivial refactors, check `git diff --numstat` before closeout. If LOC grew, trim or explain why.
|
||||
- Prefer existing narrow helpers over repeated casts/guards. Add local helpers when 2+ nearby call sites share real boundary logic.
|
||||
- Prefer ctor parameter properties for injected deps/config. Do not ban them for erasable-syntax purity.
|
||||
- Prefer `satisfies` for registries/config maps; derive types from schemas when a runtime schema already exists.
|
||||
- Table-drive repetitive tests when it reduces code and keeps failure names clear.
|
||||
- Dynamic import: no static+dynamic import for same prod module. Use `*.runtime.ts` lazy boundary. After edits: `pnpm build`; check `[INEFFECTIVE_DYNAMIC_IMPORT]`.
|
||||
- Cycles: keep `pnpm check:import-cycles` + architecture/madge green.
|
||||
- Classes: no prototype mixins/mutations. Prefer inheritance/composition. Tests prefer per-instance stubs.
|
||||
- Comments: brief, only non-obvious logic.
|
||||
- Split files around ~700 LOC when clarity/testability improves.
|
||||
- Naming: **OpenClaw** product/docs; `openclaw` CLI/package/path/config.
|
||||
- English: American spelling.
|
||||
@@ -162,12 +184,12 @@ Skills own workflows; root owns hard policy and routing.
|
||||
|
||||
## Docs / Changelog
|
||||
|
||||
- Use `$openclaw-docs` for docs writing/review. Docs change with behavior/API.
|
||||
- Use `$technical-documentation` for docs writing/review. Docs change with behavior/API.
|
||||
- Codex harness upgrade (`extensions/codex/package.json` `@openai/codex`): refresh `docs/plugins/codex-harness.md` model snapshot from the new harness `model/list`.
|
||||
- Docs final answers: include relevant full `https://docs.openclaw.ai/...` URL(s). If issue/PR work too, GitHub URL last.
|
||||
- Changelog entries: active version `### Changes`/`### Fixes`; single-line bullets only.
|
||||
- Contributor PR authors should not edit `CHANGELOG.md`; maintainer/AI adds entries during landing/merge.
|
||||
- Contributor-facing changelog entries thank credited human `@author`. Never thank bots, `@openclaw`, `@clawsweeper`, or `@steipete`; if unknown, omit thanks.
|
||||
- `CHANGELOG.md`: release-owned. Do not edit for normal PRs, direct `main` fixes, or `ship it`; only explicit release/changelog generation may rewrite it. Do not ask contributors/agents for changelog edits.
|
||||
- User-facing `fix`/`feat`/`perf`: put release-note context in PR body, squash message, or direct commit: behavior, surface, issue/PR refs, credited human author/reporter.
|
||||
- Release generation: derive `CHANGELOG.md` from merged PRs + all direct `main` commits. Entries: active `### Changes`/`### Fixes`, single-line, thank credited humans; never thank bots/forbidden handles: `@openclaw`, `@clawsweeper`, `@codex`, `@steipete`.
|
||||
|
||||
## Git
|
||||
|
||||
@@ -176,7 +198,7 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- No manual stash/autostash unless explicit. No branch/worktree changes unless requested.
|
||||
- `main`: no merge commits; rebase on latest `origin/main` before push. After one green run plus clean rebase sanity, do not chase moving `main` with repeated full gates.
|
||||
- User says `commit`: your changes only. `commit all`: all changes in grouped chunks. `push`: may `git pull --rebase` first.
|
||||
- User says `ship it`: changelog if needed, commit intended changes, pull --rebase, push.
|
||||
- User says `ship it`: commit intended changes, pull --rebase, push.
|
||||
- Do not delete/rename unexpected files; ask if blocking, else ignore.
|
||||
- Bulk PR close/reopen >5: ask with count/scope.
|
||||
|
||||
@@ -187,7 +209,7 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Dependency patches/overrides/vendor changes need explicit approval. `pnpm-workspace.yaml` patched dependencies use exact versions only.
|
||||
- Lockfiles/shrinkwrap are security surface: review `pnpm-lock.yaml`, `npm-shrinkwrap.json`, `package-lock.json`; root/plugin npm packages ship shrinkwrap, not package-lock.
|
||||
- Carbon pins owner-only: do not change `@buape/carbon` unless Shadow (`@thewilloftheshadow`, verified by `gh`) asks.
|
||||
- Releases/publish/version bumps need explicit approval. Use `$openclaw-release-maintainer`.
|
||||
- Releases/publish/version bumps need explicit approval. Use `$release-openclaw-maintainer`.
|
||||
- GHSA/advisories: `$openclaw-ghsa-maintainer` / `$security-triage`. Secret scanning: `$openclaw-secret-scanning-maintainer`.
|
||||
- Beta tag/version match: `vYYYY.M.D-beta.N` -> npm `YYYY.M.D-beta.N --tag beta`.
|
||||
|
||||
@@ -198,7 +220,7 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- SwiftUI: Observation (`@Observable`, `@Bindable`) over new `ObservableObject`.
|
||||
- Mac gateway: dev watch = `pnpm gateway:watch`; managed installs = `openclaw gateway restart/status --deep`; logs = `./scripts/clawlog.sh`. No launchd/ad-hoc tmux.
|
||||
- Mac app permission testing: stable app path + real signing identity required. No `--no-sign`, `SIGN_IDENTITY=-`, or raw debug binary; TCC prompts/listing won't stick.
|
||||
- Version bump surfaces live in `$openclaw-release-maintainer`.
|
||||
- Version bump surfaces live in `$release-openclaw-maintainer`.
|
||||
- Parallels: `$openclaw-parallels-smoke`; Discord roundtrip: `$parallels-discord-roundtrip`.
|
||||
- Crabbox/WebVNC human demos: keep remote desktop visible/windowed; no fullscreen remote browser unless video/capture-style output.
|
||||
- ClawSweeper ops: `$clawsweeper`. Deployed hook sessions may post one concise `#clawsweeper` note only when surprising/actionable/risky; if using message tool, reply exactly `NO_REPLY`.
|
||||
|
||||
152
CHANGELOG.md
152
CHANGELOG.md
@@ -6,36 +6,165 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Changes
|
||||
|
||||
- Voice: expose shared realtime turn-context tracking through the realtime voice SDK and reuse it for Discord speaker attribution and wake-name context recovery.
|
||||
- Voice: reuse shared realtime output activity tracking in Google Meet command and node audio bridges, including recent-output checks for local barge-in detection.
|
||||
- Voice: expose shared realtime output activity tracking through the realtime voice SDK and reuse it for Discord playback activity and barge-in decisions.
|
||||
- Voice: expose shared realtime consult question matching, speakable-result extraction, and alias-aware forced-consult coordination through the realtime voice SDK, then reuse it in Gateway Talk, Voice Call, and Discord voice paths.
|
||||
- Voice: share activation-name matching and consult-transcript screening through the realtime voice SDK so Discord, browser voice, and meeting surfaces can reuse one implementation.
|
||||
- Cron: default `cron.maxConcurrentRuns` to 8 so scheduled automations and their isolated agent turns can make progress in parallel without explicit configuration.
|
||||
- QA-Lab: add `qa coverage --match <query>` so focused proof selection can discover matching scenarios from existing metadata before running live or remote lanes.
|
||||
- Control UI: add an ephemeral Activity tab for sanitized live tool activity summaries without persisting raw telemetry. Fixes #12831. Thanks @BunsDev.
|
||||
- Build: include `ui:build` in the `full` and `ciArtifacts` profiles of `scripts/build-all.mjs` so `pnpm build` always rebuilds `dist/control-ui` after `tsdown` cleans `dist`, removing the second-command requirement and the missing-asset failure mode for source/runtime installs and CI artifact uploads. (#85206)
|
||||
- Migrate: import supported Hermes, OpenCode, and Codex auth credentials into OpenClaw auth profiles when credential migration is selected, with explicit opt-out and non-interactive controls. (#85667) Thanks @fuller-stack-dev.
|
||||
- iOS: improve Talk mode with direct realtime voice sessions, compact toolbar status, and responsive voice waveform feedback. (#86355) Thanks @ngutman.
|
||||
- Media: replace the Sharp image backend with Photon for metadata, resizing, EXIF orientation, and PNG alpha-preserving optimization so OpenClaw no longer installs Sharp or the WhatsApp Jimp fallback for image processing. (#86437)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Scripts: remove stale Knip unused-file allowlist entries so the dead-code gate fails only on current findings.
|
||||
- Telegram/network: treat `ENETDOWN` as a transient pre-connect network failure so Telegram sends, gateway unhandled-rejection handling, and cron network retries follow the same recovery path as sibling network outages. (#86762) Thanks @TurboTheTurtle.
|
||||
- Agents/sessions: include visibility metadata on restricted `sessions_list` results so scoped counts are clearly reported without widening access or exposing hidden-session counts. (#86944) Thanks @ferminquant.
|
||||
- Gateway/DNS: validate wide-area discovery domains before deriving zone paths or writing zone files, so invalid `discovery.wideArea.domain` and `dns setup --domain` values fail with a DNS-name diagnostic instead of falling through to unrelated configuration errors. Thanks @mmaps.
|
||||
- Agents/BTW: route fallback side-question streams through the embedded stream resolver so Anthropic-compatible MiniMax requests use the same capped transport as normal chat. (#86312) Thanks @neeravmakwana.
|
||||
- Telegram: treat `/command@TargetBot` bot-command entities as explicit mentions for the addressed bot so `requireMention` groups no longer drop targeted commands or captions. Fixes #84462. (#86553) Thanks @luoyanglang.
|
||||
- CI: bound Docker/Bash E2E tarball npm installs with `OPENCLAW_E2E_NPM_INSTALL_TIMEOUT` so package, onboarding, plugin, and upgrade lanes fail instead of hanging on a stuck npm install.
|
||||
- CI: keep `OPENCLAW_TESTBOX=1 pnpm check:changed` delegating to Blacksmith Testbox through Crabbox without forwarding local Testbox or worker env into the remote command.
|
||||
- CI: send KILL after the TERM grace period for manual checkout fetch timeouts so stuck Testbox and workflow checkout retries cannot hang behind a wedged `git fetch`.
|
||||
- iMessage: thread current channel/account inbound attachment roots into the image tool so iMessage-saved attachments under `~/Library/Messages/Attachments` (including the wildcard `/Users/*/Library/Messages/Attachments` root) are read through the existing inbound path policy instead of being rejected as `path-not-allowed`. Literal `localRoots` stays workspace-scoped. Fixes #30170. (#86569)
|
||||
- QQ Bot: respect `OPENCLAW_HOME` for outbound media path resolution so `<qqmedia>` sends no longer silently fail when `HOME` and `OPENCLAW_HOME` differ (Docker / multi-user hosts). Persisted QQ Bot data (sessions, known users, refs) stays anchored on the OS home for upgrade compatibility. Fixes #83562. Thanks @sliverp.
|
||||
- Update: report the primary malformed `openclaw.extensions` payload error without adding a duplicate missing-main diagnostic. (#86596) Thanks @ferminquant.
|
||||
- Control UI: keep host-local Markdown file paths inert while preserving app-relative links. (#86620) Thanks @BryanTegomoh.
|
||||
- Gateway: dampen repeated unauthenticated device-required probes per URL while preserving explicit-auth and paired recovery paths. (#86575) Thanks @ferminquant.
|
||||
- IRC: store inbound channel routes with the canonical `channel:#name` target and join transient channel sends before writing. (#85906) Thanks @Kailigithub.
|
||||
- Usage: surface unknown all-zero model pricing as missing cost entries instead of a confident `$0` total. (#85882) Thanks @MichaelZelbel.
|
||||
- Agents/Codex: honor yolo app-server approval policy only for the full `never` plus `danger-full-access` case. (#85909) Thanks @earlvanze.
|
||||
- Gateway/Gmail: clear Gmail watcher renewal intervals on re-entry so hot reloads do not leak lifecycle timers. (#82947) Thanks @SebTardif.
|
||||
- Logging: exit cleanly on broken stdout/stderr pipes without masking existing failure exit codes. (#80059) Thanks @pavelzak.
|
||||
- Gateway/security: escape transcript metadata field names while extracting oversized session line prefixes. (#85934) Thanks @SebTardif.
|
||||
- Plugins/security: validate manifest model pattern regexes with the safe-regex compiler so unsafe patterns are ignored before matching. (#86046) Thanks @SebTardif.
|
||||
- Discord: route gateway metadata REST lookups through the configured Discord proxy so proxied accounts do not fall back to direct `discord.com` connections before opening the WebSocket. Fixes #80227. Thanks @Clivilwalker.
|
||||
- Agents/media: hydrate current-turn image attachments from filename-derived MIME types so active vision can see generated or forwarded images whose source omitted an image content type. (#84812) Thanks @marchpure.
|
||||
- Agents/fs: point workspace-only scratch-path guidance at in-workspace temp directories while keeping host-root writes rejected by the tool guard. (#86501) Thanks @tianxiaochannel-oss88.
|
||||
- Agents/media: keep async cron media completions scoped to their run session while preserving direct delivery for stale generated-media success and failure notifications. (#86529) Thanks @ai-hpc.
|
||||
- Gateway: emit plugin `session_end`/`session_start` hooks when `agent.send` rotates or replaces a session id, keeping hook lifecycle state aligned with `sessions.changed` notifications. Fixes #83507. (#85875) Thanks @brokemac79.
|
||||
- OpenShell/SSH: reject malformed generated exec commands before sandbox/session setup so unresolved workflow placeholders fail fast instead of reaching the remote shell. Fixes #72373. Thanks @brokemac79.
|
||||
- Google: stop normalizing `gemini-3.1-flash-lite` to the retired preview endpoint and update Flash Lite alias guidance to the GA model id. Fixes #86151. (#86240) Thanks @SebTardif.
|
||||
- Installer: make Alpine apk installs cover Git, verify the Node runtime floor, try `nodejs-current`, and report Alpine version guidance when repositories only provide older Node packages.
|
||||
- Agents/status: prefer the active Claude CLI OAuth auth label over an unused Anthropic env API-key label for equivalent runtime aliases. Fixes #80184. (#86570) Thanks @brokemac79.
|
||||
- Agents/media: send direct fallback for generated media still missing after an active requester wake fails. (#85489) Thanks @fuller-stack-dev.
|
||||
- Agents: derive overflow compaction budgets from provider-reported and synthetic over-budget token counts so confirmed context overflows compact before retrying. (#70473) Thanks @fuller-stack-dev.
|
||||
- Agents/Codex: recover Codex context-window prompt errors through overflow compaction and surface reset guidance when recovery is exhausted. (#85542) Thanks @fuller-stack-dev.
|
||||
- Agents/Codex: allow Codex app-server runs to bootstrap from `CODEX_API_KEY` or `OPENAI_API_KEY` when no Codex auth profile is configured.
|
||||
- Agents/Codex: keep selected Codex runtime routing on OpenAI-Codex while preserving direct OpenAI API-key compaction fallback. (#86408) Thanks @funmerlin and @VACInc.
|
||||
- Agent transcript: include OpenClaw agent session logs when finding local transcript candidates.
|
||||
- Crabbox: bootstrap raw AWS macOS shell commands wrapped in absolute `time` paths so RSS probes can run Node and pnpm on fresh macOS runners.
|
||||
- Crabbox: bootstrap raw AWS macOS shell commands even when setup statements precede Node or pnpm usage.
|
||||
- TUI/local: skip unnecessary secret resolution, gateway model catalog loading, bootstrap, and skill scans in explicit local-model runs so startup reaches the model request faster.
|
||||
- Sessions/doctor: load large session stores without clone amplification during read-only doctor checks and reclaim stale `sessions.json.*.tmp` sidecars. Fixes #56827. Thanks @openperf.
|
||||
- Tests: clean successful plugin gateway gauntlet isolated temp roots while keeping an explicit preservation switch for failed/debug runs.
|
||||
- Plugins/perf: reuse derived plugin metadata snapshots for the lifetime of the process so reply-time skill setup no longer rescans plugin metadata on every turn.
|
||||
- Discord/OpenAI voice: keep wake-name master consults using the current speaker context after ignored ambient transcripts and shorten the default capture silence grace.
|
||||
- Doctor: skip redundant Gateway restart prompts when a recent supervisor restart leaves the Gateway healthy. Fixes #86518. (#86533) Thanks @liaoyl830.
|
||||
- Cron: restore suspended cron lanes to the configured/default concurrency instead of falling back to one after quota or circuit-breaker auto-resume.
|
||||
- Gateway: keep session-only Control UI tool-start mirrors flowing during diagnostic queue pressure instead of silently dropping non-terminal tool updates.
|
||||
- Agents/memory: return optional not-found context for missing date-only daily memory reads instead of logging benign first-run `ENOENT` failures. Fixes #82928. Thanks @galiniliev.
|
||||
- Discord: merge streamed text captions into following media block replies so captions and attachments send as one message. (#86487) Thanks @neeravmakwana.
|
||||
- Gateway: avoid sending duplicate tool-event frames to Control UI connections that are subscribed by both run and session.
|
||||
- Discord/OpenAI voice: accept broader edge-position fuzzy wake-name transcripts while keeping ambient speech gated.
|
||||
- Discord/OpenAI voice: accept longer leading wake-name mistranscripts such as "Open Club" for OpenClaw.
|
||||
- Agents/OpenAI-compatible: stop ModelStudio-compatible chat requests before sending system/tool-only payloads that have no usable user or assistant turn. (#86177) Thanks @TurboTheTurtle.
|
||||
- Gateway/plugins: reuse plugin package realpath checks while building installed plugin indexes so startup avoids repeated filesystem resolution work.
|
||||
- Kilo Gateway: send string `stop` sequences as arrays so Kilo accepts OpenAI-compatible chat completions. (#86461) Thanks @SebTardif.
|
||||
- Discord/OpenAI voice: accept leading fuzzy wake-name transcripts such as "Monty" or "Moti" for a Molty agent while keeping ambient speech gated.
|
||||
- Media understanding: convert HEIC and HEIF images to JPEG before image description providers run so iPhone photos work in direct and configured image-description flows. (#86037)
|
||||
- Agents: release embedded-attempt session locks from outer teardown so post-prompt exceptions cannot wedge later requests behind `SessionWriteLockTimeoutError`. Fixes #86014. Thanks @openperf.
|
||||
- Discord/OpenAI voice: rotate Realtime sessions at provider max duration without logging the expected session-expiry event as an error.
|
||||
- Sessions: skip metadata-only entries during QMD-slugified session lookup so one incomplete row does not block transcript hit resolution. (#86327) Thanks @abnershang.
|
||||
- Agents/media: derive bundled plugin local-media trust from plugin tool metadata instead of importing the full plugin registry on subscription paths. (#84409) Thanks @samzong.
|
||||
- Image tool: keep config-backed custom-provider API keys usable for auto-discovered vision models, including deferred image-tool execution without env keys or auth profiles. (#85733)
|
||||
- Memory/local embeddings: run local GGUF embeddings in an isolated worker sidecar and degrade to configured fallback or keyword search on worker failure so native embedding crashes do not take down the Gateway. (#85348) Thanks @osolmaz.
|
||||
- Gateway: clear the runtime config snapshot before `SIGUSR1` in-process restarts so config changes survive the next gateway loop. (#86388) Thanks @XuZehan-iCenter.
|
||||
- Models: show OAuth delegation markers as configured `models.json` auth while keeping runtime route usability checks strict. (#86378) Thanks @rohitjavvadi.
|
||||
- Cron: seed active scheduled and manual cron task rows with a progress summary so status surfaces do not look blank while jobs run. (#86313) Thanks @ferminquant.
|
||||
- Cron: preserve unsupported persisted cron payload rows during routine store writes while keeping those rows non-runnable. Fixes #84922. (#86415) Thanks @IWhatsskill.
|
||||
- Updater: exclude prerelease git tags from stable channel resolution so source updates do not check out newer alpha/rc/preview/canary tags. (#86260) Thanks @stevenepalmer.
|
||||
- Security/Audit: flag webhook `hooks.token` reuse of active Gateway password auth in `openclaw security audit` while keeping password-mode startup compatibility. (#84338) Thanks @coygeek.
|
||||
- QQBot: derive the outbound reply watchdog from configured agent and provider timeouts so slow local model replies are not cut off at five minutes. Fixes #85267. (#85271) Thanks @SymbolStar.
|
||||
- Agents/heartbeat: stop heartbeat turns after the first valid `heartbeat_respond` so repeated response loops do not burn tokens. (#86357) Thanks @udaymanish6.
|
||||
- Tasks: keep retained lost tasks out of default status health counts, explain their cleanup window during maintenance, and prune lost task records after 24 hours instead of the general 7-day terminal retention.
|
||||
- Memory-core: keep REM dreaming focused on live light-staged memories and mark staged entries as considered so old recall history no longer dominates fresh candidates. (#86302) Thanks @SebTardif.
|
||||
- Memory: abort sync instead of downgrading an existing semantic vector index to FTS-only when the configured embedding provider is temporarily unavailable. (#85704) Thanks @yaaboo-gif.
|
||||
- Telegram: propagate forum topic names through the account-scoped topic cache for native command context and topic create/edit actions. (#86299) Thanks @SebTardif.
|
||||
- Slack: keep downloaded read-only files out of reply media so Slack file reads do not echo files back to the conversation. (#86318) Thanks @neeravmakwana.
|
||||
- Cron: accept leading-plus relative durations such as `+5m` for one-shot `--at` schedules. (#86341) Thanks @mushuiyu886.
|
||||
- Agents/media: preserve async-started media tool metadata so background generation starts no longer surface generic incomplete-turn warnings while replay stays unsafe. (#85933) Thanks @fuller-stack-dev.
|
||||
- Docker E2E: dedupe scheduler lane resources so npm/service package lanes are not over-counted and serialized unnecessarily.
|
||||
- QA/diagnostics: add a collector-backed OpenTelemetry smoke lane, make the OTLP payload leak check scenario-aware, and keep source QA builds from failing on optional dependency imports resolved through pnpm's temp module path.
|
||||
- Crabbox: bootstrap Git metadata for sparse remote changed gates so raw synced workspaces can run `pnpm check:changed` from the intended diff.
|
||||
- xAI/LM Studio: avoid buffering ordinary bracketed or `final` prose until stream completion while watching for plain-text tool-call fallbacks.
|
||||
- Doctor: warn and continue when the cron job store exists but cannot be read so later health checks still run. Fixes #86102. (#86384) Thanks @1052326311.
|
||||
- Discord: suppress a bot's previous reply body and referenced media from prompt context when a user replies to that bot message, while keeping reply metadata for routing. (#86238) Thanks @fuller-stack-dev.
|
||||
- Discord: restore bare numeric channel IDs for outbound message-tool sends while keeping explicit DM targets unambiguous. (#86571) Thanks @joshavant.
|
||||
- Docker E2E: avoid rebuilding the Control UI twice while preparing the shared OpenClaw package tarball for package-backed scenario runs.
|
||||
- Tests: avoid rebuilding the Control UI twice during the installer Docker smoke now that `pnpm build` includes `ui:build`.
|
||||
- Tests: give QA config mutation RPCs enough native Windows budget to finish gateway config writes and restart settle after hot scenario runs.
|
||||
- Tests: keep the gateway restart-inflight QA scenario focused on restart recovery on native Windows by allowing expected embedded prompt handoff errors and using the Windows-safe timeout budget.
|
||||
- QA-Lab: make the synthetic OpenAI provider honor generic `reply exactly:` directives after required kickoff reads so restart-recovery scenarios do not fall through to generic repo-summary prose.
|
||||
- Gateway: abort active `agent` RPC runs during forced restart shutdown so stale in-process turns cannot keep writing a session after the Gateway lifecycle restarts.
|
||||
- Crabbox: sync clean sparse worktrees through a temporary full checkout even when reusing an existing lease so tracked build-time files are not omitted.
|
||||
- Build: route `scripts/ui.js` through the shared pnpm runner and keep Control UI chunking helpers in sparse-included source so native Windows Corepack builds can produce `dist/control-ui`.
|
||||
- Tests: give the memory fallback QA scenario enough turn budget to exercise native Windows gateway runs instead of failing on the client timeout while the mock agent is still dispatching.
|
||||
- Tests: collect QA gateway CPU/RSS metrics on native Windows and give the channel baseline enough turn budget to report slow gateway runs instead of timing out before proof.
|
||||
- Install/update: bypass npm `min-release-age` policies with `--min-release-age=0` instead of `--before` so hosted installers keep working on npm versions that reject the combined config. (#84749) Thanks @TeodoroRodrigo.
|
||||
- Diagnostics: reclaim wedged session lanes when stale active-run bookkeeping blocks queued work despite no forward progress. Fixes #85639. Thanks @openperf.
|
||||
- WebChat: keep message-tool replies visible in the chat while still summarizing internal tool results for the model. Fixes #86347. Thanks @shakkernerd.
|
||||
- Gateway/perf: fail startup benchmark samples when the Gateway process exits before benchmark teardown, including signal deaths after readiness probes.
|
||||
- Gateway/perf: fail restart benchmark samples when the Gateway exits before benchmark teardown, including clean exits and signal deaths after successful restart probes.
|
||||
- Agents/tests: keep model catalog visibility on static selection helpers so catalog visibility checks avoid the broad model-selection barrel import.
|
||||
- Agents/commitments: serialize commitment store load-modify-save writes so concurrent heartbeat and CLI updates no longer lose dismissal, sent, or attempt state. (#81153) Thanks @ai-hpc.
|
||||
- xAI/LM Studio: promote plain-text tool-call fallbacks into structured tool calls and strip leaked internal tool syntax before user-facing delivery. (#86222) Thanks @fuller-stack-dev.
|
||||
- CLI: suppress benign self-update version-skew warnings during package post-update finalization.
|
||||
- Gateway/perf: tighten restart and startup benchmark failure handling so long profiling runs, failed probes, and fresh Linux runners no longer produce false passing or `n/a` results.
|
||||
- Checks: keep intentional Knip unused-file findings optional so full CI and sparse proof workspaces stay aligned.
|
||||
- Docker: restore writable `~/.config` in runtime images. Fixes #85968. Thanks @hkoessler and @Bartok9.
|
||||
- Plugin SDK: keep legacy root diagnostic subscriptions connected when built plugin SDK aliases resolve diagnostic helpers through a separate module graph.
|
||||
- Diagnostics: export alertable OTel and Prometheus signals for blocked tools, model failover, stale sessions, liveness warnings, oversized payloads, and webhook ingress while fixing shared OTLP endpoints with query strings.
|
||||
- Tests: normalize macOS canonical temp paths in exec allowlists, fs-safe trash assertions, installed plugin matching, Telegram topic-name stores, and built ACPX MCP server expectations so native macOS proof runners cover the intended behavior.
|
||||
- Codex/app-server: preserve message-tool-only source reply delivery mode on active runs so sub-agent completion wakeups can steer the active Codex turn instead of being rejected. (#86287) Thanks @ferminquant.
|
||||
- Tests: sample the Windows kitchen-sink RPC gateway directly and serialize RSS probes so native runs keep the memory guard active.
|
||||
- Tests: normalize bundled plugin lifecycle probe paths and state-root lookup so native Windows release sweeps accept valid packaged plugin installs.
|
||||
- Agents/Claude CLI: route live native Bash permission requests through OpenClaw exec policy so Claude turns no longer stall on `control_request`, and document that OpenClaw exec policy is authoritative. Fixes #80819. (#86330, from #81971) Thanks @guthirry and @sallyom.
|
||||
- Security audit: warn when YOLO OpenClaw exec policy overrides a restrictive raw Claude `--permission-mode` for managed live sessions. (#86557) Thanks @sallyom.
|
||||
- Config: keep benign legacy metadata write anomalies out of default doctor and config command output while preserving explicit anomaly logging for diagnostics.
|
||||
- Codex: log when implicit app-server `never` approvals are promoted for OpenClaw tool policy, including whether the trigger was a `before_tool_call` hook or trusted tool policy.
|
||||
- Codex harness: make subscription usage-limit errors without reset times explain that OpenClaw cannot determine the reset and point users to wait until Codex is available, use another Codex account, or switch to another configured model/provider. Thanks @amknight.
|
||||
- Google Vertex: support production ADC modes such as Workload Identity Federation, service-account credentials, and metadata-server ADC for the native Vertex transport. (#83971) Thanks @damianFelixPago.
|
||||
- Telegram: route normal `[telegram][diag]` polling diagnostics through `runtime.log` while keeping non-diag warnings and persistence failures on `runtime.error`, so healthy polling startup no longer looks like an error. Fixes #82957. (#82958) Thanks @galiniliev.
|
||||
- Providers/Ollama: strip inline Kimi cloud reasoning prefixes from streamed and final visible replies while keeping ordinary Kimi answers append-only. (#86286) Thanks @jason-allen-oneal.
|
||||
|
||||
- Gateway: require Talk secret authority before setup-code handoff can include Talk secrets. (#85690) Thanks @ngutman.
|
||||
- Agents: keep fallback error reporting scoped to the active model candidate so stale prior-provider quota/auth text is not reported for later fallback attempts. (#86134) thanks @zhangguiping-xydt.
|
||||
- iMessage: dedupe watcher startup when `channels.imessage.accounts` lists both `default` and a named account that point at the same local Messages source, so the gateway no longer spawns two `imsg rpc` processes or doubles inbound replies; the dedupe is scoped to watcher startup, leaving duplicate accounts addressable for outbound sends, status, and capability listings, and `openclaw doctor` flags the redundant account with a rebinding hint. Fixes #65141. (#86705) Thanks @swang430.
|
||||
|
||||
## 2026.5.25
|
||||
|
||||
### Fixes
|
||||
|
||||
- Installer: let the local-prefix CLI installer use Alpine's `apk` Node.js, npm, and Git packages on musl Linux instead of downloading glibc Node tarballs that fail `node:sqlite`.
|
||||
- Scripts: use `git grep` to prefilter tracked conflict-marker scans so changed checks avoid reading every repository file on clean runs.
|
||||
- Checks: prefilter tracked conflict-marker scans so changed checks avoid reading every repository file on clean runs.
|
||||
- Plugins: allow linked local plugin paths to probe TypeScript source entries without requiring compiled package output, restoring source-checkout plugin development on native Windows.
|
||||
- CLI: route source-checkout build output to stderr before launching OpenClaw commands so stale local builds do not corrupt `--json` stdout.
|
||||
- Installer: install Node.js through `apk` on Alpine Linux instead of falling through to the NodeSource package-manager path.
|
||||
- Agents/perf: cache manifest-backed CLI provider descriptors and fallback provider resolution so model fallback retries avoid repeated bundled provider runtime scans while still invalidating across plugin reloads.
|
||||
- Installer: detect musl Linux shells such as Alpine as Linux instead of rejecting them before npm install.
|
||||
- Scripts: run direct Node package scripts with env overrides through a cross-platform launcher so gateway, TUI, and Docker-all entrypoints work on native Windows.
|
||||
- Windows: run direct Node package scripts with env overrides through a cross-platform launcher so gateway, TUI, Docker-all, generated-module formatting, and optional Discord native opus installer entrypoints work on native Windows.
|
||||
- Tests: run Vitest import timing entrypoints through a Node wrapper so native Windows package scripts can collect import diagnostics.
|
||||
- Control UI: split large build-time runtime dependencies into stable chunks so Linux/Docker install and package builds stay below the app chunk warning threshold.
|
||||
- Tests: run `test:max` and `test:changed:max` through a Node wrapper so high-worker Vitest entrypoints work on native Windows.
|
||||
- Tests: retry transient loopback HTTP resets in the kitchen-sink RPC walk so native Windows readiness probes do not fail after the gateway is already ready.
|
||||
- Tests: run `test:serial` through a Node wrapper so targeted serial Vitest commands work on native Windows.
|
||||
- Tests: normalize Vitest config path assertions so the infra config suite runs on native Windows paths.
|
||||
- Scripts: run the optional Discord native opus installer through the shared pnpm launcher and Windows CI coverage so native Windows installs avoid shell-mode package-manager shims.
|
||||
- Installer: avoid the incompatible generated `--before` install filter when raw npm `min-release-age` config is present. (#85491) Thanks @TurboTheTurtle.
|
||||
- Agents/MCP: bound bundled MCP `tools/list` catalog discovery so hung MCP servers do not block session tool materialization. (#85063) Thanks @nxmxbbd.
|
||||
- Scripts: run generated-module formatting through the shared pnpm launcher and Windows CI coverage so native Windows generator checks avoid shell-mode package-manager shims.
|
||||
- Channels/iMessage: recover malformed anchorless group watch payloads by GUID before debounce/routing, and drop unrecoverable payloads instead of replying to the sender DM. Fixes #84470. Refs #84503. Thanks @zhangguiping-xydt and @zqchris.
|
||||
- Channels/iMessage: advance the startup catchup cursor from live-handled rows after a completed catchup pass, including rows received while catchup is still running, so restarts do not replay them. (#85475) Thanks @TurboTheTurtle.
|
||||
- Tests: mount the shared Windows command helper into bare Docker E2E harness containers so published upgrade-survivor config walks can start on Linux.
|
||||
@@ -47,7 +176,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Tests: keep the Telegram user credential helper on platform temp and path APIs so native Windows credential export and restore commands do not write through POSIX-only paths.
|
||||
- Installer: include the optional verify phase in the progress counter so `--verify` shows `[4/4] Verifying installation` instead of `[4/3]`.
|
||||
- Crabbox: let the wrapper find a sibling Crabbox checkout from linked Git worktrees so Codex worktrees can run remote gates without a PATH shim.
|
||||
- Scripts: tolerate the standard `--` option separator in shared script flag parsing so perf/test helpers accept package-manager argument forwarding.
|
||||
- CI: tolerate the standard `--` option separator in shared helper flag parsing so perf and test commands accept package-manager argument forwarding.
|
||||
- Tests: preserve `--` passthrough arguments in live-media, live-shard, and extension batch harnesses so Vitest filters are not misread or silently ignored.
|
||||
- Crabbox: default AWS macOS runner requests to on-demand capacity so EC2 Mac proof commands do not fail on the unsupported Spot market default.
|
||||
- Tests: run upgrade-survivor config recipe commands through the Windows npm shim so native Windows package walks keep baseline config coverage.
|
||||
@@ -63,7 +192,9 @@ Docs: https://docs.openclaw.ai
|
||||
- Crabbox: install Corepack shims into the writable hydration `PNPM_HOME` so local AWS runner hydration no longer tries to overwrite `/usr/local/bin/pnpm`.
|
||||
- Live tests: fail Gateway live model sweeps when selected coverage is lost to timeouts or stale high-signal filters instead of reporting false missing-profile coverage, and pin Docker OpenAI gateway coverage to the current `gpt-5.5` lane.
|
||||
- Tests: fail Docker resource-ceiling checks when stats samples or configured limits are invalid instead of silently reporting zero peaks.
|
||||
- Auth/Codex: emit a one-shot actionable `log.warn` from the embedded legacy Codex OAuth sidecar loader when the only available seed lives in the macOS Keychain, naming `openclaw doctor --fix` and macOS Keychain instead of letting the credential silently fall through to a downstream `No API key found for provider "openai-codex"`. Thanks @romneyda.
|
||||
- Agents: fail closed when provider-less session models match multiple provider-prefixed runtime policies so CLI runtime routing no longer depends on config order. (#85970) Thanks @potterdigital.
|
||||
- Control UI/agents: keep collapsed tool rows readable without early ellipses, preserve raw expanded tool details, and make post-compaction AGENTS.md reinjection opt-in to avoid duplicated project context. Fixes #45649 and #45488. Thanks @BunsDev.
|
||||
|
||||
## 2026.5.24
|
||||
|
||||
@@ -110,6 +241,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway/plugins: reuse a compatible Gateway startup plugin registry during dispatch so safe plugin dispatches avoid redundant registry loading. (#84324) Thanks @ai-hpc.
|
||||
- Plugins/SDK: add a general `embeddingProviders` capability contract and registration API so embeddings can become a reusable provider surface outside memory-specific adapters.
|
||||
- Dependencies: refresh provider, plugin, UI, and tooling packages, update `protobufjs` to 8.4.0 to clear the current npm advisory, and carry the Claude ACP completion patch forward to `@agentclientprotocol/claude-agent-acp` 0.36.1.
|
||||
- ACPX: bump the bundled ACP backend to `acpx` 0.10.0 for session export/import support.
|
||||
- Agents/tools: remove the old sender-owner tool gating path so configured tools stay visible for trusted sessions while command and channel-action auth still carry real sender identity.
|
||||
- QA-Lab: add curated mock JSONL replay fixtures and first-drift reporting for runtime-parity audits. (#80323, refs #80176) Thanks @100yenadmin.
|
||||
- QA-Lab: add a QA bus tool-trace visibility scenario for sanitized tool-call assertions.
|
||||
@@ -129,6 +261,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- CLI/update: allow package-manager-managed hardlinked package roots during global update swaps while keeping generic plugin, hook, and dependency-free install moves fail-closed. (#85569) Thanks @ai-hpc.
|
||||
- Gateway/update: avoid fetching unrelated tags during dev-channel git updates so moved release tags do not block branch-based updates. (#84737) Thanks @rubencu.
|
||||
- CLI/update: suppress the expected future-config warning while an old update parent hands off to the freshly installed post-core process.
|
||||
- MiniMax: store OAuth token expiry as an absolute millisecond timestamp so OAuth profiles no longer appear expired on every request. (#83480) Thanks @NianJiuZst.
|
||||
@@ -246,7 +379,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway/agents: preserve fresh session overrides and metadata when stale cached agent-session entries race with store updates, so subagent model/provider overrides and routing policy survive concurrent writes. (#19328) Thanks @CodeReclaimers.
|
||||
- Control UI/chat: keep chat session search inline with the session selector so the header no longer shows a duplicate standalone search row.
|
||||
- Control UI/chat: collapse focused-mode header chrome and suppress hidden-header scroll updates so focus mode no longer jumps while scrolling. Thanks @amknight.
|
||||
- Codex app-server: restart the native app-server and retry once when server-side compaction times out, so preflight compaction stalls recover instead of failing every dispatch. (#85500)
|
||||
- Codex app-server: leave automatic compaction to native Codex, drop OpenClaw preflight/CLI/context-engine forced compaction for Codex runtime sessions, and still forward explicit `/compact` or plugin compaction requests into Codex while failing native compaction honestly. (#85500)
|
||||
- Restore Control UI gateway token pairing [AI]. (#85459) Thanks @pgondhi987.
|
||||
- OpenAI video: honor configured provider request private-network opt-in for local/custom video endpoints so explicitly trusted mock and self-hosted providers are not blocked. Thanks @shakkernerd.
|
||||
- OpenAI video: send uploaded video edit requests to the documented `/videos/edits` endpoint with a `video` file instead of posting MP4 references to `/videos`. Thanks @shakkernerd.
|
||||
@@ -331,7 +464,7 @@ Docs: https://docs.openclaw.ai
|
||||
- fix: constrain Windows task script names [AI]. (#85064) Thanks @pgondhi987.
|
||||
- Control UI: keep the chat session picker from hiding older or cross-agent configured conversations while preserving the bounded configured-agent refresh. (#85211) Thanks @amknight.
|
||||
- Agents/Anthropic: preserve unsafe integer tool-call input values in streamed Anthropic tool-use JSON, preventing Discord-style IDs from being rounded before dispatch. Fixes #47229. (#83063) Thanks @leno23.
|
||||
- Agents/Codex: estimate tool-heavy prompt pressure at the LLM boundary before provider submission, so persistent sessions compact before overflowing context windows. (#85541) Thanks @fuller-stack-dev and @joshavant.
|
||||
- Agents: estimate tool-heavy prompt pressure at the LLM boundary before provider submission for non-Codex embedded runtimes, so persistent PI-style sessions compact before overflowing context windows. (#85541) Thanks @fuller-stack-dev and @joshavant.
|
||||
- Agents/hooks: wait for local one-shot CLI and Codex `agent_end` plugin hooks before process cleanup so terminal observability flushes reliably. (#85007)
|
||||
- Providers/Google: preserve Gemini 3 cron `thinkingDefault: "low"` when stale catalog metadata says `reasoning:false`, so scheduled runs keep provider-supported thinking instead of downgrading to off. (#85185) Thanks @neeravmakwana.
|
||||
- CLI/agents: allow `openclaw agent --session-key` to target explicit session keys, including agent-scoped legacy keys. (#85121) Thanks @Kaspre.
|
||||
@@ -431,6 +564,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Control UI/WebChat: keep selected external-channel sessions live by mirroring Codex prompts at turn start, streaming hidden runs only to exact selected-session subscribers, and deduplicating accumulated stream snapshots around tool cards. Fixes #83528, #82611, refs #83949. Thanks @BunsDev.
|
||||
- CLI/tasks: include stale-running task maintenance decisions in `openclaw tasks maintenance --json` so retained and reconcile candidates explain backing-session, cron, CLI, and wedged-subagent state. (#84691) Thanks @efpiva.
|
||||
- Codex app-server: keep system-prompt reports working when bootstrap hooks provide workspace files with only a path and content, so hook-supplied SOUL/IDENTITY/TOOLS/USER context still reports injected characters correctly. (#84736) Thanks @JARVIS-Glasses.
|
||||
- Providers/MiniMax music: stop advertising `durationSeconds` control and remove prompt-injected duration hints, so `music_generate` reports MiniMax duration as an unsupported override instead of suggesting MiniMax can enforce track length. Fixes #84508. Thanks @neeravmakwana.
|
||||
@@ -567,6 +701,7 @@ Docs: https://docs.openclaw.ai
|
||||
- CLI: reject explicit port numbers above 65535 before they reach Gateway or Node bind paths. Fixes #83900. (#84008) Thanks @hclsys.
|
||||
- Codex app-server: preserve plugin tool auth profiles when Codex owns model transport so OpenClaw dynamic tools can resolve their provider credentials. (#83603) Thanks @rubencu.
|
||||
- Memory/search: scan the JS-side fallback vector path (used when the sqlite-vec index is unavailable or has a mismatched dimension) in bounded rowid batches and yield to the event loop between batches so large chunk tables can no longer pin the Node.js main thread for multi-second windows. Also keeps the SQL prepared statement rooted in a local so node:sqlite cannot finalize it mid-scan under heap pressure. Fixes #81172. Thanks @dev23xyz-oss.
|
||||
- Telegram: preserve inbound bold, italic, code, preformatted, strikethrough, underline, spoiler, and text-link entities as markdown in the agent-facing prompt body. Fixes #52859.
|
||||
- Backup: dereference hardlinks during archive creation and reject unsafe hardlink targets during verification so archives that pass `backup verify` do not fail broad extraction on macOS tar. Fixes #54242. Thanks @jason-allen-oneal.
|
||||
- Memory Wiki: preserve fs-safe diagnostics when bridge source page writes fail for non-symlink filesystem safety reasons, so directory collisions are reported with the underlying error code. (#83776) Thanks @TurboTheTurtle.
|
||||
- Telegram: keep forum topics from blocking sibling topic traffic by routing inbound serialization, media/text buffers, and account API queues on topic-aware lanes. (#83829)
|
||||
@@ -2038,6 +2173,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Telegram/groups: include the recent local chat window and nearby reply-target window as generic inbound context so stale reply ancestry does not overshadow the live group conversation.
|
||||
- Plugins/Nix: allow externally configured plugin roots under `/nix/store` to load in `OPENCLAW_NIX_MODE=1` while keeping normal external plugin hardlink rejection unchanged. Thanks @joshp123.
|
||||
- Nextcloud Talk: include the required bot `response` feature in setup, explain missing `--feature response` on rejected sends, and surface missing response capability in doctor/status checks. Fixes #78935. (#79657) Thanks @joshavant.
|
||||
- Cron/diagnostics: emit the existing `message.queued`, `session.state` (processing/idle), and `message.processed` lifecycle events for isolated-cron agent turns in `runCronIsolatedAgentTurn`, matching the dispatch and embedded-runner paths so subscribers (diagnostics OTLP, OTel exporters, custom observability plugins) get per-run session attribution instead of bucketing isolated cron LLM calls under static fallback ids. Events are gated on `isDiagnosticsEnabled(cfg)` so the documented `diagnostics.enabled: false` master toggle continues to silence the recorder. (#79214) Thanks @arniesaha.
|
||||
- fix(discord): gate user allowlist name resolution [AI]. (#79002) Thanks @pgondhi987.
|
||||
- fix(msteams): gate startup user allowlist resolution [AI]. (#79003) Thanks @pgondhi987.
|
||||
- Infra/fetch-timeout: pass `operation` and `url` context to `buildTimeoutAbortSignal` from the music-generate reference fetch and the Matrix guarded redirect transport, so the `fetch timeout reached; aborting operation` warning carries actionable structured fields instead of a bare line. Fixes #79195. Thanks @pandadev66.
|
||||
|
||||
@@ -107,6 +107,7 @@ For coordinated change sets that genuinely need more than 20 PRs, join the **#cl
|
||||
|
||||
- Test locally with your OpenClaw instance
|
||||
- External PRs must include a filled **Real behavior proof** section in the PR body. Show the real setup you tested, the exact command or steps you ran after the patch, after-fix evidence, the observed result, and anything you did not test. Screenshots, recordings, terminal screenshots, console output, copied live output, linked artifacts, and redacted runtime logs all count. Unit tests, mocks, snapshots, lint, typechecks, and CI are useful but do not satisfy this requirement by themselves. Maintainers may apply `proof: override` only when the proof gate should not apply.
|
||||
- Keep PRs takeover-ready: open them from a branch maintainers can push to. For fork PRs, leave GitHub's **Allow edits by maintainers** option enabled so maintainers can finish urgent fixes, changelog entries, or merge prep when needed. If GitHub shows **Allow edits and access to secrets by maintainers**, enable it only when that workflow/secrets access is acceptable and say so in the PR.
|
||||
- Do not edit `CHANGELOG.md` in contributor PRs. Maintainers or ClawSweeper add the changelog entry when landing user-facing changes.
|
||||
- Run tests: `pnpm build && pnpm check && pnpm test`
|
||||
- For iterative local commits, `scripts/committer --fast "message" <files...>` passes `FAST_COMMIT=1` through to the pre-commit hook so it skips the repo-wide `pnpm check`. Only use it when you've already run equivalent targeted validation for the touched surface.
|
||||
|
||||
@@ -287,12 +287,17 @@ RUN ln -sf /app/openclaw.mjs /usr/local/bin/openclaw \
|
||||
|
||||
# Pre-create default named-volume mount points so first-run Docker volumes copy
|
||||
# node ownership from the image instead of starting as root-owned directories.
|
||||
RUN install -d -m 0700 -o node -g node \
|
||||
# NOTE: /home/node/.config must be created with node ownership first so that
|
||||
# the leaf /home/node/.config/openclaw inherits the correct parent permissions.
|
||||
# Without this, install -d leaves /home/node/.config as root:root (issue #85968).
|
||||
RUN install -d -m 0755 -o node -g node /home/node/.config && \
|
||||
install -d -m 0700 -o node -g node \
|
||||
/home/node/.openclaw \
|
||||
/home/node/.openclaw/workspace \
|
||||
/home/node/.config/openclaw && \
|
||||
stat -c '%U:%G %a' /home/node/.openclaw | grep -qx 'node:node 700' && \
|
||||
stat -c '%U:%G %a' /home/node/.openclaw/workspace | grep -qx 'node:node 700' && \
|
||||
stat -c '%U:%G %a' /home/node/.config | grep -qx 'node:node 755' && \
|
||||
stat -c '%U:%G %a' /home/node/.config/openclaw | grep -qx 'node:node 700'
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
@@ -98,7 +98,7 @@ These are frequently reported but are typically closed with no code change:
|
||||
- Reports that treat `POST /tools/invoke` under shared-secret bearer auth (`gateway.auth.mode="token"` or `"password"`) as a narrower per-request/per-scope authorization surface. That endpoint is designed as the same trusted-operator HTTP boundary: shared-secret bearer auth is full operator access there, narrower `x-openclaw-scopes` values do not reduce that path, and owner-only tool policy follows the shared-secret operator contract.
|
||||
- Reports that only show differences in heuristic detection/parity (for example obfuscation-pattern detection on one exec path but not another, such as `node.invoke -> system.run` parity gaps) without demonstrating bypass of auth, approvals, allowlist enforcement, sandboxing, or other documented trust boundaries.
|
||||
- Reports that only show an ACP tool can indirectly execute, mutate, orchestrate sessions, or reach another tool/runtime without demonstrating bypass of ACP prompt/approval, allowlist enforcement, sandboxing, or another documented trust boundary. ACP silent approval is intentionally limited to narrow readonly classes; parity-only indirect-command findings are hardening, not vulnerabilities.
|
||||
- Reports that only show untrusted media bytes reaching a maintained native decoder dependency (for example Sharp/libvips/libheif) without proving the shipped dependency version is vulnerable and demonstrating crash, memory corruption, data exposure, or a boundary bypass through OpenClaw. JavaScript header sniffing and image dimension fast-paths are preflight/UX checks, not the security boundary for native decoder correctness.
|
||||
- Reports that only show untrusted media bytes reaching a maintained native decoder dependency (for example image codec libraries such as libheif) without proving the shipped dependency version is vulnerable and demonstrating crash, memory corruption, data exposure, or a boundary bypass through OpenClaw. JavaScript header sniffing and image dimension fast-paths are preflight/UX checks, not the security boundary for native decoder correctness.
|
||||
- Reports whose only impact is transient extra memory, CPU, or allocation work from decoding, base64 expansion, media transcoding, serialization, or other format conversion after the input was already accepted under OpenClaw's configured size/trust limits, including base64 decode-before-size-estimate findings. These are performance issues, not vulnerabilities, unless the report demonstrates unauthenticated amplification, bypass of configured limits, crash/process termination, persistent resource exhaustion, data exposure, or another documented boundary bypass.
|
||||
- ReDoS/DoS claims that require trusted operator configuration input (for example catastrophic regex in `sessionFilter` or `logging.redactPatterns`) without a trust-boundary bypass.
|
||||
- Archive/install extraction claims that require pre-existing local filesystem priming in trusted state (for example planting symlink/hardlink aliases under destination directories such as skills/tools paths) without showing an untrusted path that can create/control that primitive.
|
||||
|
||||
@@ -38,6 +38,15 @@ Contribution rules:
|
||||
- Do not open large batches of tiny PRs at once; each PR has review cost.
|
||||
- For very small related fixes, grouping into one focused PR is encouraged.
|
||||
|
||||
Configuration compatibility:
|
||||
|
||||
OpenClaw runtime code reads the current configuration schema only.
|
||||
We do not keep long-lived aliases or compatibility branches that silently accept old, renamed, or malformed config keys.
|
||||
|
||||
When a config change makes existing user config invalid, the same change needs a doctor migration.
|
||||
`openclaw doctor --fix` should detect the old shape, explain it, back it up when needed, and rewrite it to the canonical format.
|
||||
Core-owned config and auth state are repaired in core doctor code; plugin-owned config is repaired by that plugin's doctor contract.
|
||||
|
||||
## Security
|
||||
|
||||
Security in OpenClaw is a deliberate tradeoff: strong defaults without killing capability.
|
||||
|
||||
@@ -65,8 +65,8 @@ android {
|
||||
applicationId = "ai.openclaw.app"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 2026052500
|
||||
versionName = "2026.5.25"
|
||||
versionCode = 2026052601
|
||||
versionName = "2026.5.26"
|
||||
ndk {
|
||||
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
|
||||
14
apps/android/app/src/debug/AndroidManifest.xml
Normal file
14
apps/android/app/src/debug/AndroidManifest.xml
Normal file
@@ -0,0 +1,14 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application>
|
||||
<receiver
|
||||
android:name=".VoiceE2eReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="ai.openclaw.app.debug.RUN_VOICE_E2E" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<service
|
||||
android:name=".VoiceE2eService"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,195 @@
|
||||
package ai.openclaw.app
|
||||
|
||||
import android.app.Service
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import java.io.File
|
||||
|
||||
private const val tag = "VoiceE2E"
|
||||
private const val resultFileName = "voice_e2e_result.json"
|
||||
|
||||
class VoiceE2eReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(
|
||||
context: Context,
|
||||
intent: Intent,
|
||||
) {
|
||||
context.startService(
|
||||
Intent(context, VoiceE2eService::class.java)
|
||||
.putExtras(intent),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class VoiceE2eService : Service() {
|
||||
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(
|
||||
intent: Intent?,
|
||||
flags: Int,
|
||||
startId: Int,
|
||||
): Int {
|
||||
val command = intent ?: return START_NOT_STICKY
|
||||
serviceScope.launch {
|
||||
try {
|
||||
runCommand(command)
|
||||
} finally {
|
||||
stopSelf(startId)
|
||||
}
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
serviceScope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private suspend fun runCommand(intent: Intent) {
|
||||
try {
|
||||
val app = applicationContext as NodeApp
|
||||
val runtime = app.ensureRuntime()
|
||||
val mode =
|
||||
intent
|
||||
.getDecodedStringExtra("mode")
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
.ifEmpty { "both" }
|
||||
if (mode == "stop") {
|
||||
runtime.cancelMicCapture()
|
||||
runtime.setTalkModeEnabled(false)
|
||||
writeResult("""{"ok":true,"mode":"stop"}""")
|
||||
return
|
||||
}
|
||||
|
||||
val connect = !intent.getBooleanExtra("noConnect", false)
|
||||
val connectTimeoutMs = intent.getLongExtra("connectTimeoutMs", 20_000L)
|
||||
if (connect) {
|
||||
configureGateway(runtime = runtime, intent = intent)
|
||||
}
|
||||
if (connect || !runtime.isConnected.value) {
|
||||
awaitGateway(runtime = runtime, timeoutMs = connectTimeoutMs)
|
||||
}
|
||||
|
||||
startActivity(
|
||||
Intent(actionOpenVoiceE2e)
|
||||
.setClass(this, MainActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP),
|
||||
)
|
||||
|
||||
if (mode == "connect") {
|
||||
val resultJson = """{"ok":true,"mode":"connect","connected":true}"""
|
||||
writeResult(resultJson)
|
||||
Log.i(tag, "PASS $resultJson")
|
||||
return
|
||||
}
|
||||
|
||||
val transcript =
|
||||
intent
|
||||
.getDecodedStringExtra("transcript")
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
.ifEmpty { "Reply exactly: Android voice e2e normal path ok." }
|
||||
val realtimeReply =
|
||||
intent
|
||||
.getDecodedStringExtra("realtimeAssistant")
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
.ifEmpty { "Android realtime voice e2e relay path ok." }
|
||||
val timeoutMs = intent.getLongExtra("timeoutMs", 60_000L)
|
||||
val result =
|
||||
runtime.runVoiceE2e(
|
||||
mode = mode,
|
||||
transcript = transcript,
|
||||
realtimeAssistantText = realtimeReply,
|
||||
timeoutMs = timeoutMs,
|
||||
)
|
||||
val resultJson = encodeResult(result)
|
||||
writeResult(resultJson)
|
||||
Log.i(tag, "PASS $resultJson")
|
||||
} catch (err: Throwable) {
|
||||
val resultJson =
|
||||
buildJsonObject {
|
||||
put("ok", JsonPrimitive(false))
|
||||
put("error", JsonPrimitive(err.message ?: err::class.java.simpleName))
|
||||
}.toString()
|
||||
writeResult(resultJson)
|
||||
Log.e(tag, "FAIL $resultJson", err)
|
||||
}
|
||||
}
|
||||
|
||||
private fun configureGateway(
|
||||
runtime: NodeRuntime,
|
||||
intent: Intent,
|
||||
) {
|
||||
val host =
|
||||
intent
|
||||
.getDecodedStringExtra("host")
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
.ifEmpty { "127.0.0.1" }
|
||||
val port = intent.getIntExtra("port", 18789)
|
||||
runtime.setManualEnabled(true)
|
||||
runtime.setManualHost(host)
|
||||
runtime.setManualPort(port)
|
||||
runtime.setManualTls(intent.getBooleanExtra("tls", false))
|
||||
runtime.setGatewayToken(intent.getDecodedStringExtra("token").orEmpty())
|
||||
runtime.setGatewayBootstrapToken(intent.getDecodedStringExtra("bootstrapToken").orEmpty())
|
||||
runtime.setGatewayPassword(intent.getDecodedStringExtra("password").orEmpty())
|
||||
runtime.setOnboardingCompleted(true)
|
||||
runtime.connectManual()
|
||||
}
|
||||
|
||||
private suspend fun awaitGateway(
|
||||
runtime: NodeRuntime,
|
||||
timeoutMs: Long,
|
||||
) {
|
||||
withTimeout(timeoutMs) {
|
||||
while (!runtime.isConnected.value) {
|
||||
delay(100L)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun encodeResult(result: NodeRuntime.VoiceE2eResult): String =
|
||||
buildJsonObject {
|
||||
put("ok", JsonPrimitive(true))
|
||||
put("normal", result.normal?.let(::encodeSlice) ?: JsonNull)
|
||||
put("realtime", result.realtime?.let(::encodeSlice) ?: JsonNull)
|
||||
}.toString()
|
||||
|
||||
private fun encodeSlice(slice: NodeRuntime.VoiceE2eSliceResult) =
|
||||
buildJsonObject {
|
||||
put("mode", JsonPrimitive(slice.mode))
|
||||
put("status", JsonPrimitive(slice.status))
|
||||
put("userText", slice.userText?.let(::JsonPrimitive) ?: JsonNull)
|
||||
put("assistantText", slice.assistantText?.let(::JsonPrimitive) ?: JsonNull)
|
||||
}
|
||||
|
||||
private fun writeResult(json: String) {
|
||||
File(cacheDir, resultFileName).writeText(json)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Intent.getDecodedStringExtra(name: String): String? {
|
||||
val encoded = getStringExtra("${name}Base64")
|
||||
if (!encoded.isNullOrBlank()) {
|
||||
return String(Base64.decode(encoded, Base64.NO_WRAP), Charsets.UTF_8)
|
||||
}
|
||||
return getStringExtra(name)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package ai.openclaw.app
|
||||
import android.content.Intent
|
||||
|
||||
const val actionAskOpenClaw = "ai.openclaw.app.action.ASK_OPENCLAW"
|
||||
const val actionOpenVoiceE2e = "ai.openclaw.app.debug.OPEN_VOICE_E2E"
|
||||
const val extraAssistantPrompt = "prompt"
|
||||
|
||||
enum class HomeDestination {
|
||||
@@ -19,6 +20,14 @@ data class AssistantLaunchRequest(
|
||||
val autoSend: Boolean,
|
||||
)
|
||||
|
||||
fun parseHomeDestinationIntent(intent: Intent?): HomeDestination? {
|
||||
val action = intent?.action ?: return null
|
||||
return when {
|
||||
BuildConfig.DEBUG && action == actionOpenVoiceE2e -> HomeDestination.Voice
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun parseAssistantLaunchIntent(intent: Intent?): AssistantLaunchRequest? {
|
||||
val action = intent?.action ?: return null
|
||||
return when (action) {
|
||||
|
||||
@@ -79,6 +79,10 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
private fun handleAssistantIntent(intent: android.content.Intent?) {
|
||||
parseHomeDestinationIntent(intent)?.let { destination ->
|
||||
viewModel.requestHomeDestination(destination)
|
||||
return
|
||||
}
|
||||
val request = parseAssistantLaunchIntent(intent) ?: return
|
||||
viewModel.handleAssistantLaunch(request)
|
||||
}
|
||||
|
||||
@@ -330,6 +330,10 @@ class MainViewModel(
|
||||
_requestedHomeDestination.value = null
|
||||
}
|
||||
|
||||
fun requestHomeDestination(destination: HomeDestination) {
|
||||
_requestedHomeDestination.value = destination
|
||||
}
|
||||
|
||||
fun clearChatDraft() {
|
||||
_chatDraft.value = null
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ import ai.openclaw.app.protocol.OpenClawCanvasA2UIAction
|
||||
import ai.openclaw.app.voice.MicCaptureManager
|
||||
import ai.openclaw.app.voice.TalkModeManager
|
||||
import ai.openclaw.app.voice.VoiceConversationEntry
|
||||
import ai.openclaw.app.voice.VoiceConversationRole
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
@@ -64,6 +65,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
@@ -256,6 +258,18 @@ class NodeRuntime(
|
||||
val previousFingerprintSha256: String? = null,
|
||||
)
|
||||
|
||||
data class VoiceE2eSliceResult(
|
||||
val mode: String,
|
||||
val status: String,
|
||||
val userText: String?,
|
||||
val assistantText: String?,
|
||||
)
|
||||
|
||||
data class VoiceE2eResult(
|
||||
val normal: VoiceE2eSliceResult?,
|
||||
val realtime: VoiceE2eSliceResult?,
|
||||
)
|
||||
|
||||
private val _isConnected = MutableStateFlow(false)
|
||||
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
|
||||
private val _nodeConnected = MutableStateFlow(false)
|
||||
@@ -501,7 +515,7 @@ class NodeRuntime(
|
||||
context = appContext,
|
||||
scope = scope,
|
||||
session = operatorSession,
|
||||
isConnected = { operatorConnected },
|
||||
isConnected = { _isConnected.value },
|
||||
onBeforeSpeak = { micCapture.pauseForTts() },
|
||||
onAfterSpeak = { micCapture.resumeAfterTts() },
|
||||
).also { speaker ->
|
||||
@@ -608,7 +622,7 @@ class NodeRuntime(
|
||||
context = appContext,
|
||||
scope = scope,
|
||||
session = operatorSession,
|
||||
isConnected = { operatorConnected },
|
||||
isConnected = { _isConnected.value },
|
||||
onBeforeSpeak = { micCapture.pauseForTts() },
|
||||
onAfterSpeak = { micCapture.resumeAfterTts() },
|
||||
onStoppedByRelay = { finishTalkModeAfterRelayClose() },
|
||||
@@ -1187,6 +1201,115 @@ class NodeRuntime(
|
||||
talkMode.setPlaybackEnabled(value)
|
||||
}
|
||||
|
||||
suspend fun runVoiceE2e(
|
||||
mode: String,
|
||||
transcript: String,
|
||||
realtimeAssistantText: String,
|
||||
timeoutMs: Long,
|
||||
): VoiceE2eResult {
|
||||
if (!BuildConfig.DEBUG) {
|
||||
throw IllegalStateException("voice e2e is debug-only")
|
||||
}
|
||||
if (!_isConnected.value) {
|
||||
throw IllegalStateException("gateway not connected")
|
||||
}
|
||||
if (!hasRecordAudioPermission()) {
|
||||
throw IllegalStateException("microphone permission missing")
|
||||
}
|
||||
|
||||
val normalizedMode = mode.trim().lowercase().ifEmpty { "both" }
|
||||
val runNormal = normalizedMode == "both" || normalizedMode == "normal" || normalizedMode == "dictation"
|
||||
val runRealtime = normalizedMode == "both" || normalizedMode == "realtime" || normalizedMode == "talk"
|
||||
if (!runNormal && !runRealtime) {
|
||||
throw IllegalArgumentException("unknown voice e2e mode: $mode")
|
||||
}
|
||||
|
||||
val previousSpeakerEnabled = speakerEnabled.value
|
||||
setSpeakerEnabled(false)
|
||||
var completed = false
|
||||
return try {
|
||||
VoiceE2eResult(
|
||||
normal =
|
||||
if (runNormal) {
|
||||
runNormalVoiceE2e(transcript = transcript, timeoutMs = timeoutMs)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
realtime =
|
||||
if (runRealtime) {
|
||||
runRealtimeVoiceE2e(
|
||||
transcript = transcript,
|
||||
assistantText = realtimeAssistantText,
|
||||
timeoutMs = timeoutMs,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
).also { completed = true }
|
||||
} finally {
|
||||
if (!completed) {
|
||||
stopActiveVoiceSession()
|
||||
}
|
||||
setSpeakerEnabled(previousSpeakerEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runNormalVoiceE2e(
|
||||
transcript: String,
|
||||
timeoutMs: Long,
|
||||
): VoiceE2eSliceResult {
|
||||
stopActiveVoiceSession()
|
||||
setVoiceCaptureMode(VoiceCaptureMode.ManualMic)
|
||||
micCapture.submitTranscribedMessage(transcript)
|
||||
awaitVoiceConversation(timeoutMs = timeoutMs) {
|
||||
micCapture.conversation.value.any { it.role == VoiceConversationRole.Assistant && !it.isStreaming }
|
||||
}
|
||||
val entries = micCapture.conversation.value
|
||||
return VoiceE2eSliceResult(
|
||||
mode = "normal",
|
||||
status = micCapture.statusText.value,
|
||||
userText = entries.lastOrNull { it.role == VoiceConversationRole.User }?.text,
|
||||
assistantText = entries.lastOrNull { it.role == VoiceConversationRole.Assistant }?.text,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun runRealtimeVoiceE2e(
|
||||
transcript: String,
|
||||
assistantText: String,
|
||||
timeoutMs: Long,
|
||||
): VoiceE2eSliceResult {
|
||||
stopActiveVoiceSession()
|
||||
setVoiceCaptureMode(VoiceCaptureMode.TalkMode)
|
||||
talkMode.runE2eRealtimeTurn(
|
||||
userText = transcript,
|
||||
assistantText = assistantText,
|
||||
timeoutMs = timeoutMs,
|
||||
)
|
||||
awaitVoiceConversation(timeoutMs = timeoutMs) {
|
||||
val entries = talkMode.conversation.value
|
||||
entries.any { it.role == VoiceConversationRole.User && !it.isStreaming } &&
|
||||
entries.any { it.role == VoiceConversationRole.Assistant && !it.isStreaming }
|
||||
}
|
||||
val entries = talkMode.conversation.value
|
||||
return VoiceE2eSliceResult(
|
||||
mode = "realtime",
|
||||
status = talkMode.statusText.value,
|
||||
userText = entries.lastOrNull { it.role == VoiceConversationRole.User }?.text,
|
||||
assistantText = entries.lastOrNull { it.role == VoiceConversationRole.Assistant }?.text,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun awaitVoiceConversation(
|
||||
timeoutMs: Long,
|
||||
ready: () -> Boolean,
|
||||
) {
|
||||
withTimeout(timeoutMs) {
|
||||
while (!ready()) {
|
||||
delay(100L)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setVoiceCaptureMode(
|
||||
mode: VoiceCaptureMode,
|
||||
persistManualMic: Boolean = true,
|
||||
|
||||
@@ -262,6 +262,11 @@ class MicCaptureManager(
|
||||
}
|
||||
}
|
||||
|
||||
internal fun submitTranscribedMessage(text: String) {
|
||||
queueRecognizedMessage(text)
|
||||
sendQueuedIfIdle()
|
||||
}
|
||||
|
||||
fun handleGatewayEvent(
|
||||
event: String,
|
||||
payloadJson: String?,
|
||||
@@ -701,8 +706,7 @@ class MicCaptureManager(
|
||||
val text = obj["text"].asStringOrNull()?.trim().orEmpty()
|
||||
if (text.isNotEmpty()) {
|
||||
if (text != flushedPartialTranscript) {
|
||||
queueRecognizedMessage(text)
|
||||
sendQueuedIfIdle()
|
||||
submitTranscribedMessage(text)
|
||||
} else {
|
||||
flushedPartialTranscript = null
|
||||
_liveTranscript.value = null
|
||||
|
||||
@@ -11,14 +11,8 @@ internal data class TalkModeGatewayConfigState(
|
||||
val mainSessionKey: String,
|
||||
val interruptOnSpeech: Boolean?,
|
||||
val silenceTimeoutMs: Long,
|
||||
val executionMode: TalkModeExecutionMode,
|
||||
)
|
||||
|
||||
internal enum class TalkModeExecutionMode {
|
||||
Native,
|
||||
RealtimeRelay,
|
||||
}
|
||||
|
||||
internal object TalkModeGatewayConfigParser {
|
||||
fun parse(config: JsonObject?): TalkModeGatewayConfigState {
|
||||
val talk = config?.get("talk").asObjectOrNull()
|
||||
@@ -27,22 +21,9 @@ internal object TalkModeGatewayConfigParser {
|
||||
mainSessionKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull()),
|
||||
interruptOnSpeech = talk?.get("interruptOnSpeech").asBooleanOrNull(),
|
||||
silenceTimeoutMs = resolvedSilenceTimeoutMs(talk),
|
||||
executionMode = resolvedExecutionMode(talk),
|
||||
)
|
||||
}
|
||||
|
||||
fun resolvedExecutionMode(talk: JsonObject?): TalkModeExecutionMode {
|
||||
val realtime = talk?.get("realtime").asObjectOrNull() ?: return TalkModeExecutionMode.Native
|
||||
val mode = realtime["mode"].asStringOrNull()
|
||||
val transport = realtime["transport"].asStringOrNull()
|
||||
val brain = realtime["brain"].asStringOrNull()
|
||||
return if (mode == "realtime" && transport == "gateway-relay" && (brain == null || brain == "agent-consult")) {
|
||||
TalkModeExecutionMode.RealtimeRelay
|
||||
} else {
|
||||
TalkModeExecutionMode.Native
|
||||
}
|
||||
}
|
||||
|
||||
fun resolvedSilenceTimeoutMs(talk: JsonObject?): Long {
|
||||
val fallback = TalkDefaults.defaultSilenceTimeoutMs
|
||||
val primitive = talk?.get("silenceTimeoutMs") as? JsonPrimitive ?: return fallback
|
||||
|
||||
@@ -157,7 +157,6 @@ class TalkModeManager internal constructor(
|
||||
private val completedRunStates = LinkedHashMap<String, Boolean>()
|
||||
private val completedRunTexts = LinkedHashMap<String, String>()
|
||||
private var configLoaded = false
|
||||
private var executionMode = TalkModeExecutionMode.Native
|
||||
private val startGeneration = AtomicLong(0L)
|
||||
|
||||
@Volatile private var realtimeSessionId: String? = null
|
||||
@@ -480,6 +479,19 @@ class TalkModeManager internal constructor(
|
||||
pendingRunId = null
|
||||
}
|
||||
|
||||
internal suspend fun runE2eRealtimeTurn(
|
||||
userText: String,
|
||||
assistantText: String,
|
||||
timeoutMs: Long,
|
||||
) {
|
||||
if (!_isEnabled.value) {
|
||||
setEnabled(true)
|
||||
}
|
||||
val sessionId = awaitRealtimeSessionId(timeoutMs)
|
||||
handleGatewayEvent("talk.event", realtimeTranscriptPayload(sessionId = sessionId, role = "user", text = userText))
|
||||
handleGatewayEvent("talk.event", realtimeTranscriptPayload(sessionId = sessionId, role = "assistant", text = assistantText))
|
||||
}
|
||||
|
||||
fun setPlaybackEnabled(enabled: Boolean) {
|
||||
if (playbackEnabled == enabled) return
|
||||
playbackEnabled = enabled
|
||||
@@ -513,53 +525,17 @@ class TalkModeManager internal constructor(
|
||||
try {
|
||||
ensureConfigLoaded()
|
||||
if (generation != startGeneration.get() || !_isEnabled.value || stopRequested) return@launch
|
||||
if (executionMode == TalkModeExecutionMode.RealtimeRelay) {
|
||||
startRealtimeRelay(generation)
|
||||
} else {
|
||||
startNativeRecognition(generation)
|
||||
}
|
||||
startRealtimeRelay(generation)
|
||||
} catch (err: Throwable) {
|
||||
if (err is CancellationException) return@launch
|
||||
_statusText.value = "Start failed: ${err.message ?: err::class.simpleName}"
|
||||
Log.w(tag, "start failed: ${err.message ?: err::class.simpleName}")
|
||||
if (executionMode == TalkModeExecutionMode.RealtimeRelay) {
|
||||
stopRealtimeRelay(closeSession = false, preserveStatus = true)
|
||||
disableRealtimeModeAndNotifyOwner()
|
||||
}
|
||||
stopRealtimeRelay(closeSession = false, preserveStatus = true)
|
||||
disableRealtimeModeAndNotifyOwner()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun startNativeRecognition(generation: Long) {
|
||||
withContext(Dispatchers.Main) {
|
||||
if (generation != startGeneration.get()) return@withContext
|
||||
if (!_isEnabled.value || stopRequested) return@withContext
|
||||
if (_isListening.value) return@withContext
|
||||
Log.d(tag, "start native")
|
||||
|
||||
if (!SpeechRecognizer.isRecognitionAvailable(context)) {
|
||||
_statusText.value = "Speech recognizer unavailable"
|
||||
Log.w(tag, "speech recognizer unavailable")
|
||||
return@withContext
|
||||
}
|
||||
|
||||
val micOk =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (!micOk) {
|
||||
_statusText.value = "Microphone permission required"
|
||||
Log.w(tag, "microphone permission required")
|
||||
return@withContext
|
||||
}
|
||||
|
||||
recognizer?.destroy()
|
||||
recognizer = SpeechRecognizer.createSpeechRecognizer(context).also { it.setRecognitionListener(listener) }
|
||||
startListeningInternal(markListening = true)
|
||||
startSilenceMonitor()
|
||||
Log.d(tag, "listening")
|
||||
}
|
||||
}
|
||||
|
||||
private fun stop() {
|
||||
stopRequested = true
|
||||
finalizeInFlight = false
|
||||
@@ -597,6 +573,19 @@ class TalkModeManager internal constructor(
|
||||
shutdownTextToSpeech()
|
||||
}
|
||||
|
||||
private suspend fun awaitRealtimeSessionId(timeoutMs: Long): String =
|
||||
withTimeout(timeoutMs) {
|
||||
while (true) {
|
||||
realtimeSessionId?.let { return@withTimeout it }
|
||||
val status = _statusText.value
|
||||
if (!_isEnabled.value && status != "Off") {
|
||||
throw IllegalStateException(status)
|
||||
}
|
||||
delay(100L)
|
||||
}
|
||||
error("unreachable")
|
||||
}
|
||||
|
||||
private suspend fun startRealtimeRelay(generation: Long) {
|
||||
if (!isConnected()) {
|
||||
_statusText.value = "Gateway not connected"
|
||||
@@ -852,6 +841,19 @@ class TalkModeManager internal constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun realtimeTranscriptPayload(
|
||||
sessionId: String,
|
||||
role: String,
|
||||
text: String,
|
||||
): String =
|
||||
buildJsonObject {
|
||||
put("relaySessionId", JsonPrimitive(sessionId))
|
||||
put("type", JsonPrimitive("transcript"))
|
||||
put("role", JsonPrimitive(role))
|
||||
put("text", JsonPrimitive(text))
|
||||
put("final", JsonPrimitive(true))
|
||||
}.toString()
|
||||
|
||||
private fun playRealtimeAudio(bytes: ByteArray) {
|
||||
if (!playbackEnabled || realtimeOutputSuppressed || bytes.isEmpty()) return
|
||||
val queue = ensureRealtimeAudioQueue()
|
||||
@@ -2182,11 +2184,9 @@ class TalkModeManager internal constructor(
|
||||
val parsed = TalkModeGatewayConfigParser.parse(root?.get("config").asObjectOrNull())
|
||||
silenceWindowMs = parsed.silenceTimeoutMs
|
||||
parsed.interruptOnSpeech?.let { interruptOnSpeech = it }
|
||||
executionMode = parsed.executionMode
|
||||
configLoaded = true
|
||||
} catch (_: Throwable) {
|
||||
silenceWindowMs = TalkDefaults.defaultSilenceTimeoutMs
|
||||
executionMode = TalkModeExecutionMode.Native
|
||||
configLoaded = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_MEDIA_IMAGES"
|
||||
tools:node="remove" />
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_MEDIA_VIDEO"
|
||||
tools:node="remove" />
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED"
|
||||
tools:node="remove" />
|
||||
|
||||
@@ -100,6 +100,40 @@ class MicCaptureManagerTest {
|
||||
assertEquals(emptyList<VoiceConversationEntry>(), manager.conversation.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun submittedTranscribedMessageUsesGatewayTurnPath() =
|
||||
runTest {
|
||||
val sentMessages = mutableListOf<String>()
|
||||
val manager =
|
||||
createManager(
|
||||
scope = this,
|
||||
sendToGateway = { message, onRunIdKnown ->
|
||||
sentMessages += message
|
||||
onRunIdKnown("run-voice-e2e")
|
||||
"run-voice-e2e"
|
||||
},
|
||||
)
|
||||
|
||||
manager.onGatewayConnectionChanged(true)
|
||||
manager.submitTranscribedMessage("voice e2e message")
|
||||
runCurrent()
|
||||
manager.handleGatewayEvent("chat", chatFinalPayload(runId = "run-voice-e2e", text = "voice e2e reply"))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(listOf("voice e2e message"), sentMessages)
|
||||
assertEquals(
|
||||
listOf(VoiceConversationRole.User, VoiceConversationRole.Assistant),
|
||||
manager.conversation.value.map { it.role },
|
||||
)
|
||||
assertEquals(
|
||||
"voice e2e reply",
|
||||
manager.conversation.value
|
||||
.last()
|
||||
.text,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pcm16FramesAreEncodedAsPcmuFrames() {
|
||||
val manager = createManager()
|
||||
|
||||
@@ -62,37 +62,4 @@ class TalkModeConfigParsingTest {
|
||||
TalkModeGatewayConfigParser.resolvedSilenceTimeoutMs(talk),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun defaultsToNativeTalkMode() {
|
||||
val talk =
|
||||
buildJsonObject {
|
||||
put("realtime", buildJsonObject { put("transport", "webrtc") })
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
TalkModeExecutionMode.Native,
|
||||
TalkModeGatewayConfigParser.resolvedExecutionMode(talk),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun usesRealtimeRelayWhenGatewayRelayIsConfigured() {
|
||||
val talk =
|
||||
buildJsonObject {
|
||||
put(
|
||||
"realtime",
|
||||
buildJsonObject {
|
||||
put("mode", "realtime")
|
||||
put("transport", "gateway-relay")
|
||||
put("brain", "agent-consult")
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
TalkModeExecutionMode.RealtimeRelay,
|
||||
TalkModeGatewayConfigParser.resolvedExecutionMode(talk),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.currentTime
|
||||
@@ -327,6 +328,28 @@ class TalkModeManagerTest {
|
||||
assertTrue(entries.none { it.isStreaming })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun e2eRealtimeTurnUsesRelayTranscriptPath() =
|
||||
runTest {
|
||||
val manager = createManager(scope = this)
|
||||
|
||||
setPrivateField(manager, "realtimeSessionId", "relay-1")
|
||||
setMutableStateFlow(manager, "_isEnabled", true)
|
||||
manager.runE2eRealtimeTurn(
|
||||
userText = "voice e2e user",
|
||||
assistantText = "voice e2e assistant",
|
||||
timeoutMs = 1_000L,
|
||||
)
|
||||
|
||||
val entries = manager.conversation.value
|
||||
assertEquals(2, entries.size)
|
||||
assertEquals(VoiceConversationRole.User, entries[0].role)
|
||||
assertEquals("voice e2e user", entries[0].text)
|
||||
assertEquals(VoiceConversationRole.Assistant, entries[1].role)
|
||||
assertEquals("voice e2e assistant", entries[1].text)
|
||||
assertTrue(entries.none { it.isStreaming })
|
||||
}
|
||||
|
||||
@Test
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun realtimeStartWithoutGatewayTurnsTalkOff() =
|
||||
@@ -339,7 +362,6 @@ class TalkModeManagerTest {
|
||||
onStoppedByRelay = { stoppedByRelay.set(true) },
|
||||
)
|
||||
|
||||
setPrivateField(manager, "executionMode", TalkModeExecutionMode.RealtimeRelay)
|
||||
setPrivateField(manager, "configLoaded", true)
|
||||
manager.setEnabled(true)
|
||||
advanceUntilIdle()
|
||||
@@ -483,6 +505,15 @@ class TalkModeManagerTest {
|
||||
return field.get(target)
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun <T> setMutableStateFlow(
|
||||
target: Any,
|
||||
name: String,
|
||||
value: T,
|
||||
) {
|
||||
(readPrivateField(target, name) as MutableStateFlow<T>).value = value
|
||||
}
|
||||
|
||||
private fun shouldAppendRealtimeCapturedFrame(
|
||||
manager: TalkModeManager,
|
||||
length: Int,
|
||||
|
||||
230
apps/android/scripts/voice-e2e.sh
Executable file
230
apps/android/scripts/voice-e2e.sh
Executable file
@@ -0,0 +1,230 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
||||
ANDROID_DIR="$ROOT_DIR/apps/android"
|
||||
PACKAGE_NAME="ai.openclaw.app"
|
||||
RECEIVER="$PACKAGE_NAME/.VoiceE2eReceiver"
|
||||
RUN_ACTION="ai.openclaw.app.debug.RUN_VOICE_E2E"
|
||||
OPEN_ACTION="ai.openclaw.app.debug.OPEN_VOICE_E2E"
|
||||
PORT=18789
|
||||
HOST="127.0.0.1"
|
||||
MODE="both"
|
||||
TRANSCRIPT="Reply exactly: Android voice e2e normal path ok."
|
||||
REALTIME_ASSISTANT="Android realtime voice e2e relay path ok."
|
||||
TIMEOUT_MS=60000
|
||||
INSTALL=1
|
||||
CONNECT=1
|
||||
CLEANUP=0
|
||||
START_GATEWAY=0
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: apps/android/scripts/voice-e2e.sh [options]
|
||||
|
||||
Options:
|
||||
--mode connect|normal|realtime|both
|
||||
Gateway probe or voice path to test. Default: both.
|
||||
--transcript TEXT Synthetic user transcript for the voice turn.
|
||||
--realtime-assistant TEXT Synthetic realtime assistant relay text.
|
||||
--host HOST Gateway host visible from Android. Default: 127.0.0.1.
|
||||
--port PORT Gateway port. Default: 18789.
|
||||
--timeout-ms MS Per-mode timeout. Default: 60000.
|
||||
--skip-install Reuse the installed debug app.
|
||||
--no-connect Do not rewrite manual gateway settings.
|
||||
--start-gateway Start a temporary local gateway with bws_get_secret.
|
||||
--cleanup Stop voice capture after screenshots.
|
||||
USAGE
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--mode)
|
||||
MODE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--transcript)
|
||||
TRANSCRIPT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--realtime-assistant)
|
||||
REALTIME_ASSISTANT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--host)
|
||||
HOST="$2"
|
||||
shift 2
|
||||
;;
|
||||
--port)
|
||||
PORT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--timeout-ms)
|
||||
TIMEOUT_MS="$2"
|
||||
shift 2
|
||||
;;
|
||||
--skip-install)
|
||||
INSTALL=0
|
||||
shift
|
||||
;;
|
||||
--no-connect)
|
||||
CONNECT=0
|
||||
shift
|
||||
;;
|
||||
--start-gateway)
|
||||
START_GATEWAY=1
|
||||
shift
|
||||
;;
|
||||
--cleanup)
|
||||
CLEANUP=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "unknown option: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
export JAVA_HOME="${JAVA_HOME:-/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home}"
|
||||
export ANDROID_HOME="${ANDROID_HOME:-/opt/homebrew/share/android-commandlinetools}"
|
||||
export ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-$ANDROID_HOME}"
|
||||
export PATH="/opt/homebrew/opt/openjdk@17/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/cmdline-tools/latest/bin:$PATH"
|
||||
|
||||
ARTIFACT_DIR="/tmp/openclaw-android-voice-e2e-$(date +%Y%m%d-%H%M%S)"
|
||||
mkdir -p "$ARTIFACT_DIR"
|
||||
|
||||
cleanup_gateway() {
|
||||
if [[ -n "${GATEWAY_PID:-}" ]]; then
|
||||
kill "$GATEWAY_PID" >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
trap cleanup_gateway EXIT
|
||||
|
||||
if ! adb devices -l | awk 'NR > 1 && $2 == "device" { found = 1 } END { exit(found ? 0 : 1) }'; then
|
||||
echo "no authorized Android device found" >&2
|
||||
adb devices -l >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
adb reverse "tcp:$PORT" "tcp:$PORT" >/dev/null
|
||||
|
||||
if [[ "$START_GATEWAY" -eq 1 ]]; then
|
||||
if command -v bws_get_secret >/dev/null 2>&1; then
|
||||
OPENCLAW_OPENAI_API_KEY="$(bws_get_secret OPENCLAW_OPENAI_API_KEY)"
|
||||
else
|
||||
OPENCLAW_OPENAI_API_KEY="$(zsh -ic 'bws_get_secret OPENCLAW_OPENAI_API_KEY')"
|
||||
fi
|
||||
(
|
||||
cd "$ROOT_DIR"
|
||||
OPENAI_API_KEY="$OPENCLAW_OPENAI_API_KEY" \
|
||||
pnpm openclaw gateway run \
|
||||
--port "$PORT" \
|
||||
--auth none \
|
||||
--bind loopback \
|
||||
--force \
|
||||
--allow-unconfigured \
|
||||
--ws-log compact
|
||||
) >"$ARTIFACT_DIR/gateway.log" 2>&1 &
|
||||
GATEWAY_PID=$!
|
||||
sleep 4
|
||||
if ! kill -0 "$GATEWAY_PID" >/dev/null 2>&1; then
|
||||
cat "$ARTIFACT_DIR/gateway.log" >&2
|
||||
exit 1
|
||||
fi
|
||||
unset OPENCLAW_OPENAI_API_KEY
|
||||
fi
|
||||
|
||||
if [[ "$INSTALL" -eq 1 ]]; then
|
||||
(cd "$ANDROID_DIR" && ./gradlew :app:installPlayDebug)
|
||||
fi
|
||||
|
||||
adb shell pm grant "$PACKAGE_NAME" android.permission.RECORD_AUDIO >/dev/null 2>&1 || true
|
||||
adb shell am force-stop "$PACKAGE_NAME" >/dev/null
|
||||
adb shell am start -a "$OPEN_ACTION" -n "$PACKAGE_NAME/.MainActivity" >/dev/null
|
||||
adb logcat -c
|
||||
|
||||
run_mode() {
|
||||
local test_mode="$1"
|
||||
local result_name="$ARTIFACT_DIR/result-$test_mode.json"
|
||||
local screenshot_name="$ARTIFACT_DIR/screen-$test_mode.png"
|
||||
local transcript_base64
|
||||
local realtime_assistant_base64
|
||||
transcript_base64="$(printf '%s' "$TRANSCRIPT" | base64 | tr -d '\n')"
|
||||
realtime_assistant_base64="$(printf '%s' "$REALTIME_ASSISTANT" | base64 | tr -d '\n')"
|
||||
|
||||
adb shell run-as "$PACKAGE_NAME" rm -f cache/voice_e2e_result.json >/dev/null 2>&1 || true
|
||||
local no_connect_flag=true
|
||||
if [[ "$CONNECT" -eq 1 ]]; then
|
||||
no_connect_flag=false
|
||||
fi
|
||||
|
||||
adb shell am broadcast \
|
||||
-a "$RUN_ACTION" \
|
||||
-n "$RECEIVER" \
|
||||
--es mode "$test_mode" \
|
||||
--ez noConnect "$no_connect_flag" \
|
||||
--es host "$HOST" \
|
||||
--ei port "$PORT" \
|
||||
--ez tls false \
|
||||
--el timeoutMs "$TIMEOUT_MS" \
|
||||
--el connectTimeoutMs "$TIMEOUT_MS" \
|
||||
--es transcriptBase64 "$transcript_base64" \
|
||||
--es realtimeAssistantBase64 "$realtime_assistant_base64" >/dev/null
|
||||
|
||||
local deadline=$((SECONDS + TIMEOUT_MS / 1000 + 20))
|
||||
local result=""
|
||||
while [[ "$SECONDS" -lt "$deadline" ]]; do
|
||||
result="$(adb shell run-as "$PACKAGE_NAME" cat cache/voice_e2e_result.json 2>/dev/null | tr -d '\r' || true)"
|
||||
if [[ -n "$result" ]]; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [[ -z "$result" ]]; then
|
||||
echo "voice e2e $test_mode timed out waiting for result" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '%s\n' "$result" >"$result_name"
|
||||
adb exec-out screencap -p >"$screenshot_name"
|
||||
if ! grep -q '"ok":true' "$result_name"; then
|
||||
echo "voice e2e $test_mode failed: $result" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
case "$MODE" in
|
||||
both)
|
||||
run_mode normal
|
||||
run_mode realtime
|
||||
;;
|
||||
normal|dictation)
|
||||
run_mode normal
|
||||
;;
|
||||
realtime|talk)
|
||||
run_mode realtime
|
||||
;;
|
||||
connect)
|
||||
run_mode connect
|
||||
;;
|
||||
*)
|
||||
echo "unknown mode: $MODE" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
adb logcat -d -v time |
|
||||
rg -i 'OpenClaw|TalkMode|MicCapture|AudioRecord|SpeechRecognizer|realtime|talk.session|appendAudio|transcript|Talk failed|Transcription failed|Speech network|VoiceE2E' |
|
||||
tail -250 >"$ARTIFACT_DIR/logcat.txt" || true
|
||||
|
||||
if [[ "$CLEANUP" -eq 1 ]]; then
|
||||
adb shell am broadcast -a "$RUN_ACTION" -n "$RECEIVER" --es mode stop >/dev/null
|
||||
fi
|
||||
|
||||
echo "$ARTIFACT_DIR"
|
||||
@@ -1,5 +1,9 @@
|
||||
# OpenClaw iOS Changelog
|
||||
|
||||
## 2026.5.26 - 2026-05-26
|
||||
|
||||
Maintenance update for the current OpenClaw release.
|
||||
|
||||
## 2026.5.25 - 2026-05-25
|
||||
|
||||
Maintenance update for the current OpenClaw release.
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// Source of truth: apps/ios/version.json
|
||||
// Generated by scripts/ios-sync-versioning.ts.
|
||||
|
||||
OPENCLAW_IOS_VERSION = 2026.5.25
|
||||
OPENCLAW_MARKETING_VERSION = 2026.5.25
|
||||
OPENCLAW_IOS_VERSION = 2026.5.26
|
||||
OPENCLAW_MARKETING_VERSION = 2026.5.26
|
||||
OPENCLAW_BUILD_VERSION = 1
|
||||
|
||||
#include? "../build/Version.xcconfig"
|
||||
|
||||
@@ -75,6 +75,387 @@ struct HomeToolbar: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct TalkToolbarTray: View {
|
||||
var brighten: Bool
|
||||
var tint: Color
|
||||
var statusText: String
|
||||
var agentName: String
|
||||
var micLevel: Double
|
||||
var isListening: Bool
|
||||
var isSpeaking: Bool
|
||||
var isUserSpeechDetected: Bool
|
||||
var permissionState: TalkGatewayPermissionState
|
||||
var voiceModeTitle: String
|
||||
var voiceModeSubtitle: String?
|
||||
var onEnableTalk: () -> Void
|
||||
var onStopTalk: () -> Void
|
||||
|
||||
@Environment(\.colorSchemeContrast) private var contrast
|
||||
|
||||
private var state: TalkToolbarTrayState {
|
||||
TalkToolbarTrayState(
|
||||
statusText: self.statusText,
|
||||
isListening: self.isListening,
|
||||
isSpeaking: self.isSpeaking,
|
||||
isUserSpeechDetected: self.isUserSpeechDetected,
|
||||
permissionState: self.permissionState)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(self.tint.opacity(self.state.iconFillOpacity))
|
||||
.frame(width: 36, height: 36)
|
||||
Image(systemName: self.state.systemImage)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(self.state.iconColor(tint: self.tint))
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
HStack(spacing: 8) {
|
||||
Text(self.state.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
|
||||
if self.state.showsProgress {
|
||||
ProgressView()
|
||||
.controlSize(.mini)
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
TalkWaveformView(
|
||||
mode: self.state.waveformMode(micLevel: self.micLevel),
|
||||
tint: self.state.waveformTint(tint: self.tint))
|
||||
.frame(width: 84, height: 18)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
Text(self.subtitle)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
if let voiceModeText = self.voiceModeText {
|
||||
Text(voiceModeText)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
switch self.state.action {
|
||||
case .enable:
|
||||
Button(action: self.onEnableTalk) {
|
||||
Label("Enable Talk", systemImage: "key.fill")
|
||||
.labelStyle(.titleAndIcon)
|
||||
}
|
||||
.font(.caption.weight(.semibold))
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
case .stop:
|
||||
Button(action: self.onStopTalk) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 13, weight: .bold))
|
||||
.frame(width: 28, height: 28)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.background {
|
||||
Circle()
|
||||
.fill(Color.black.opacity(self.brighten ? 0.10 : 0.18))
|
||||
.overlay {
|
||||
Circle()
|
||||
.strokeBorder(
|
||||
.white.opacity(self.contrast == .increased ? 0.42 : 0.16),
|
||||
lineWidth: self.contrast == .increased ? 1.0 : 0.6)
|
||||
}
|
||||
}
|
||||
.accessibilityLabel("Stop Talk")
|
||||
case .none:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(.ultraThinMaterial)
|
||||
.overlay(alignment: .top) {
|
||||
Rectangle()
|
||||
.fill(.white.opacity(self.contrast == .increased ? 0.46 : (self.brighten ? 0.18 : 0.12)))
|
||||
.frame(height: self.contrast == .increased ? 1.0 : 0.6)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.overlay(alignment: .bottom) {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
self.tint.opacity(self.brighten ? 0.12 : 0.16),
|
||||
.clear,
|
||||
],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing)
|
||||
.frame(height: 1)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("Talk Mode")
|
||||
.accessibilityValue(self.accessibilityValue)
|
||||
}
|
||||
|
||||
private var accessibilityValue: String {
|
||||
if let voiceModeText {
|
||||
return "\(self.state.title), \(self.subtitle), \(voiceModeText)"
|
||||
}
|
||||
return "\(self.state.title), \(self.subtitle)"
|
||||
}
|
||||
|
||||
private var voiceModeText: String? {
|
||||
guard !self.state.prefersPermissionCopy else { return nil }
|
||||
let title = self.voiceModeTitle.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !title.isEmpty, title != "Not loaded" else { return nil }
|
||||
let subtitle = (self.voiceModeSubtitle ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return subtitle.isEmpty ? title : "\(title) • \(subtitle)"
|
||||
}
|
||||
|
||||
private var subtitle: String {
|
||||
let trimmedAgent = self.agentName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if self.state.prefersPermissionCopy {
|
||||
return "Gateway approval needed"
|
||||
}
|
||||
if !trimmedAgent.isEmpty {
|
||||
return trimmedAgent
|
||||
}
|
||||
return "OpenClaw"
|
||||
}
|
||||
}
|
||||
|
||||
private enum TalkToolbarTrayAction {
|
||||
case none
|
||||
case enable
|
||||
case stop
|
||||
}
|
||||
|
||||
private enum TalkWaveformMode: Equatable {
|
||||
case level(Double)
|
||||
case inputSpeech
|
||||
case speaking
|
||||
case indeterminate
|
||||
case still
|
||||
}
|
||||
|
||||
private struct TalkToolbarTrayState: Equatable {
|
||||
let statusText: String
|
||||
let isListening: Bool
|
||||
let isSpeaking: Bool
|
||||
let isUserSpeechDetected: Bool
|
||||
let permissionState: TalkGatewayPermissionState
|
||||
|
||||
private var normalizedStatus: String {
|
||||
self.statusText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self.permissionState {
|
||||
case .missingScope, .requestFailed:
|
||||
return "Gateway permission required"
|
||||
case .requestingUpgrade:
|
||||
return "Requesting approval"
|
||||
case .upgradeRequested:
|
||||
return "Approval requested"
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
if self.isSpeaking { return "Speaking" }
|
||||
if self.isListening { return "Listening" }
|
||||
if self.normalizedStatus.contains("connecting") { return "Connecting" }
|
||||
if self.normalizedStatus.contains("thinking") { return "Asking OpenClaw" }
|
||||
if self.normalizedStatus == "ready" { return "Ready to talk" }
|
||||
if self.normalizedStatus.isEmpty || self.normalizedStatus == "off" { return "Talk" }
|
||||
return self.statusText
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self.permissionState {
|
||||
case .missingScope, .requestFailed:
|
||||
return "key.fill"
|
||||
case .requestingUpgrade:
|
||||
return "paperplane.fill"
|
||||
case .upgradeRequested:
|
||||
return "hourglass"
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
if self.isSpeaking { return "speaker.wave.2.fill" }
|
||||
if self.isListening { return "mic.fill" }
|
||||
if self.normalizedStatus.contains("thinking") { return "sparkles" }
|
||||
if self.normalizedStatus.contains("connecting") { return "dot.radiowaves.left.and.right" }
|
||||
return "waveform"
|
||||
}
|
||||
|
||||
var action: TalkToolbarTrayAction {
|
||||
switch self.permissionState {
|
||||
case .missingScope, .requestFailed:
|
||||
.enable
|
||||
case .requestingUpgrade, .upgradeRequested:
|
||||
.none
|
||||
default:
|
||||
.stop
|
||||
}
|
||||
}
|
||||
|
||||
var showsProgress: Bool {
|
||||
switch self.permissionState {
|
||||
case .requestingUpgrade, .upgradeRequested:
|
||||
true
|
||||
default:
|
||||
self.normalizedStatus.contains("connecting") || self.normalizedStatus.contains("thinking")
|
||||
}
|
||||
}
|
||||
|
||||
var prefersPermissionCopy: Bool {
|
||||
switch self.permissionState {
|
||||
case .missingScope, .requestingUpgrade, .upgradeRequested, .requestFailed:
|
||||
true
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
var iconFillOpacity: Double {
|
||||
self.prefersPermissionCopy ? 0.18 : 0.24
|
||||
}
|
||||
|
||||
func iconColor(tint: Color) -> Color {
|
||||
switch self.permissionState {
|
||||
case .requestFailed:
|
||||
.red
|
||||
case .missingScope, .requestingUpgrade, .upgradeRequested:
|
||||
.orange
|
||||
default:
|
||||
tint
|
||||
}
|
||||
}
|
||||
|
||||
func waveformTint(tint: Color) -> Color {
|
||||
switch self.permissionState {
|
||||
case .requestFailed:
|
||||
.red
|
||||
case .missingScope, .requestingUpgrade, .upgradeRequested:
|
||||
.orange
|
||||
default:
|
||||
tint
|
||||
}
|
||||
}
|
||||
|
||||
func waveformMode(micLevel: Double) -> TalkWaveformMode {
|
||||
switch self.permissionState {
|
||||
case .requestingUpgrade, .upgradeRequested:
|
||||
return .indeterminate
|
||||
case .missingScope, .requestFailed:
|
||||
return .still
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
if self.isSpeaking {
|
||||
return .speaking
|
||||
}
|
||||
if self.isListening, self.isUserSpeechDetected {
|
||||
return .inputSpeech
|
||||
}
|
||||
if self.isListening {
|
||||
return .level(micLevel)
|
||||
}
|
||||
if self.normalizedStatus.contains("connecting") || self.normalizedStatus.contains("thinking") {
|
||||
return .indeterminate
|
||||
}
|
||||
return .still
|
||||
}
|
||||
}
|
||||
|
||||
private struct TalkWaveformView: View {
|
||||
var mode: TalkWaveformMode
|
||||
var tint: Color
|
||||
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
private let barCount = 14
|
||||
|
||||
var body: some View {
|
||||
TimelineView(.periodic(from: .now, by: 1.0 / 24.0)) { timeline in
|
||||
HStack(alignment: .center, spacing: 3) {
|
||||
ForEach(0..<self.barCount, id: \.self) { index in
|
||||
Capsule(style: .continuous)
|
||||
.fill(self.tint.opacity(self.opacity(for: index)))
|
||||
.frame(width: 3, height: self.height(for: index, date: timeline.date))
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private func height(for index: Int, date: Date) -> CGFloat {
|
||||
let minimum: Double = 4
|
||||
let maximum: Double = 18
|
||||
let amplitude = self.amplitude(for: index, date: date)
|
||||
return CGFloat(minimum + ((maximum - minimum) * amplitude))
|
||||
}
|
||||
|
||||
private func opacity(for index: Int) -> Double {
|
||||
switch self.mode {
|
||||
case .still:
|
||||
index == self.barCount / 2 ? 0.64 : 0.32
|
||||
default:
|
||||
0.78
|
||||
}
|
||||
}
|
||||
|
||||
private func amplitude(for index: Int, date: Date) -> Double {
|
||||
if self.reduceMotion {
|
||||
switch self.mode {
|
||||
case let .level(level):
|
||||
return min(max(level, 0.10), 1.0)
|
||||
case .inputSpeech:
|
||||
return 0.72
|
||||
case .speaking:
|
||||
return 0.62
|
||||
case .indeterminate:
|
||||
return 0.34
|
||||
case .still:
|
||||
return 0.18
|
||||
}
|
||||
}
|
||||
|
||||
let t = date.timeIntervalSinceReferenceDate
|
||||
let phase = Double(index) * 0.52
|
||||
switch self.mode {
|
||||
case let .level(level):
|
||||
let clamped = min(max(level, 0), 1)
|
||||
let shaped = 0.12 + (0.88 * clamped)
|
||||
let variation = 0.72 + (0.28 * sin((t * 12.0) + phase))
|
||||
return min(max(shaped * variation, 0.10), 1.0)
|
||||
case .inputSpeech:
|
||||
let primary = 0.5 + (0.5 * sin((t * 14.0) + phase))
|
||||
let secondary = 0.5 + (0.5 * sin((t * 5.0) + (phase * 1.35)))
|
||||
return min(max(0.16 + (0.60 * primary) + (0.24 * secondary), 0.14), 1.0)
|
||||
case .speaking:
|
||||
let wave = 0.5 + (0.5 * sin((t * 7.5) + phase))
|
||||
let secondary = 0.5 + (0.5 * sin((t * 3.0) + (phase * 0.7)))
|
||||
return min(max(0.18 + (0.58 * wave) + (0.24 * secondary), 0.12), 1.0)
|
||||
case .indeterminate:
|
||||
let center = (sin((t * 3.2) + phase) + 1) / 2
|
||||
return 0.16 + (0.42 * center)
|
||||
case .still:
|
||||
return index == self.barCount / 2 ? 0.32 : 0.16
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct HomeToolbarStatusButton: View {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
@@ -148,6 +148,8 @@ final class NodeAppModel {
|
||||
private let operatorGateway = GatewayNodeSession()
|
||||
private var nodeGatewayTask: Task<Void, Never>?
|
||||
private var operatorGatewayTask: Task<Void, Never>?
|
||||
private var forceOperatorTalkPermissionUpgradeRequest = false
|
||||
private var lastTalkPermissionReconnectAttemptAt: Date?
|
||||
private var voiceWakeSyncTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var cameraHUDDismissTask: Task<Void, Never>?
|
||||
@ObservationIgnored private lazy var capabilityRouter: NodeCapabilityRouter = self.buildCapabilityRouter()
|
||||
@@ -609,6 +611,85 @@ final class NodeAppModel {
|
||||
self.talkMode.applyProviderSelectionChanged()
|
||||
}
|
||||
|
||||
func requestTalkPermissionUpgrade() {
|
||||
guard let config = self.activeGatewayConnectConfig else {
|
||||
self.talkMode.gatewayTalkPermissionState = .requestFailed("Gateway is not connected")
|
||||
self.talkMode.statusText = "Gateway not connected"
|
||||
return
|
||||
}
|
||||
GatewayDiagnostics.log("talk permission upgrade requested")
|
||||
self.talkMode.gatewayTalkPermissionState = .requestingUpgrade
|
||||
self.talkMode.statusText = "Requesting Talk approval"
|
||||
self.forceOperatorTalkPermissionUpgradeRequest = true
|
||||
self.gatewayAutoReconnectEnabled = true
|
||||
self.gatewayPairingPaused = false
|
||||
self.gatewayPairingRequestId = nil
|
||||
self.lastGatewayProblem = nil
|
||||
self.operatorGatewayTask?.cancel()
|
||||
self.operatorGatewayTask = nil
|
||||
let sessionBox = config.tls.map { WebSocketSessionBox(session: GatewayTLSPinningSession(params: $0)) }
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.operatorGateway.disconnect()
|
||||
await MainActor.run {
|
||||
self.startOperatorGatewayLoop(
|
||||
url: config.url,
|
||||
stableID: config.effectiveStableID,
|
||||
token: config.token,
|
||||
bootstrapToken: config.bootstrapToken,
|
||||
password: config.password,
|
||||
nodeOptions: config.nodeOptions,
|
||||
sessionBox: sessionBox)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func pollTalkPermissionUpgrade() async {
|
||||
guard self.talkMode.gatewayTalkPermissionState.isApprovalRequestInProgress else {
|
||||
await self.talkMode.reloadConfig()
|
||||
await self.talkMode.prefetchRealtimeSessionIfReady(reason: "talk_permission_poll")
|
||||
return
|
||||
}
|
||||
|
||||
guard let cfg = self.activeGatewayConnectConfig else {
|
||||
self.talkMode.gatewayTalkPermissionState = .requestFailed("Gateway is not connected")
|
||||
self.talkMode.statusText = "Gateway not connected"
|
||||
return
|
||||
}
|
||||
|
||||
let now = Date()
|
||||
if let lastTalkPermissionReconnectAttemptAt,
|
||||
now.timeIntervalSince(lastTalkPermissionReconnectAttemptAt) < 6
|
||||
{
|
||||
return
|
||||
}
|
||||
self.lastTalkPermissionReconnectAttemptAt = now
|
||||
|
||||
GatewayDiagnostics.log("talk permission approval poll reconnect")
|
||||
self.gatewayAutoReconnectEnabled = true
|
||||
self.gatewayPairingPaused = false
|
||||
self.gatewayPairingRequestId = nil
|
||||
self.ensureOperatorReconnectLoopIfNeeded()
|
||||
|
||||
if self.operatorGatewayTask == nil {
|
||||
let sessionBox = cfg.tls.map { WebSocketSessionBox(session: GatewayTLSPinningSession(params: $0)) }
|
||||
self.startOperatorGatewayLoop(
|
||||
url: cfg.url,
|
||||
stableID: cfg.effectiveStableID,
|
||||
token: cfg.token,
|
||||
bootstrapToken: cfg.bootstrapToken,
|
||||
password: cfg.password,
|
||||
nodeOptions: cfg.nodeOptions,
|
||||
sessionBox: sessionBox)
|
||||
}
|
||||
|
||||
guard await self.waitForOperatorConnection(timeoutMs: 2500, pollMs: 250) else {
|
||||
return
|
||||
}
|
||||
await self.talkMode.reloadConfig()
|
||||
await self.talkMode.prefetchRealtimeSessionIfReady(reason: "talk_permission_poll_connected")
|
||||
}
|
||||
|
||||
func requestLocationPermissions(mode: OpenClawLocationMode) async -> Bool {
|
||||
guard mode != .off else { return true }
|
||||
let status = await self.locationService.ensureAuthorization(mode: mode)
|
||||
@@ -2026,7 +2107,11 @@ extension NodeAppModel {
|
||||
sessionBox: WebSocketSessionBox?) async
|
||||
{
|
||||
self.clearPersistedGatewayBootstrapTokenIfNeeded()
|
||||
if self.operatorGatewayTask == nil, self.shouldStartOperatorGatewayLoop(
|
||||
self.operatorGatewayTask?.cancel()
|
||||
self.operatorGatewayTask = nil
|
||||
await self.operatorGateway.disconnect()
|
||||
|
||||
if self.shouldStartOperatorGatewayLoop(
|
||||
token: token,
|
||||
bootstrapToken: nil,
|
||||
password: password,
|
||||
@@ -2107,7 +2192,8 @@ extension NodeAppModel {
|
||||
displayName: nodeOptions.clientDisplayName,
|
||||
includeApprovalScope: self.shouldRequestOperatorApprovalScope(
|
||||
token: reconnectAuth.token,
|
||||
password: reconnectAuth.password))
|
||||
password: reconnectAuth.password),
|
||||
forceExplicitScopes: self.forceOperatorTalkPermissionUpgradeRequest)
|
||||
|
||||
do {
|
||||
try await self.operatorGateway.connect(
|
||||
@@ -2121,11 +2207,13 @@ extension NodeAppModel {
|
||||
guard let self else { return }
|
||||
await MainActor.run {
|
||||
self.operatorConnected = true
|
||||
self.forceOperatorTalkPermissionUpgradeRequest = false
|
||||
self.talkMode.updateGatewayConnected(true)
|
||||
}
|
||||
GatewayDiagnostics.log(
|
||||
"operator gateway connected host=\(url.host ?? "?") scheme=\(url.scheme ?? "?")")
|
||||
await self.talkMode.reloadConfig()
|
||||
await self.talkMode.prefetchRealtimeSessionIfReady(reason: "operator_connected")
|
||||
await self.refreshBrandingFromGateway()
|
||||
await self.refreshAgentsFromGateway()
|
||||
await self.refreshShareRouteFromGateway()
|
||||
@@ -2158,6 +2246,29 @@ extension NodeAppModel {
|
||||
} catch {
|
||||
attempt += 1
|
||||
GatewayDiagnostics.log("operator gateway connect error: \(error.localizedDescription)")
|
||||
let problem = await MainActor.run {
|
||||
let nextProblem = GatewayConnectionProblemMapper.map(error: error)
|
||||
if let nextProblem {
|
||||
if nextProblem.kind == .pairingScopeUpgradeRequired {
|
||||
self.gatewayPairingPaused = true
|
||||
self.gatewayPairingRequestId = nextProblem.requestId
|
||||
self.talkMode.markTalkPermissionUpgradeRequested(requestId: nextProblem.requestId)
|
||||
}
|
||||
}
|
||||
return nextProblem
|
||||
}
|
||||
if problem?.needsPairingApproval == true {
|
||||
self.operatorGatewayTask?.cancel()
|
||||
self.operatorGatewayTask = nil
|
||||
await self.operatorGateway.disconnect()
|
||||
break
|
||||
}
|
||||
if problem?.pauseReconnect == true {
|
||||
self.operatorGatewayTask?.cancel()
|
||||
self.operatorGatewayTask = nil
|
||||
await self.operatorGateway.disconnect()
|
||||
break
|
||||
}
|
||||
let sleepSeconds = min(8.0, 0.5 * pow(1.7, Double(attempt)))
|
||||
try? await Task.sleep(nanoseconds: UInt64(sleepSeconds * 1_000_000_000))
|
||||
}
|
||||
@@ -2420,7 +2531,8 @@ extension NodeAppModel {
|
||||
private func makeOperatorConnectOptions(
|
||||
clientId: String,
|
||||
displayName: String?,
|
||||
includeApprovalScope: Bool) -> GatewayConnectOptions
|
||||
includeApprovalScope: Bool,
|
||||
forceExplicitScopes: Bool = false) -> GatewayConnectOptions
|
||||
{
|
||||
var scopes = ["operator.read", "operator.write", "operator.talk.secrets"]
|
||||
// Preserve reconnect compatibility for older paired operator tokens that were
|
||||
@@ -2431,6 +2543,7 @@ extension NodeAppModel {
|
||||
return GatewayConnectOptions(
|
||||
role: "operator",
|
||||
scopes: scopes,
|
||||
scopesAreExplicit: forceExplicitScopes,
|
||||
caps: [],
|
||||
commands: [],
|
||||
permissions: [:],
|
||||
@@ -2561,7 +2674,9 @@ extension NodeAppModel {
|
||||
|
||||
func reloadTalkConfig() {
|
||||
Task { [weak self] in
|
||||
await self?.talkMode.reloadConfig()
|
||||
guard let self else { return }
|
||||
await self.talkMode.reloadConfig()
|
||||
await self.talkMode.prefetchRealtimeSessionIfReady(reason: "config_reload")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4283,12 +4398,14 @@ extension NodeAppModel {
|
||||
func _test_makeOperatorConnectOptions(
|
||||
clientId: String,
|
||||
displayName: String?,
|
||||
includeApprovalScope: Bool) -> GatewayConnectOptions
|
||||
includeApprovalScope: Bool,
|
||||
forceExplicitScopes: Bool = false) -> GatewayConnectOptions
|
||||
{
|
||||
self.makeOperatorConnectOptions(
|
||||
clientId: clientId,
|
||||
displayName: displayName,
|
||||
includeApprovalScope: includeApprovalScope)
|
||||
includeApprovalScope: includeApprovalScope,
|
||||
forceExplicitScopes: forceExplicitScopes)
|
||||
}
|
||||
|
||||
func _test_presentExecApprovalPrompt(_ prompt: ExecApprovalPrompt) {
|
||||
|
||||
@@ -350,7 +350,7 @@ struct RootCanvas: View {
|
||||
private func homeCanvasBadge(for agent: AgentSummary) -> String {
|
||||
if let identity = agent.identity,
|
||||
let emoji = identity["emoji"]?.value as? String,
|
||||
let normalizedEmoji = self.normalized(emoji)
|
||||
let normalizedEmoji = normalized(emoji)
|
||||
{
|
||||
return normalizedEmoji
|
||||
}
|
||||
@@ -471,6 +471,8 @@ private struct CanvasContent: View {
|
||||
@AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true
|
||||
@State private var showGatewayActions: Bool = false
|
||||
@State private var showGatewayProblemDetails: Bool = false
|
||||
@State private var showTalkPermissionPrompt: Bool = false
|
||||
@State private var showTalkPermissionTray: Bool = false
|
||||
var systemColorScheme: ColorScheme
|
||||
var gatewayStatus: StatusPill.GatewayState
|
||||
var voiceWakeEnabled: Bool
|
||||
@@ -487,48 +489,76 @@ private struct CanvasContent: View {
|
||||
}
|
||||
|
||||
private var talkActive: Bool {
|
||||
self.appModel.talkMode.isEnabled || self.talkEnabled
|
||||
(self.appModel.talkMode.isEnabled || self.talkEnabled) && !self.talkPermissionBlocksStart
|
||||
}
|
||||
|
||||
private var talkPermissionBlocksStart: Bool {
|
||||
self.appModel.talkMode.gatewayTalkPermissionState.requiresTalkPermissionAction
|
||||
}
|
||||
|
||||
private var showTalkTray: Bool {
|
||||
self.talkActive ||
|
||||
self.showTalkPermissionTray ||
|
||||
self.appModel.talkMode.gatewayTalkPermissionState.isApprovalRequestInProgress
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
ScreenTab()
|
||||
}
|
||||
.overlay(alignment: .center) {
|
||||
if self.talkActive {
|
||||
TalkOrbOverlay()
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
HomeToolbar(
|
||||
gateway: self.gatewayStatus,
|
||||
voiceWakeEnabled: self.voiceWakeEnabled,
|
||||
activity: self.statusActivity,
|
||||
brighten: self.brightenButtons,
|
||||
talkButtonEnabled: self.talkButtonEnabled,
|
||||
talkActive: self.talkActive,
|
||||
talkTint: self.appModel.seamColor,
|
||||
onStatusTap: {
|
||||
if self.gatewayStatus == .connected {
|
||||
self.showGatewayActions = true
|
||||
} else if self.appModel.lastGatewayProblem != nil {
|
||||
self.showGatewayProblemDetails = true
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
if self.showTalkTray {
|
||||
TalkToolbarTray(
|
||||
brighten: self.brightenButtons,
|
||||
tint: self.appModel.seamColor,
|
||||
statusText: self.appModel.talkMode.statusText,
|
||||
agentName: self.appModel.activeAgentName,
|
||||
micLevel: self.appModel.talkMode.micLevel,
|
||||
isListening: self.appModel.talkMode.isListening,
|
||||
isSpeaking: self.appModel.talkMode.isSpeaking,
|
||||
isUserSpeechDetected: self.appModel.talkMode.isUserSpeechDetected,
|
||||
permissionState: self.appModel.talkMode.gatewayTalkPermissionState,
|
||||
voiceModeTitle: self.appModel.talkMode.gatewayTalkVoiceModeTitle,
|
||||
voiceModeSubtitle: self.appModel.talkMode.gatewayTalkVoiceModeSubtitle,
|
||||
onEnableTalk: {
|
||||
self.showTalkPermissionPrompt = true
|
||||
},
|
||||
onStopTalk: {
|
||||
self.showTalkPermissionTray = false
|
||||
self.stopTalk()
|
||||
})
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
|
||||
HomeToolbar(
|
||||
gateway: self.gatewayStatus,
|
||||
voiceWakeEnabled: self.voiceWakeEnabled,
|
||||
activity: self.statusActivity,
|
||||
brighten: self.brightenButtons,
|
||||
talkButtonEnabled: self.talkButtonEnabled,
|
||||
talkActive: self.talkActive,
|
||||
talkTint: self.appModel.seamColor,
|
||||
onStatusTap: {
|
||||
if self.gatewayStatus == .connected {
|
||||
self.showGatewayActions = true
|
||||
} else if self.appModel.lastGatewayProblem != nil {
|
||||
self.showGatewayProblemDetails = true
|
||||
} else {
|
||||
self.openSettings()
|
||||
}
|
||||
},
|
||||
onChatTap: {
|
||||
self.openChat()
|
||||
},
|
||||
onTalkTap: {
|
||||
self.handleTalkToolbarTap()
|
||||
},
|
||||
onSettingsTap: {
|
||||
self.openSettings()
|
||||
}
|
||||
},
|
||||
onChatTap: {
|
||||
self.openChat()
|
||||
},
|
||||
onTalkTap: {
|
||||
let next = !self.talkActive
|
||||
self.talkEnabled = next
|
||||
self.appModel.setTalkEnabled(next)
|
||||
},
|
||||
onSettingsTap: {
|
||||
self.openSettings()
|
||||
})
|
||||
})
|
||||
}
|
||||
.animation(.spring(response: 0.28, dampingFraction: 0.86), value: self.showTalkTray)
|
||||
}
|
||||
.overlay(alignment: .top) {
|
||||
if let gatewayProblem = self.appModel.lastGatewayProblem,
|
||||
@@ -572,9 +602,32 @@ private struct CanvasContent: View {
|
||||
})
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: self.$showTalkPermissionPrompt) {
|
||||
NavigationStack {
|
||||
TalkPermissionPromptView(
|
||||
style: .sheet,
|
||||
onPermissionReady: {
|
||||
self.showTalkPermissionPrompt = false
|
||||
self.showTalkPermissionTray = false
|
||||
self.startTalk()
|
||||
})
|
||||
.padding()
|
||||
.navigationTitle("Enable Talk")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Not Now") {
|
||||
self.showTalkPermissionPrompt = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
}
|
||||
.onAppear {
|
||||
// Keep the runtime talk state aligned with persisted toggle state on cold launch.
|
||||
if self.talkEnabled != self.appModel.talkMode.isEnabled {
|
||||
if self.talkPermissionBlocksStart, self.talkEnabled || self.appModel.talkMode.isEnabled {
|
||||
self.stopTalk()
|
||||
} else if self.talkEnabled != self.appModel.talkMode.isEnabled {
|
||||
self.appModel.setTalkEnabled(self.talkEnabled)
|
||||
}
|
||||
}
|
||||
@@ -605,6 +658,44 @@ private struct CanvasContent: View {
|
||||
self.openSettings()
|
||||
}
|
||||
}
|
||||
|
||||
private func handleTalkToolbarTap() {
|
||||
GatewayDiagnostics.log(
|
||||
"talk.timeline tap active=\(self.talkActive) permissionBlocked=\(self.talkPermissionBlocksStart)")
|
||||
if self.talkActive {
|
||||
self.showTalkPermissionTray = false
|
||||
self.stopTalk()
|
||||
return
|
||||
}
|
||||
|
||||
if self.talkPermissionBlocksStart {
|
||||
self.stopTalk()
|
||||
self.showTalkPermissionTray = true
|
||||
Task {
|
||||
await self.appModel.pollTalkPermissionUpgrade()
|
||||
if !self.talkPermissionBlocksStart {
|
||||
self.showTalkPermissionTray = false
|
||||
self.startTalk()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
self.showTalkPermissionTray = false
|
||||
self.startTalk()
|
||||
}
|
||||
|
||||
private func startTalk() {
|
||||
GatewayDiagnostics.log("talk.timeline start requested from toolbar")
|
||||
self.talkEnabled = true
|
||||
self.appModel.setTalkEnabled(true)
|
||||
}
|
||||
|
||||
private func stopTalk() {
|
||||
GatewayDiagnostics.log("talk.timeline stop requested from toolbar")
|
||||
self.talkEnabled = false
|
||||
self.appModel.setTalkEnabled(false)
|
||||
}
|
||||
}
|
||||
|
||||
private struct CameraFlashOverlay: View {
|
||||
|
||||
@@ -612,7 +612,7 @@ struct SettingsTab: View {
|
||||
private var shouldShowRealtimeVoicePicker: Bool {
|
||||
let providerSelection = TalkModeProviderSelection.resolved(self.talkProviderSelectionRaw)
|
||||
return providerSelection == .openAIRealtime
|
||||
|| self.appModel.talkMode.gatewayTalkUsesRealtimeRelay
|
||||
|| self.appModel.talkMode.gatewayTalkUsesRealtime
|
||||
}
|
||||
|
||||
private func talkVoiceSettingsView() -> AnyView {
|
||||
@@ -633,6 +633,16 @@ struct SettingsTab: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
LabeledContent("Voice Mode") {
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(self.appModel.talkMode.gatewayTalkVoiceModeTitle)
|
||||
if let subtitle = self.appModel.talkMode.gatewayTalkVoiceModeSubtitle {
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
LabeledContent(
|
||||
"Active Provider",
|
||||
value: self.appModel.talkMode.gatewayTalkProviderLabel)
|
||||
|
||||
@@ -3,9 +3,107 @@ import OpenClawKit
|
||||
|
||||
enum TalkModeExecutionMode {
|
||||
case native
|
||||
case realtimeClient
|
||||
case realtimeRelay
|
||||
}
|
||||
|
||||
struct TalkVoiceModeDescriptor: Equatable {
|
||||
let title: String
|
||||
let subtitle: String?
|
||||
let providerId: String?
|
||||
let modelId: String?
|
||||
let voiceId: String?
|
||||
let transport: String?
|
||||
let isRealtime: Bool
|
||||
|
||||
var accessibilityValue: String {
|
||||
if let subtitle, !subtitle.isEmpty {
|
||||
return "\(self.title), \(subtitle)"
|
||||
}
|
||||
return self.title
|
||||
}
|
||||
}
|
||||
|
||||
enum TalkVoiceModeDescriptorBuilder {
|
||||
static func build(
|
||||
providerId: String,
|
||||
providerLabel: String,
|
||||
modelId: String?,
|
||||
voiceId: String?,
|
||||
transport: String?,
|
||||
isRealtime: Bool) -> TalkVoiceModeDescriptor
|
||||
{
|
||||
let normalizedProvider = providerId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let trimmedModel = Self.trimmed(modelId)
|
||||
let trimmedVoice = Self.trimmed(voiceId)
|
||||
let trimmedTransport = Self.trimmed(transport)
|
||||
let title = if isRealtime, normalizedProvider == "openai", trimmedModel == "gpt-realtime-2" {
|
||||
"GPT Realtime 2.0"
|
||||
} else if isRealtime, normalizedProvider == "openai" {
|
||||
"OpenAI Realtime"
|
||||
} else if isRealtime {
|
||||
providerLabel.isEmpty ? "Realtime Voice" : providerLabel
|
||||
} else if normalizedProvider == "system" {
|
||||
"iOS System Voice"
|
||||
} else {
|
||||
providerLabel.isEmpty ? "Talk Voice" : providerLabel
|
||||
}
|
||||
|
||||
var details: [String] = []
|
||||
if isRealtime, normalizedProvider != "openai", !providerLabel.isEmpty, providerLabel != title {
|
||||
details.append(providerLabel)
|
||||
}
|
||||
if let trimmedTransport {
|
||||
details.append(Self.transportLabel(trimmedTransport))
|
||||
}
|
||||
if let trimmedModel, title != "GPT Realtime 2.0" || trimmedModel != "gpt-realtime-2" {
|
||||
details.append(trimmedModel)
|
||||
}
|
||||
if let trimmedVoice {
|
||||
details.append(Self.voiceLabel(trimmedVoice))
|
||||
}
|
||||
|
||||
return TalkVoiceModeDescriptor(
|
||||
title: title,
|
||||
subtitle: details.isEmpty ? nil : details.joined(separator: " • "),
|
||||
providerId: normalizedProvider.isEmpty ? nil : normalizedProvider,
|
||||
modelId: trimmedModel,
|
||||
voiceId: trimmedVoice,
|
||||
transport: trimmedTransport,
|
||||
isRealtime: isRealtime)
|
||||
}
|
||||
|
||||
private static func trimmed(_ value: String?) -> String? {
|
||||
let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private static func voiceLabel(_ voice: String) -> String {
|
||||
TalkModeRealtimeVoiceSelection.voices.contains(voice)
|
||||
? TalkModeRealtimeVoiceSelection.label(for: voice)
|
||||
: voice
|
||||
}
|
||||
|
||||
private static func transportLabel(_ transport: String) -> String {
|
||||
switch transport.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
|
||||
case "webrtc":
|
||||
"Native WebRTC"
|
||||
case "gateway-relay":
|
||||
"Gateway Relay"
|
||||
case "provider-websocket":
|
||||
"Provider WebSocket"
|
||||
case "managed-room":
|
||||
"Managed Room"
|
||||
case "native":
|
||||
"Native"
|
||||
case let value where !value.isEmpty:
|
||||
value
|
||||
default:
|
||||
"Native"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum TalkModeProviderSelection: String, CaseIterable, Identifiable {
|
||||
case gatewayDefault = "gateway"
|
||||
case nativeElevenLabs = "elevenlabs"
|
||||
@@ -112,8 +210,9 @@ enum TalkModeGatewayConfigParser {
|
||||
let defaultVoiceId = Self.firstString(activeConfig, keys: ["voiceId", "voice"])
|
||||
let defaultOutputFormat = Self.firstString(activeConfig, keys: ["outputFormat"])
|
||||
let realtime = talk?["realtime"]?.dictionaryValue
|
||||
let realtimeProvider = Self.firstString(realtime, keys: ["provider"])
|
||||
let realtimeProviders = realtime?["providers"]?.dictionaryValue
|
||||
let realtimeProvider = Self.firstString(realtime, keys: ["provider"])
|
||||
?? Self.singleRealtimeProviderId(realtimeProviders)
|
||||
let realtimeProviderConfig = Self.realtimeProviderConfig(
|
||||
providers: realtimeProviders,
|
||||
provider: realtimeProvider)
|
||||
@@ -164,12 +263,24 @@ enum TalkModeGatewayConfigParser {
|
||||
let mode = Self.firstString(realtime, keys: ["mode"])?.lowercased()
|
||||
let transport = Self.firstString(realtime, keys: ["transport"])?.lowercased()
|
||||
let brain = Self.firstString(realtime, keys: ["brain"])?.lowercased()
|
||||
if mode == "realtime", transport == "gateway-relay", brain == nil || brain == "agent-consult" {
|
||||
guard mode == "realtime", brain == nil || brain == "agent-consult" else {
|
||||
return .native
|
||||
}
|
||||
if transport == "gateway-relay" {
|
||||
return .realtimeRelay
|
||||
}
|
||||
if transport == nil || transport == "webrtc" {
|
||||
return .realtimeClient
|
||||
}
|
||||
return .native
|
||||
}
|
||||
|
||||
private static func singleRealtimeProviderId(_ providers: [String: AnyCodable]?) -> String? {
|
||||
guard let providers, providers.count == 1 else { return nil }
|
||||
let provider = providers.keys.first?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return provider?.isEmpty == false ? provider : nil
|
||||
}
|
||||
|
||||
private static func realtimeProviderConfig(
|
||||
providers: [String: AnyCodable]?,
|
||||
provider: String?) -> [String: AnyCodable]?
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,87 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TalkOrbOverlay: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@State private var pulse: Bool = false
|
||||
|
||||
var body: some View {
|
||||
let seam = self.appModel.seamColor
|
||||
let status = self.appModel.talkMode.statusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let mic = min(max(self.appModel.talkMode.micLevel, 0), 1)
|
||||
|
||||
VStack(spacing: 14) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(seam.opacity(0.26), lineWidth: 2)
|
||||
.frame(width: 320, height: 320)
|
||||
.scaleEffect(self.pulse ? 1.15 : 0.96)
|
||||
.opacity(self.pulse ? 0.0 : 1.0)
|
||||
.animation(.easeOut(duration: 1.3).repeatForever(autoreverses: false), value: self.pulse)
|
||||
|
||||
Circle()
|
||||
.stroke(seam.opacity(0.18), lineWidth: 2)
|
||||
.frame(width: 320, height: 320)
|
||||
.scaleEffect(self.pulse ? 1.45 : 1.02)
|
||||
.opacity(self.pulse ? 0.0 : 0.9)
|
||||
.animation(.easeOut(duration: 1.9).repeatForever(autoreverses: false).delay(0.2), value: self.pulse)
|
||||
|
||||
Circle()
|
||||
.fill(
|
||||
RadialGradient(
|
||||
colors: [
|
||||
seam.opacity(0.75 + (0.20 * mic)),
|
||||
seam.opacity(0.40),
|
||||
Color.black.opacity(0.55),
|
||||
],
|
||||
center: .center,
|
||||
startRadius: 1,
|
||||
endRadius: 112))
|
||||
.frame(width: 190, height: 190)
|
||||
.scaleEffect(1.0 + (0.12 * mic))
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(seam.opacity(0.35), lineWidth: 1))
|
||||
.shadow(color: seam.opacity(0.32), radius: 26, x: 0, y: 0)
|
||||
.shadow(color: Color.black.opacity(0.50), radius: 22, x: 0, y: 10)
|
||||
}
|
||||
.contentShape(Circle())
|
||||
.onTapGesture {
|
||||
self.appModel.talkMode.userTappedOrb()
|
||||
}
|
||||
|
||||
let agentName = self.appModel.activeAgentName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !agentName.isEmpty {
|
||||
Text("Bot: \(agentName)")
|
||||
.font(.system(.caption, design: .rounded).weight(.semibold))
|
||||
.foregroundStyle(Color.white.opacity(0.70))
|
||||
}
|
||||
|
||||
if !status.isEmpty, status != "Off" {
|
||||
Text(status)
|
||||
.font(.system(.footnote, design: .rounded).weight(.semibold))
|
||||
.foregroundStyle(Color.white.opacity(0.92))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(Color.black.opacity(0.40))
|
||||
.overlay(
|
||||
Capsule().stroke(seam.opacity(0.22), lineWidth: 1)))
|
||||
}
|
||||
|
||||
if self.appModel.talkMode.isListening {
|
||||
Capsule()
|
||||
.fill(seam.opacity(0.90))
|
||||
.frame(width: max(18, 180 * mic), height: 6)
|
||||
.animation(.easeOut(duration: 0.12), value: mic)
|
||||
.accessibilityLabel("Microphone level")
|
||||
}
|
||||
}
|
||||
.padding(28)
|
||||
.onAppear {
|
||||
self.pulse = true
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("Talk Mode \(status)")
|
||||
}
|
||||
}
|
||||
173
apps/ios/Sources/Voice/TalkPermissionPromptView.swift
Normal file
173
apps/ios/Sources/Voice/TalkPermissionPromptView.swift
Normal file
@@ -0,0 +1,173 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TalkPermissionPromptView: View {
|
||||
enum Style {
|
||||
case card
|
||||
case settings
|
||||
case sheet
|
||||
}
|
||||
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
|
||||
let style: Style
|
||||
var onPermissionReady: (() -> Void)?
|
||||
|
||||
private var state: TalkGatewayPermissionState {
|
||||
self.appModel.talkMode.gatewayTalkPermissionState
|
||||
}
|
||||
|
||||
private var requestIsPending: Bool {
|
||||
self.state.isApprovalRequestInProgress
|
||||
}
|
||||
|
||||
private var pollTaskKey: String {
|
||||
switch self.state {
|
||||
case .requestingUpgrade:
|
||||
"requesting"
|
||||
case let .upgradeRequested(requestId):
|
||||
"pending:\(requestId ?? "")"
|
||||
default:
|
||||
"idle:\(self.state.statusLabel)"
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: self.style == .sheet ? 16 : 12) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: self.iconSystemName)
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(self.requestIsPending ? Color.orange : Color.accentColor)
|
||||
.frame(width: 28, height: 28)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(self.titleText)
|
||||
.font(self.style == .sheet ? .title3.weight(.semibold) : .headline)
|
||||
Text(self.messageText)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
if let failureMessage = self.state.failureMessage {
|
||||
Label(failureMessage, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.red)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
if let requestId = self.state.requestId {
|
||||
LabeledContent("Request ID") {
|
||||
Text(requestId)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 10) {
|
||||
Button {
|
||||
self.appModel.requestTalkPermissionUpgrade()
|
||||
} label: {
|
||||
if case .requestingUpgrade = self.state {
|
||||
Label {
|
||||
Text("Sending...")
|
||||
} icon: {
|
||||
ProgressView()
|
||||
}
|
||||
} else {
|
||||
Label(self.primaryButtonTitle, systemImage: self.primaryButtonSystemImage)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.state == .requestingUpgrade)
|
||||
|
||||
Button {
|
||||
Task { await self.appModel.talkMode.reloadConfig() }
|
||||
} label: {
|
||||
Label("Retry", systemImage: "arrow.triangle.2.circlepath")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
.padding(self.style == .card || self.style == .sheet ? 16 : 0)
|
||||
.background {
|
||||
if self.style == .card || self.style == .sheet {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(.thinMaterial)
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
if self.style == .card || self.style == .sheet {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.stroke(Color.accentColor.opacity(0.20), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
.task(id: self.pollTaskKey) {
|
||||
guard self.requestIsPending else { return }
|
||||
await self.pollUntilReady()
|
||||
}
|
||||
.onChange(of: self.state) { _, newState in
|
||||
if newState == .ready {
|
||||
self.onPermissionReady?()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var iconSystemName: String {
|
||||
switch self.state {
|
||||
case .requestingUpgrade:
|
||||
"paperplane.fill"
|
||||
case .upgradeRequested:
|
||||
"hourglass"
|
||||
case .requestFailed:
|
||||
"exclamationmark.triangle.fill"
|
||||
default:
|
||||
"key.fill"
|
||||
}
|
||||
}
|
||||
|
||||
private var titleText: String {
|
||||
switch self.state {
|
||||
case .requestingUpgrade:
|
||||
"Sending approval request"
|
||||
case .upgradeRequested:
|
||||
"Approval sent"
|
||||
case .requestFailed:
|
||||
"Could not request approval"
|
||||
default:
|
||||
"Enable Talk"
|
||||
}
|
||||
}
|
||||
|
||||
private var messageText: String {
|
||||
switch self.state {
|
||||
case .requestingUpgrade:
|
||||
"Sending a new pairing request to your gateway..."
|
||||
case .upgradeRequested:
|
||||
"Approve this request on your gateway. Talk will start automatically when approval lands."
|
||||
default:
|
||||
"This iPhone needs gateway approval before Talk can use realtime voice. Audio will go directly from " +
|
||||
"this phone to the voice provider."
|
||||
}
|
||||
}
|
||||
|
||||
private var primaryButtonTitle: String {
|
||||
self.requestIsPending ? "Request Again" : "Send Approval Request"
|
||||
}
|
||||
|
||||
private var primaryButtonSystemImage: String {
|
||||
self.requestIsPending ? "arrow.clockwise" : "paperplane.fill"
|
||||
}
|
||||
|
||||
private func pollUntilReady() async {
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||
if Task.isCancelled { return }
|
||||
await self.appModel.pollTalkPermissionUpgrade()
|
||||
if !self.appModel.talkMode.gatewayTalkPermissionState.requiresTalkPermissionAction {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
86
apps/ios/Sources/Voice/TalkRealtimeClientSession.swift
Normal file
86
apps/ios/Sources/Voice/TalkRealtimeClientSession.swift
Normal file
@@ -0,0 +1,86 @@
|
||||
import Foundation
|
||||
|
||||
struct TalkRealtimeClientCreateParams: Encodable {
|
||||
var mode = "realtime"
|
||||
var provider: String?
|
||||
var transport = "webrtc"
|
||||
var brain = "agent-consult"
|
||||
var model: String?
|
||||
var voice: String?
|
||||
}
|
||||
|
||||
struct TalkRealtimeClientSession: Decodable, Sendable {
|
||||
let provider: String
|
||||
let transport: String
|
||||
let clientSecret: String
|
||||
let offerUrl: String?
|
||||
let offerHeaders: [String: String]?
|
||||
let model: String?
|
||||
let voice: String?
|
||||
let expiresAt: Double?
|
||||
|
||||
var isWebRTC: Bool {
|
||||
self.transport.caseInsensitiveCompare("webrtc") == .orderedSame
|
||||
}
|
||||
}
|
||||
|
||||
struct TalkRealtimeToolCallResponse: Decodable, Sendable {
|
||||
let runId: String?
|
||||
let idempotencyKey: String?
|
||||
}
|
||||
|
||||
struct TalkRealtimeServerEvent: Decodable, Sendable {
|
||||
let type: String
|
||||
let itemId: String?
|
||||
let item: TalkRealtimeServerItem?
|
||||
let callId: String?
|
||||
let name: String?
|
||||
let delta: String?
|
||||
let arguments: String?
|
||||
let transcript: String?
|
||||
let text: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case type
|
||||
case itemId = "item_id"
|
||||
case item
|
||||
case callId = "call_id"
|
||||
case name
|
||||
case delta
|
||||
case arguments
|
||||
case transcript
|
||||
case text
|
||||
}
|
||||
|
||||
var resolvedItemId: String? {
|
||||
self.itemId ?? self.item?.id
|
||||
}
|
||||
|
||||
var resolvedCallId: String? {
|
||||
self.callId ?? self.item?.callId
|
||||
}
|
||||
|
||||
var resolvedName: String? {
|
||||
self.name ?? self.item?.name
|
||||
}
|
||||
|
||||
var resolvedArguments: String? {
|
||||
self.arguments ?? self.item?.arguments
|
||||
}
|
||||
}
|
||||
|
||||
struct TalkRealtimeServerItem: Decodable, Sendable {
|
||||
let id: String?
|
||||
let type: String?
|
||||
let callId: String?
|
||||
let name: String?
|
||||
let arguments: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case type
|
||||
case callId = "call_id"
|
||||
case name
|
||||
case arguments
|
||||
}
|
||||
}
|
||||
994
apps/ios/Sources/Voice/TalkRealtimeWebRTCSession.swift
Normal file
994
apps/ios/Sources/Voice/TalkRealtimeWebRTCSession.swift
Normal file
@@ -0,0 +1,994 @@
|
||||
import AVFAudio
|
||||
import Foundation
|
||||
import OpenClawChatUI
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import OSLog
|
||||
@preconcurrency import WebRTC
|
||||
|
||||
@MainActor
|
||||
protocol TalkRealtimeWebRTCSessionDelegate: AnyObject {
|
||||
func realtimeSession(_ session: TalkRealtimeWebRTCSession, didChangeStatus status: String)
|
||||
func realtimeSession(_ session: TalkRealtimeWebRTCSession, didDetectInputSpeech active: Bool)
|
||||
func realtimeSession(_ session: TalkRealtimeWebRTCSession, didReceiveUserTranscript text: String)
|
||||
func realtimeSession(_ session: TalkRealtimeWebRTCSession, didReceiveAssistantTranscript text: String)
|
||||
func realtimeSessionDidFinish(_ session: TalkRealtimeWebRTCSession)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class TalkRealtimeWebRTCSession: NSObject {
|
||||
private static let logger = Logger(subsystem: "ai.openclaw", category: "TalkRealtimeWebRTC")
|
||||
private static let consultToolName = "openclaw_agent_consult"
|
||||
private static let controlToolName = "openclaw_agent_control"
|
||||
private static let defaultOfferURL = "https://api.openai.com/v1/realtime/calls"
|
||||
private static let mediaStreamID = "openclaw-ios-realtime"
|
||||
private static let audioTrackID = "openclaw-ios-audio"
|
||||
private static let dataChannelLabel = "oai-events"
|
||||
private static let toolCallTimeoutSeconds = 12
|
||||
private static let toolResultTimeoutSeconds = 45
|
||||
private static let agentWaitSliceSeconds = 3
|
||||
private static let agentWaitRequestGraceSeconds = 15
|
||||
private static let historyFallbackTimeoutSeconds = 5
|
||||
private static let stillWorkingDelaySeconds = 6
|
||||
private static let assistantPlaybackDrainGraceSeconds = 1.8
|
||||
|
||||
private let gateway: GatewayNodeSession
|
||||
private let sessionKey: String
|
||||
private weak var delegate: TalkRealtimeWebRTCSessionDelegate?
|
||||
|
||||
private var factory: RTCPeerConnectionFactory?
|
||||
private var peerConnection: RTCPeerConnection?
|
||||
private var dataChannel: RTCDataChannel?
|
||||
private var session: TalkRealtimeClientSession?
|
||||
private var toolBuffers: [String: ToolBuffer] = [:]
|
||||
private var activeToolTasks: [String: Task<Void, Never>] = [:]
|
||||
private var activeToolRunIds: [String: String] = [:]
|
||||
private var stopped = false
|
||||
private var timelineStartedAt = ProcessInfo.processInfo.systemUptime
|
||||
private var seenRealtimeEventTypes: Set<String> = []
|
||||
private var loggedFirstServerSpeech = false
|
||||
private var loggedFirstAssistantSignal = false
|
||||
private var assistantAudioActive = false
|
||||
private var assistantAudioFinishTask: Task<Void, Never>?
|
||||
|
||||
private struct ToolBuffer {
|
||||
var name: String
|
||||
var callId: String
|
||||
var args: String
|
||||
}
|
||||
|
||||
private struct AgentWaitResponse: Decodable {
|
||||
let runId: String?
|
||||
let status: String?
|
||||
let startedAt: Double?
|
||||
let endedAt: Double?
|
||||
let error: String?
|
||||
let stopReason: String?
|
||||
let timeoutPhase: String?
|
||||
let providerStarted: Bool?
|
||||
}
|
||||
|
||||
init(gateway: GatewayNodeSession, sessionKey: String, delegate: TalkRealtimeWebRTCSessionDelegate) {
|
||||
self.gateway = gateway
|
||||
self.sessionKey = sessionKey
|
||||
self.delegate = delegate
|
||||
super.init()
|
||||
}
|
||||
|
||||
func start(
|
||||
provider: String?,
|
||||
model: String?,
|
||||
voice: String?,
|
||||
prefetchedSession: TalkRealtimeClientSession? = nil) async throws
|
||||
{
|
||||
self.timelineStartedAt = ProcessInfo.processInfo.systemUptime
|
||||
self.seenRealtimeEventTypes.removeAll()
|
||||
self.loggedFirstServerSpeech = false
|
||||
self.loggedFirstAssistantSignal = false
|
||||
self.assistantAudioActive = false
|
||||
self.assistantAudioFinishTask?.cancel()
|
||||
self.assistantAudioFinishTask = nil
|
||||
self.stopped = false
|
||||
self.trace(
|
||||
"start provider=\(provider ?? "default") model=\(model ?? "default") "
|
||||
+ "voice=\(voice ?? "default") sessionKey=\(self.sessionKey)")
|
||||
self.delegate?.realtimeSession(self, didChangeStatus: "Connecting")
|
||||
let session: TalkRealtimeClientSession
|
||||
if let prefetchedSession {
|
||||
self.trace(
|
||||
"gateway talk.client.create skipped prefetched provider=\(prefetchedSession.provider) "
|
||||
+ "transport=\(prefetchedSession.transport) model=\(prefetchedSession.model ?? "unknown") "
|
||||
+ "voice=\(prefetchedSession.voice ?? "unknown")")
|
||||
session = prefetchedSession
|
||||
} else {
|
||||
session = try await self.createClientSession(provider: provider, model: model, voice: voice)
|
||||
}
|
||||
let sessionModel = session.model ?? "unknown"
|
||||
let sessionVoice = session.voice ?? "unknown"
|
||||
Self.logger.info(
|
||||
"realtime session provider=\(session.provider, privacy: .public) model=\(sessionModel, privacy: .public)")
|
||||
Self.logger.info(
|
||||
"realtime session voice=\(sessionVoice, privacy: .public) transport=\(session.transport, privacy: .public)")
|
||||
try self.checkNotStopped()
|
||||
guard session.isWebRTC else {
|
||||
throw NSError(domain: "TalkRealtimeWebRTC", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Realtime provider returned unsupported transport \(session.transport)",
|
||||
])
|
||||
}
|
||||
self.session = session
|
||||
|
||||
self.trace("configure audio session start")
|
||||
try Self.configureAudioSession()
|
||||
self.trace("configure audio session done")
|
||||
RTCInitializeSSL()
|
||||
let factory = RTCPeerConnectionFactory(
|
||||
encoderFactory: RTCDefaultVideoEncoderFactory(),
|
||||
decoderFactory: RTCDefaultVideoDecoderFactory())
|
||||
self.factory = factory
|
||||
|
||||
let config = RTCConfiguration()
|
||||
config.sdpSemantics = .unifiedPlan
|
||||
config.continualGatheringPolicy = .gatherContinually
|
||||
let constraints = RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil)
|
||||
guard let peer = factory.peerConnection(with: config, constraints: constraints, delegate: self) else {
|
||||
throw NSError(domain: "TalkRealtimeWebRTC", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Failed to create WebRTC peer connection",
|
||||
])
|
||||
}
|
||||
self.peerConnection = peer
|
||||
|
||||
let audioSource = factory.audioSource(with: constraints)
|
||||
let audioTrack = factory.audioTrack(with: audioSource, trackId: Self.audioTrackID)
|
||||
peer.add(audioTrack, streamIds: [Self.mediaStreamID])
|
||||
|
||||
let channelConfig = RTCDataChannelConfiguration()
|
||||
let channel = peer.dataChannel(forLabel: Self.dataChannelLabel, configuration: channelConfig)
|
||||
channel?.delegate = self
|
||||
self.dataChannel = channel
|
||||
|
||||
let offer = try await createOffer(peer: peer)
|
||||
self.trace("local offer created sdpBytes=\(offer.sdp.utf8.count)")
|
||||
try self.checkNotStopped()
|
||||
try await self.setLocalDescription(offer, peer: peer)
|
||||
self.trace("local description set")
|
||||
try self.checkNotStopped()
|
||||
let answerSDP = try await exchangeOffer(offer.sdp, session: session)
|
||||
self.trace("remote answer received sdpBytes=\(answerSDP.utf8.count)")
|
||||
try self.checkNotStopped()
|
||||
let answer = RTCSessionDescription(type: .answer, sdp: answerSDP)
|
||||
try await setRemoteDescription(answer, peer: peer)
|
||||
self.trace("remote description set")
|
||||
try self.checkNotStopped()
|
||||
self.delegate?.realtimeSession(self, didChangeStatus: "Listening")
|
||||
}
|
||||
|
||||
func stop() {
|
||||
let shouldNotify = !self.stopped
|
||||
self.stopped = true
|
||||
self.cancelActiveToolCalls()
|
||||
self.toolBuffers.removeAll()
|
||||
self.dataChannel?.close()
|
||||
self.dataChannel = nil
|
||||
self.peerConnection?.close()
|
||||
self.peerConnection = nil
|
||||
self.factory = nil
|
||||
self.session = nil
|
||||
self.assistantAudioActive = false
|
||||
self.assistantAudioFinishTask?.cancel()
|
||||
self.assistantAudioFinishTask = nil
|
||||
if shouldNotify {
|
||||
self.delegate?.realtimeSessionDidFinish(self)
|
||||
}
|
||||
}
|
||||
|
||||
private func checkNotStopped() throws {
|
||||
if self.stopped {
|
||||
throw CancellationError()
|
||||
}
|
||||
}
|
||||
|
||||
private func elapsedMs() -> Int {
|
||||
max(0, Int((ProcessInfo.processInfo.systemUptime - self.timelineStartedAt) * 1000))
|
||||
}
|
||||
|
||||
private func trace(_ message: String) {
|
||||
GatewayDiagnostics.log("talk.timeline realtime +\(self.elapsedMs())ms \(message)")
|
||||
Self.logger.info("timeline +\(self.elapsedMs(), privacy: .public)ms \(message, privacy: .public)")
|
||||
}
|
||||
|
||||
func cancelResponse() {
|
||||
self.sendRealtimeEvent(["type": "response.cancel"])
|
||||
self.cancelActiveToolCalls()
|
||||
}
|
||||
|
||||
private func cancelActiveToolCalls() {
|
||||
let runIds = Array(Set(activeToolRunIds.values))
|
||||
for task in self.activeToolTasks.values {
|
||||
task.cancel()
|
||||
}
|
||||
self.activeToolTasks.removeAll()
|
||||
self.activeToolRunIds.removeAll()
|
||||
for runId in runIds {
|
||||
Task { [gateway, sessionKey] in
|
||||
let params = ["sessionKey": sessionKey, "runId": runId]
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: params),
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
else { return }
|
||||
_ = try? await gateway.request(method: "chat.abort", paramsJSON: json, timeoutSeconds: 5)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func createClientSession(
|
||||
provider: String?,
|
||||
model: String?,
|
||||
voice: String?) async throws -> TalkRealtimeClientSession
|
||||
{
|
||||
self.trace("gateway talk.client.create start")
|
||||
let startedAt = ProcessInfo.processInfo.systemUptime
|
||||
let params = TalkRealtimeClientCreateParams(provider: provider, model: model, voice: voice)
|
||||
let data = try JSONEncoder().encode(params)
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
let res = try await gateway.request(method: "talk.client.create", paramsJSON: json, timeoutSeconds: 12)
|
||||
let session = try JSONDecoder().decode(TalkRealtimeClientSession.self, from: res)
|
||||
let elapsed = Int((ProcessInfo.processInfo.systemUptime - startedAt) * 1000)
|
||||
self.trace(
|
||||
"gateway talk.client.create done elapsedMs=\(elapsed) "
|
||||
+ "provider=\(session.provider) transport=\(session.transport) "
|
||||
+ "model=\(session.model ?? "unknown") voice=\(session.voice ?? "unknown")")
|
||||
return session
|
||||
}
|
||||
|
||||
private func createOffer(peer: RTCPeerConnection) async throws -> RTCSessionDescription {
|
||||
self.trace("local offer create start")
|
||||
let constraints = RTCMediaConstraints(
|
||||
mandatoryConstraints: [
|
||||
"OfferToReceiveAudio": "true",
|
||||
"OfferToReceiveVideo": "false",
|
||||
],
|
||||
optionalConstraints: nil)
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
peer.offer(for: constraints) { offer, error in
|
||||
if let error {
|
||||
continuation.resume(throwing: error)
|
||||
} else if let offer {
|
||||
continuation.resume(returning: offer)
|
||||
} else {
|
||||
continuation.resume(throwing: NSError(domain: "TalkRealtimeWebRTC", code: 3, userInfo: [
|
||||
NSLocalizedDescriptionKey: "OpenAI realtime offer creation returned no SDP",
|
||||
]))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setLocalDescription(_ description: RTCSessionDescription, peer: RTCPeerConnection) async throws {
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
peer.setLocalDescription(description) { error in
|
||||
if let error {
|
||||
continuation.resume(throwing: error)
|
||||
} else {
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setRemoteDescription(_ description: RTCSessionDescription, peer: RTCPeerConnection) async throws {
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
peer.setRemoteDescription(description) { error in
|
||||
if let error {
|
||||
continuation.resume(throwing: error)
|
||||
} else {
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func exchangeOffer(_ sdp: String, session: TalkRealtimeClientSession) async throws -> String {
|
||||
let rawURL = session.offerUrl ?? Self.defaultOfferURL
|
||||
guard let url = URL(string: rawURL) else {
|
||||
throw NSError(domain: "TalkRealtimeWebRTC", code: 4, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Invalid OpenAI realtime offer URL",
|
||||
])
|
||||
}
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("Bearer \(session.clientSecret)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("application/sdp", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = sdp.data(using: .utf8)
|
||||
for (key, value) in session.offerHeaders ?? [:] {
|
||||
request.setValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
|
||||
self.trace("openai webrtc offer exchange start urlHost=\(url.host ?? "unknown")")
|
||||
let startedAt = ProcessInfo.processInfo.systemUptime
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw NSError(domain: "TalkRealtimeWebRTC", code: 5, userInfo: [
|
||||
NSLocalizedDescriptionKey: "OpenAI realtime offer returned a non-HTTP response",
|
||||
])
|
||||
}
|
||||
let elapsed = Int((ProcessInfo.processInfo.systemUptime - startedAt) * 1000)
|
||||
self.trace("openai webrtc offer exchange response status=\(http.statusCode) elapsedMs=\(elapsed)")
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
let body = String(data: data, encoding: .utf8) ?? ""
|
||||
throw NSError(domain: "TalkRealtimeWebRTC", code: http.statusCode, userInfo: [
|
||||
NSLocalizedDescriptionKey: "OpenAI realtime offer failed: \(http.statusCode) \(body)",
|
||||
])
|
||||
}
|
||||
guard let answer = String(data: data, encoding: .utf8),
|
||||
!answer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
else {
|
||||
throw NSError(domain: "TalkRealtimeWebRTC", code: 6, userInfo: [
|
||||
NSLocalizedDescriptionKey: "OpenAI realtime offer returned an empty SDP answer",
|
||||
])
|
||||
}
|
||||
return answer
|
||||
}
|
||||
|
||||
private func handleRealtimeEvent(_ event: TalkRealtimeServerEvent) {
|
||||
if !self.seenRealtimeEventTypes.contains(event.type) {
|
||||
self.seenRealtimeEventTypes.insert(event.type)
|
||||
self.trace("event first type=\(event.type)")
|
||||
}
|
||||
if self.handleRealtimeAudioStateEvent(event) {
|
||||
return
|
||||
}
|
||||
switch event.type {
|
||||
case "conversation.input_transcript.delta",
|
||||
"conversation.item.input_audio_transcription.delta":
|
||||
if !self.loggedFirstServerSpeech {
|
||||
self.loggedFirstServerSpeech = true
|
||||
self.trace("server speech/transcript first delta")
|
||||
}
|
||||
if let text = event.delta ?? event.transcript {
|
||||
self.delegate?.realtimeSession(self, didReceiveUserTranscript: text)
|
||||
}
|
||||
case "conversation.input_transcript.done",
|
||||
"conversation.item.input_audio_transcription.completed":
|
||||
if let text = event.transcript ?? event.text {
|
||||
self.delegate?.realtimeSession(self, didReceiveUserTranscript: text)
|
||||
}
|
||||
case "conversation.output_transcript.delta",
|
||||
"response.output_text.delta",
|
||||
"response.audio_transcript.delta",
|
||||
"response.output_audio_transcript.delta":
|
||||
if !self.loggedFirstAssistantSignal {
|
||||
self.loggedFirstAssistantSignal = true
|
||||
self.trace("assistant first output signal type=\(event.type)")
|
||||
}
|
||||
if let text = event.delta ?? event.transcript ?? event.text {
|
||||
self.delegate?.realtimeSession(self, didReceiveAssistantTranscript: text)
|
||||
}
|
||||
case "conversation.output_transcript.done",
|
||||
"response.output_text.done",
|
||||
"response.audio_transcript.done",
|
||||
"response.output_audio_transcript.done":
|
||||
if let text = event.transcript ?? event.text {
|
||||
self.delegate?.realtimeSession(self, didReceiveAssistantTranscript: text)
|
||||
}
|
||||
case "response.function_call_arguments.delta":
|
||||
self.bufferToolDelta(event)
|
||||
case "response.output_item.added":
|
||||
self.bufferToolMetadata(event)
|
||||
case "response.function_call_arguments.done",
|
||||
"response.output_item.done",
|
||||
"conversation.item.done":
|
||||
self.handleToolDone(event)
|
||||
case "error":
|
||||
self.delegate?.realtimeSession(self, didChangeStatus: "Realtime error")
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func handleRealtimeAudioStateEvent(_ event: TalkRealtimeServerEvent) -> Bool {
|
||||
switch event.type {
|
||||
case "response.audio.delta", "response.output_audio.delta", "conversation.output_audio.delta":
|
||||
self.markAssistantAudioActive()
|
||||
return true
|
||||
case "response.created":
|
||||
self.trace("response created")
|
||||
self.markAssistantAudioActive()
|
||||
return true
|
||||
case "response.audio.done", "response.output_audio.done", "conversation.output_audio.done", "response.done":
|
||||
self.scheduleAssistantAudioFinished()
|
||||
return true
|
||||
case "input_audio_buffer.speech_started":
|
||||
if self.assistantAudioActive {
|
||||
self.trace("input speech ignored while assistant audio active")
|
||||
return true
|
||||
}
|
||||
if !self.loggedFirstServerSpeech {
|
||||
self.loggedFirstServerSpeech = true
|
||||
self.trace("server detected speech")
|
||||
}
|
||||
self.delegate?.realtimeSession(self, didDetectInputSpeech: true)
|
||||
self.delegate?.realtimeSession(self, didChangeStatus: "Listening")
|
||||
return true
|
||||
case "input_audio_buffer.speech_stopped", "input_audio_buffer.committed":
|
||||
self.delegate?.realtimeSession(self, didDetectInputSpeech: false)
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func markAssistantAudioActive() {
|
||||
self.assistantAudioActive = true
|
||||
self.assistantAudioFinishTask?.cancel()
|
||||
self.assistantAudioFinishTask = nil
|
||||
self.delegate?.realtimeSession(self, didDetectInputSpeech: false)
|
||||
self.delegate?.realtimeSession(self, didChangeStatus: "Speaking")
|
||||
}
|
||||
|
||||
private func scheduleAssistantAudioFinished() {
|
||||
self.assistantAudioFinishTask?.cancel()
|
||||
self.assistantAudioFinishTask = Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(
|
||||
nanoseconds: UInt64(Self.assistantPlaybackDrainGraceSeconds * 1_000_000_000))
|
||||
guard let self, !Task.isCancelled, !self.stopped else { return }
|
||||
self.assistantAudioActive = false
|
||||
self.assistantAudioFinishTask = nil
|
||||
self.delegate?.realtimeSession(self, didChangeStatus: "Listening")
|
||||
}
|
||||
}
|
||||
|
||||
private func toolBufferKey(for event: TalkRealtimeServerEvent) -> String? {
|
||||
event.resolvedItemId ?? event.resolvedCallId
|
||||
}
|
||||
|
||||
private func bufferToolMetadata(_ event: TalkRealtimeServerEvent) {
|
||||
guard Self.isSupportedToolName(event.resolvedName), let key = toolBufferKey(for: event) else { return }
|
||||
var buffer = self.toolBuffers[key] ?? ToolBuffer(name: "", callId: "", args: "")
|
||||
buffer.name = event.resolvedName ?? buffer.name
|
||||
buffer.callId = event.resolvedCallId ?? buffer.callId
|
||||
if let arguments = event.resolvedArguments, !arguments.isEmpty {
|
||||
buffer.args = arguments
|
||||
}
|
||||
self.toolBuffers[key] = buffer
|
||||
}
|
||||
|
||||
private func bufferToolDelta(_ event: TalkRealtimeServerEvent) {
|
||||
guard let key = toolBufferKey(for: event) else { return }
|
||||
var buffer = self.toolBuffers[key] ?? ToolBuffer(
|
||||
name: event.resolvedName ?? "",
|
||||
callId: event.resolvedCallId ?? "",
|
||||
args: "")
|
||||
buffer.name = buffer.name.isEmpty ? (event.resolvedName ?? "") : buffer.name
|
||||
buffer.callId = buffer.callId.isEmpty ? (event.resolvedCallId ?? "") : buffer.callId
|
||||
buffer.args += event.delta ?? ""
|
||||
self.toolBuffers[key] = buffer
|
||||
}
|
||||
|
||||
private func handleToolDone(_ event: TalkRealtimeServerEvent) {
|
||||
guard let key = toolBufferKey(for: event) else { return }
|
||||
let buffered = self.toolBuffers[key]
|
||||
let name = buffered?.name.isEmpty == false ? buffered?.name : event.resolvedName
|
||||
let callId = buffered?.callId.isEmpty == false ? buffered?.callId : event.resolvedCallId
|
||||
let args = buffered?.args.isEmpty == false ? buffered?.args : event.resolvedArguments
|
||||
guard Self.isSupportedToolName(name), let callId, !callId.isEmpty else { return }
|
||||
guard args?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else {
|
||||
self.bufferToolMetadata(event)
|
||||
return
|
||||
}
|
||||
guard self.activeToolTasks[callId] == nil else { return }
|
||||
self.toolBuffers.removeValue(forKey: key)
|
||||
self.trace("tool call ready name=\(name ?? "unknown") callId=\(callId) argsBytes=\((args ?? "").utf8.count)")
|
||||
self.assistantAudioActive = false
|
||||
self.assistantAudioFinishTask?.cancel()
|
||||
self.assistantAudioFinishTask = nil
|
||||
self.delegate?.realtimeSession(
|
||||
self,
|
||||
didChangeStatus: name == Self.controlToolName ? "Updating OpenClaw" : "Asking OpenClaw")
|
||||
let task = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
if name == Self.controlToolName {
|
||||
await self.submitControlToolCall(callId: callId, argsJSON: args ?? "{}")
|
||||
} else {
|
||||
await self.submitConsultToolCall(callId: callId, argsJSON: args ?? "{}")
|
||||
}
|
||||
}
|
||||
self.activeToolTasks[callId] = task
|
||||
}
|
||||
|
||||
private static func isSupportedToolName(_ name: String?) -> Bool {
|
||||
name == self.consultToolName || name == self.controlToolName
|
||||
}
|
||||
|
||||
private func submitConsultToolCall(callId: String, argsJSON: String) async {
|
||||
self.trace("tool call submit start callId=\(callId) argsBytes=\(argsJSON.utf8.count)")
|
||||
let statusTask = Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(nanoseconds: UInt64(Self.stillWorkingDelaySeconds) * 1_000_000_000)
|
||||
guard let self, !Task.isCancelled, !self.stopped else { return }
|
||||
self.delegate?.realtimeSession(self, didChangeStatus: "Still asking OpenClaw")
|
||||
}
|
||||
defer {
|
||||
statusTask.cancel()
|
||||
self.activeToolTasks[callId] = nil
|
||||
self.activeToolRunIds[callId] = nil
|
||||
}
|
||||
do {
|
||||
let args = try Self.decodeJSONObject(argsJSON)
|
||||
let params: [String: Any] = [
|
||||
"sessionKey": sessionKey,
|
||||
"callId": callId,
|
||||
"name": Self.consultToolName,
|
||||
"args": args,
|
||||
]
|
||||
let historySince = Date().timeIntervalSince1970
|
||||
let data = try JSONSerialization.data(withJSONObject: params)
|
||||
guard let json = String(data: data, encoding: .utf8) else {
|
||||
throw NSError(domain: "TalkRealtimeWebRTC", code: 7, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Failed to encode realtime tool call",
|
||||
])
|
||||
}
|
||||
let stream = await gateway.subscribeServerEvents(bufferingNewest: 200)
|
||||
self.trace("tool call gateway request start callId=\(callId)")
|
||||
let requestStartedAt = ProcessInfo.processInfo.systemUptime
|
||||
let res = try await gateway.request(
|
||||
method: "talk.client.toolCall",
|
||||
paramsJSON: json,
|
||||
timeoutSeconds: Self.toolCallTimeoutSeconds)
|
||||
let response = try JSONDecoder().decode(TalkRealtimeToolCallResponse.self, from: res)
|
||||
let requestElapsed = Int((ProcessInfo.processInfo.systemUptime - requestStartedAt) * 1000)
|
||||
guard let runId = response.runId ?? response.idempotencyKey else {
|
||||
throw NSError(domain: "TalkRealtimeWebRTC", code: 8, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Gateway did not return a realtime tool run id",
|
||||
])
|
||||
}
|
||||
self.trace("tool call gateway request done callId=\(callId) runId=\(runId) elapsedMs=\(requestElapsed)")
|
||||
self.activeToolRunIds[callId] = runId
|
||||
if Task.isCancelled || self.stopped {
|
||||
await self.abortChatRun(runId: runId)
|
||||
return
|
||||
}
|
||||
let result = try await waitForChatResult(
|
||||
runId: runId,
|
||||
stream: stream,
|
||||
since: historySince,
|
||||
timeoutSeconds: Self.toolResultTimeoutSeconds)
|
||||
if Task.isCancelled || self.stopped { return }
|
||||
self.trace("tool call chat result ready callId=\(callId) runId=\(runId) chars=\(result.count)")
|
||||
self.submitToolResult(callId: callId, result: ["result": result])
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
if Task.isCancelled || self.stopped { return }
|
||||
Self.logger.error("realtime tool call failed: \(error.localizedDescription, privacy: .public)")
|
||||
self.trace("tool call failed callId=\(callId) error=\(error.localizedDescription)")
|
||||
if let runId = activeToolRunIds[callId] {
|
||||
await self.abortChatRun(runId: runId)
|
||||
}
|
||||
self.delegate?.realtimeSession(self, didChangeStatus: "OpenClaw unavailable")
|
||||
let fallbackMessage = [
|
||||
"OpenClaw consult did not finish quickly enough.",
|
||||
"Give a brief spoken fallback from the realtime conversation",
|
||||
"and ask the user to try again if they need OpenClaw-specific context.",
|
||||
].joined(separator: " ")
|
||||
self.submitToolResult(callId: callId, result: [
|
||||
"error": fallbackMessage,
|
||||
])
|
||||
}
|
||||
guard !Task.isCancelled, !self.stopped else { return }
|
||||
if !self.assistantAudioActive {
|
||||
self.delegate?.realtimeSession(self, didChangeStatus: "Listening")
|
||||
}
|
||||
}
|
||||
|
||||
private func submitControlToolCall(callId: String, argsJSON: String) async {
|
||||
self.trace("control tool submit start callId=\(callId) argsBytes=\(argsJSON.utf8.count)")
|
||||
defer { self.activeToolTasks[callId] = nil }
|
||||
do {
|
||||
let params = try Self.controlParams(sessionKey: self.sessionKey, argsJSON: argsJSON)
|
||||
let data = try JSONSerialization.data(withJSONObject: params)
|
||||
guard let json = String(data: data, encoding: .utf8) else {
|
||||
throw NSError(domain: "TalkRealtimeWebRTC", code: 19, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Failed to encode realtime control call",
|
||||
])
|
||||
}
|
||||
let res = try await gateway.request(
|
||||
method: "talk.client.steer",
|
||||
paramsJSON: json,
|
||||
timeoutSeconds: Self.toolCallTimeoutSeconds)
|
||||
let message = Self.controlResultMessage(from: res) ?? "OpenClaw updated the active run."
|
||||
self.trace("control tool gateway request done callId=\(callId) messageBytes=\(message.utf8.count)")
|
||||
self.submitToolResult(callId: callId, result: ["result": message])
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
if Task.isCancelled || self.stopped { return }
|
||||
Self.logger.error("realtime control tool failed: \(error.localizedDescription, privacy: .public)")
|
||||
self.trace("control tool failed callId=\(callId) error=\(error.localizedDescription)")
|
||||
self.submitToolResult(callId: callId, result: [
|
||||
"error": "OpenClaw could not update the active run.",
|
||||
])
|
||||
}
|
||||
guard !Task.isCancelled, !self.stopped else { return }
|
||||
if !self.assistantAudioActive {
|
||||
self.delegate?.realtimeSession(self, didChangeStatus: "Listening")
|
||||
}
|
||||
}
|
||||
|
||||
private static func controlParams(sessionKey: String, argsJSON: String) throws -> [String: Any] {
|
||||
let args = try Self.decodeJSONObject(argsJSON)
|
||||
let record = args as? [String: Any] ?? [:]
|
||||
let text = Self.nonEmptyString(record["text"])
|
||||
?? Self.nonEmptyString(record["message"])
|
||||
?? Self.nonEmptyString(record["request"])
|
||||
?? Self.nonEmptyString(record["query"])
|
||||
guard let text else {
|
||||
throw NSError(domain: "TalkRealtimeWebRTC", code: 20, userInfo: [
|
||||
NSLocalizedDescriptionKey: "OpenClaw control tool call missing text",
|
||||
])
|
||||
}
|
||||
var params: [String: Any] = [
|
||||
"sessionKey": sessionKey,
|
||||
"text": text,
|
||||
]
|
||||
if let mode = Self.nonEmptyString(record["mode"]) {
|
||||
params["mode"] = mode
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
private static func nonEmptyString(_ value: Any?) -> String? {
|
||||
guard let raw = value as? String else { return nil }
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private static func controlResultMessage(from data: Data) -> String? {
|
||||
guard let object = try? JSONSerialization.jsonObject(with: data),
|
||||
let record = object as? [String: Any]
|
||||
else { return nil }
|
||||
return Self.nonEmptyString(record["message"])
|
||||
}
|
||||
|
||||
private func abortChatRun(runId: String) async {
|
||||
let params = ["sessionKey": sessionKey, "runId": runId]
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: params),
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
else { return }
|
||||
_ = try? await self.gateway.request(method: "chat.abort", paramsJSON: json, timeoutSeconds: 5)
|
||||
}
|
||||
|
||||
private static func decodeJSONObject(_ json: String) throws -> Any {
|
||||
let trimmed = json.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return [:] }
|
||||
let data = Data(trimmed.utf8)
|
||||
return try JSONSerialization.jsonObject(with: data)
|
||||
}
|
||||
|
||||
private func waitForChatResult(
|
||||
runId: String,
|
||||
stream: AsyncStream<EventFrame>,
|
||||
since: Double,
|
||||
timeoutSeconds: Int = 120) async throws -> String
|
||||
{
|
||||
let currentSessionKey = self.sessionKey
|
||||
return try await withThrowingTaskGroup(of: String.self) { group in
|
||||
group.addTask { [runId, currentSessionKey] in
|
||||
for await evt in stream {
|
||||
guard evt.event == "chat", let payload = evt.payload else { continue }
|
||||
guard let chatEvent = try? GatewayPayloadDecoding.decode(
|
||||
payload,
|
||||
as: OpenClawChatEventPayload.self)
|
||||
else {
|
||||
continue
|
||||
}
|
||||
guard chatEvent.runId == runId else { continue }
|
||||
if let eventSessionKey = chatEvent.sessionKey,
|
||||
!Self.matchesSessionKey(eventSessionKey, currentSessionKey)
|
||||
{
|
||||
continue
|
||||
}
|
||||
await MainActor.run {
|
||||
self.trace("chat event runId=\(runId) state=\(chatEvent.state ?? "unknown")")
|
||||
}
|
||||
if chatEvent.state == "final" {
|
||||
return OpenClawChatEventText.assistantText(from: chatEvent) ?? "OpenClaw finished with no text."
|
||||
}
|
||||
if chatEvent.state == "aborted" {
|
||||
throw NSError(domain: "TalkRealtimeWebRTC", code: 9, userInfo: [
|
||||
NSLocalizedDescriptionKey: "OpenClaw realtime tool call aborted",
|
||||
])
|
||||
}
|
||||
if chatEvent.state == "error" {
|
||||
throw NSError(domain: "TalkRealtimeWebRTC", code: 10, userInfo: [
|
||||
NSLocalizedDescriptionKey: "OpenClaw realtime tool call failed",
|
||||
])
|
||||
}
|
||||
}
|
||||
throw NSError(domain: "TalkRealtimeWebRTC", code: 11, userInfo: [
|
||||
NSLocalizedDescriptionKey: "OpenClaw realtime tool event stream ended",
|
||||
])
|
||||
}
|
||||
group.addTask { [gateway, sessionKey] in
|
||||
try await Self.waitForAgentResult(
|
||||
gateway: gateway,
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
since: since,
|
||||
timeoutSeconds: timeoutSeconds)
|
||||
}
|
||||
group.addTask {
|
||||
try await Task.sleep(nanoseconds: UInt64(timeoutSeconds) * 1_000_000_000)
|
||||
throw NSError(domain: "TalkRealtimeWebRTC", code: 12, userInfo: [
|
||||
NSLocalizedDescriptionKey: "OpenClaw realtime tool call timed out",
|
||||
])
|
||||
}
|
||||
guard let result = try await group.next() else {
|
||||
throw NSError(domain: "TalkRealtimeWebRTC", code: 13, userInfo: [
|
||||
NSLocalizedDescriptionKey: "OpenClaw realtime tool call did not finish",
|
||||
])
|
||||
}
|
||||
group.cancelAll()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func matchesSessionKey(_ incoming: String, _ current: String) -> Bool {
|
||||
let incoming = incoming.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let current = current.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if incoming == current { return true }
|
||||
return (incoming == "agent:main:main" && current == "main") ||
|
||||
(incoming == "main" && current == "agent:main:main")
|
||||
}
|
||||
|
||||
private static func waitForAgentResult(
|
||||
gateway: GatewayNodeSession,
|
||||
sessionKey: String,
|
||||
runId: String,
|
||||
since: Double,
|
||||
timeoutSeconds: Int) async throws -> String
|
||||
{
|
||||
let deadline = Date().addingTimeInterval(TimeInterval(timeoutSeconds))
|
||||
var sawProviderStart = false
|
||||
while Date() < deadline {
|
||||
let remaining = max(1, Int(ceil(deadline.timeIntervalSinceNow)))
|
||||
let waitSeconds = min(Self.agentWaitSliceSeconds, remaining)
|
||||
let wait = try await Self.agentWait(
|
||||
gateway: gateway,
|
||||
runId: runId,
|
||||
timeoutSeconds: waitSeconds)
|
||||
let status = wait.status?.lowercased() ?? "unknown"
|
||||
if wait.startedAt != nil || wait.providerStarted == true {
|
||||
sawProviderStart = true
|
||||
}
|
||||
GatewayDiagnostics.log(
|
||||
"talk.timeline realtime agent.wait runId=\(runId) status=\(status) "
|
||||
+
|
||||
"phase=\(wait.timeoutPhase ?? "unknown") "
|
||||
+
|
||||
"providerStarted=\(wait.providerStarted.map(String.init) ?? "unknown")")
|
||||
switch status {
|
||||
case "ok":
|
||||
if let text = try await Self.waitForAssistantTextFromHistory(
|
||||
gateway: gateway,
|
||||
sessionKey: sessionKey,
|
||||
since: since,
|
||||
timeoutSeconds: Self.historyFallbackTimeoutSeconds)
|
||||
{
|
||||
return text
|
||||
}
|
||||
case "error":
|
||||
throw NSError(domain: "TalkRealtimeWebRTC", code: 14, userInfo: [
|
||||
NSLocalizedDescriptionKey: wait.error ?? "OpenClaw realtime tool call failed",
|
||||
])
|
||||
case "aborted", "cancelled", "canceled":
|
||||
throw NSError(domain: "TalkRealtimeWebRTC", code: 15, userInfo: [
|
||||
NSLocalizedDescriptionKey: wait.stopReason ?? "OpenClaw realtime tool call aborted",
|
||||
])
|
||||
case "timeout":
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
let phase = sawProviderStart ? "provider" : "queue"
|
||||
throw NSError(domain: "TalkRealtimeWebRTC", code: 16, userInfo: [
|
||||
NSLocalizedDescriptionKey: "OpenClaw realtime tool call timed out in \(phase)",
|
||||
])
|
||||
}
|
||||
|
||||
private static func agentWait(
|
||||
gateway: GatewayNodeSession,
|
||||
runId: String,
|
||||
timeoutSeconds: Int) async throws -> AgentWaitResponse
|
||||
{
|
||||
let timeoutMs = max(1, timeoutSeconds) * 1000
|
||||
let params: [String: Any] = [
|
||||
"runId": runId,
|
||||
"timeoutMs": timeoutMs,
|
||||
]
|
||||
let data = try JSONSerialization.data(withJSONObject: params)
|
||||
guard let json = String(data: data, encoding: .utf8) else {
|
||||
throw NSError(domain: "TalkRealtimeWebRTC", code: 17, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Failed to encode OpenClaw wait request",
|
||||
])
|
||||
}
|
||||
let response = try await gateway.request(
|
||||
method: "agent.wait",
|
||||
paramsJSON: json,
|
||||
timeoutSeconds: timeoutSeconds + Self.agentWaitRequestGraceSeconds)
|
||||
return try JSONDecoder().decode(AgentWaitResponse.self, from: response)
|
||||
}
|
||||
|
||||
private static func waitForAssistantTextFromHistory(
|
||||
gateway: GatewayNodeSession,
|
||||
sessionKey: String,
|
||||
since: Double,
|
||||
timeoutSeconds: Int) async throws -> String?
|
||||
{
|
||||
let deadline = Date().addingTimeInterval(TimeInterval(timeoutSeconds))
|
||||
while Date() < deadline {
|
||||
if let text = try await Self.latestAssistantTextFromHistory(
|
||||
gateway: gateway,
|
||||
sessionKey: sessionKey,
|
||||
since: since)
|
||||
{
|
||||
return text
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func latestAssistantTextFromHistory(
|
||||
gateway: GatewayNodeSession,
|
||||
sessionKey: String,
|
||||
since: Double) async throws -> String?
|
||||
{
|
||||
let params: [String: Any] = ["sessionKey": sessionKey]
|
||||
let data = try JSONSerialization.data(withJSONObject: params)
|
||||
guard let json = String(data: data, encoding: .utf8) else {
|
||||
throw NSError(domain: "TalkRealtimeWebRTC", code: 18, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Failed to encode OpenClaw history request",
|
||||
])
|
||||
}
|
||||
let response = try await gateway.request(method: "chat.history", paramsJSON: json, timeoutSeconds: 15)
|
||||
let history = try JSONDecoder().decode(OpenClawChatHistoryPayload.self, from: response)
|
||||
let messages = history.messages ?? []
|
||||
let decoded: [OpenClawChatMessage] = messages.compactMap { item in
|
||||
guard let data = try? JSONEncoder().encode(item) else { return nil }
|
||||
return try? JSONDecoder().decode(OpenClawChatMessage.self, from: data)
|
||||
}
|
||||
let assistant = decoded.last { message in
|
||||
guard message.role == "assistant" else { return false }
|
||||
guard let timestamp = message.timestamp else { return false }
|
||||
return TalkHistoryTimestamp.isAfter(timestamp, sinceSeconds: since)
|
||||
}
|
||||
guard let assistant else { return nil }
|
||||
let text = assistant.content.compactMap(\.text).joined(separator: "\n")
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private func submitToolResult(callId: String, result: [String: String]) {
|
||||
guard let output = Self.encodeJSONString(result) else { return }
|
||||
self.trace("tool result send callId=\(callId) outputBytes=\(output.utf8.count)")
|
||||
self.sendRealtimeEvent([
|
||||
"type": "conversation.item.create",
|
||||
"item": [
|
||||
"type": "function_call_output",
|
||||
"call_id": callId,
|
||||
"output": output,
|
||||
],
|
||||
])
|
||||
self.sendRealtimeEvent(["type": "response.create"])
|
||||
}
|
||||
|
||||
private static func encodeJSONString(_ value: Any) -> String? {
|
||||
guard JSONSerialization.isValidJSONObject(value) else { return nil }
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: value) else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
private func sendRealtimeEvent(_ event: [String: Any]) {
|
||||
guard
|
||||
let channel = dataChannel,
|
||||
channel.readyState == .open,
|
||||
let json = Self.encodeJSONString(event),
|
||||
let data = json.data(using: .utf8)
|
||||
else { return }
|
||||
channel.sendData(RTCDataBuffer(data: data, isBinary: false))
|
||||
if let type = event["type"] as? String {
|
||||
self.trace("client event sent type=\(type)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func configureAudioSession() throws {
|
||||
let config = RTCAudioSessionConfiguration.webRTC()
|
||||
config.category = AVAudioSession.Category.playAndRecord.rawValue
|
||||
config.mode = AVAudioSession.Mode.default.rawValue
|
||||
config.categoryOptions = [
|
||||
.allowBluetoothHFP,
|
||||
.defaultToSpeaker,
|
||||
]
|
||||
config.sampleRate = 48000
|
||||
config.ioBufferDuration = 0.01
|
||||
RTCAudioSessionConfiguration.setWebRTC(config)
|
||||
|
||||
let session = RTCAudioSession.sharedInstance()
|
||||
session.lockForConfiguration()
|
||||
defer { session.unlockForConfiguration() }
|
||||
|
||||
session.ignoresPreferredAttributeConfigurationErrors = true
|
||||
try session.setConfiguration(config, active: true)
|
||||
try? session.overrideOutputAudioPort(.speaker)
|
||||
}
|
||||
}
|
||||
|
||||
extension TalkRealtimeWebRTCSession: RTCPeerConnectionDelegate {
|
||||
nonisolated func peerConnection(_: RTCPeerConnection, didChange _: RTCSignalingState) {}
|
||||
nonisolated func peerConnection(_: RTCPeerConnection, didAdd stream: RTCMediaStream) {
|
||||
Task { @MainActor in
|
||||
self
|
||||
.trace(
|
||||
"remote stream added audioTracks=\(stream.audioTracks.count) "
|
||||
+ "videoTracks=\(stream.videoTracks.count)")
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func peerConnection(_: RTCPeerConnection, didRemove _: RTCMediaStream) {}
|
||||
nonisolated func peerConnectionShouldNegotiate(_: RTCPeerConnection) {}
|
||||
nonisolated func peerConnection(_: RTCPeerConnection, didChange newState: RTCIceConnectionState) {
|
||||
Task { @MainActor in
|
||||
guard !self.stopped else { return }
|
||||
switch newState {
|
||||
case .connected, .completed:
|
||||
if !self.assistantAudioActive {
|
||||
self.delegate?.realtimeSession(self, didChangeStatus: "Listening")
|
||||
}
|
||||
case .disconnected:
|
||||
self.delegate?.realtimeSession(self, didChangeStatus: "Reconnecting")
|
||||
case .failed, .closed:
|
||||
self.delegate?.realtimeSession(self, didChangeStatus: "Realtime disconnected")
|
||||
self.stop()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func peerConnection(_: RTCPeerConnection, didChange _: RTCIceGatheringState) {}
|
||||
nonisolated func peerConnection(_: RTCPeerConnection, didGenerate _: RTCIceCandidate) {}
|
||||
nonisolated func peerConnection(_: RTCPeerConnection, didRemove _: [RTCIceCandidate]) {}
|
||||
nonisolated func peerConnection(_: RTCPeerConnection, didOpen dataChannel: RTCDataChannel) {
|
||||
Task { @MainActor in
|
||||
self.dataChannel = dataChannel
|
||||
dataChannel.delegate = self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension TalkRealtimeWebRTCSession: RTCDataChannelDelegate {
|
||||
nonisolated func dataChannelDidChangeState(_ dataChannel: RTCDataChannel) {
|
||||
Task { @MainActor in
|
||||
guard !self.stopped else { return }
|
||||
if dataChannel.readyState == .open {
|
||||
if !self.assistantAudioActive {
|
||||
self.delegate?.realtimeSession(self, didChangeStatus: "Listening")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func dataChannel(_: RTCDataChannel, didReceiveMessageWith buffer: RTCDataBuffer) {
|
||||
guard !buffer.isBinary else { return }
|
||||
let data = buffer.data
|
||||
Task { @MainActor in
|
||||
guard !self.stopped else { return }
|
||||
do {
|
||||
let event = try JSONDecoder().decode(TalkRealtimeServerEvent.self, from: data)
|
||||
self.handleRealtimeEvent(event)
|
||||
} catch {
|
||||
Self.logger
|
||||
.debug("ignored realtime event decode failure: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,14 @@ struct VoiceTab: View {
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
if self.appModel.talkMode.gatewayTalkPermissionState.requiresTalkPermissionAction {
|
||||
Section {
|
||||
TalkPermissionPromptView(style: .card)
|
||||
.listRowInsets(EdgeInsets(top: 10, leading: 16, bottom: 10, trailing: 16))
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Status") {
|
||||
LabeledContent("Voice Wake", value: self.voiceWakeEnabled ? "Enabled" : "Disabled")
|
||||
LabeledContent("Listener", value: self.voiceWake.isListening ? "Listening" : "Idle")
|
||||
@@ -16,6 +24,9 @@ struct VoiceTab: View {
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
LabeledContent("Talk Mode", value: self.talkEnabled ? "Enabled" : "Disabled")
|
||||
LabeledContent(
|
||||
"Talk Permission",
|
||||
value: self.appModel.talkMode.gatewayTalkPermissionState.statusLabel)
|
||||
}
|
||||
|
||||
Section("Notes") {
|
||||
|
||||
@@ -75,8 +75,9 @@ Sources/Status/VoiceWakeToast.swift
|
||||
Sources/Voice/TalkDefaults.swift
|
||||
Sources/Voice/TalkModeGatewayConfig.swift
|
||||
Sources/Voice/TalkModeManager.swift
|
||||
Sources/Voice/TalkOrbOverlay.swift
|
||||
Sources/Voice/RealtimeTalkRelaySession.swift
|
||||
Sources/Voice/TalkPermissionPromptView.swift
|
||||
Sources/Voice/TalkRealtimeClientSession.swift
|
||||
Sources/Voice/TalkRealtimeWebRTCSession.swift
|
||||
Sources/Voice/TalkSpeechLocale.swift
|
||||
Sources/Voice/VoiceTab.swift
|
||||
Sources/Voice/VoiceWakeManager.swift
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import OpenClawKit
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import Testing
|
||||
import UIKit
|
||||
@testable import OpenClaw
|
||||
@@ -52,6 +52,7 @@ import UIKit
|
||||
#expect(commands.contains(OpenClawLocationCommand.get.rawValue))
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func currentCommandsExcludeDangerousSystemExecCommands() {
|
||||
withUserDefaults([
|
||||
"node.instanceId": "ios-test",
|
||||
@@ -87,17 +88,31 @@ import UIKit
|
||||
#expect(withoutApprovalScope.scopes.contains("operator.write"))
|
||||
#expect(!withoutApprovalScope.scopes.contains("operator.approvals"))
|
||||
#expect(withoutApprovalScope.scopes.contains("operator.talk.secrets"))
|
||||
#expect(!withoutApprovalScope.scopesAreExplicit)
|
||||
|
||||
#expect(withApprovalScope.scopes.contains("operator.approvals"))
|
||||
}
|
||||
|
||||
@Test @MainActor func operatorTalkPermissionUpgradeUsesExplicitScopes() {
|
||||
let appModel = NodeAppModel()
|
||||
let options = appModel._test_makeOperatorConnectOptions(
|
||||
clientId: "openclaw-ios",
|
||||
displayName: "OpenClaw iOS",
|
||||
includeApprovalScope: false,
|
||||
forceExplicitScopes: true)
|
||||
|
||||
#expect(options.scopesAreExplicit)
|
||||
#expect(options.scopes.contains("operator.read"))
|
||||
#expect(options.scopes.contains("operator.write"))
|
||||
#expect(options.scopes.contains("operator.talk.secrets"))
|
||||
}
|
||||
|
||||
@Test func operatorApprovalScopeRequestsStayBackwardCompatible() {
|
||||
#expect(
|
||||
!NodeAppModel._test_shouldRequestOperatorApprovalScope(
|
||||
token: nil,
|
||||
password: nil,
|
||||
storedOperatorScopes: ["operator.read", "operator.write", "operator.talk.secrets"])
|
||||
)
|
||||
storedOperatorScopes: ["operator.read", "operator.write", "operator.talk.secrets"]))
|
||||
#expect(
|
||||
NodeAppModel._test_shouldRequestOperatorApprovalScope(
|
||||
token: nil,
|
||||
@@ -107,14 +122,12 @@ import UIKit
|
||||
"operator.read",
|
||||
"operator.write",
|
||||
"operator.talk.secrets",
|
||||
])
|
||||
)
|
||||
]))
|
||||
#expect(
|
||||
NodeAppModel._test_shouldRequestOperatorApprovalScope(
|
||||
token: "shared-token",
|
||||
password: nil,
|
||||
storedOperatorScopes: [])
|
||||
)
|
||||
storedOperatorScopes: []))
|
||||
}
|
||||
|
||||
@Test @MainActor func loadLastConnectionReadsSavedValues() {
|
||||
@@ -134,7 +147,11 @@ import UIKit
|
||||
useTLS: true,
|
||||
stableID: "manual|gateway.example.com|443")
|
||||
let loaded = GatewaySettingsStore.loadLastGatewayConnection()
|
||||
#expect(loaded == .manual(host: "gateway.example.com", port: 443, useTLS: true, stableID: "manual|gateway.example.com|443"))
|
||||
#expect(loaded == .manual(
|
||||
host: "gateway.example.com",
|
||||
port: 443,
|
||||
useTLS: true,
|
||||
stableID: "manual|gateway.example.com|443"))
|
||||
}
|
||||
|
||||
@Test @MainActor func loadLastConnectionReturnsNilForInvalidData() {
|
||||
|
||||
@@ -48,6 +48,46 @@ import Testing
|
||||
#expect(parsed.realtimeVoiceId == "marin")
|
||||
}
|
||||
|
||||
@Test func infersRealtimeProviderWhenProviderMapHasSingleEntry() {
|
||||
let config: [String: Any] = [
|
||||
"talk": [
|
||||
"realtime": [
|
||||
"mode": "realtime",
|
||||
"transport": "webrtc",
|
||||
"providers": [
|
||||
"openai": [
|
||||
"model": "gpt-realtime-2",
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
let parsed = TalkModeGatewayConfigParser.parse(
|
||||
config: config,
|
||||
defaultProvider: "elevenlabs",
|
||||
defaultModelIdFallback: "eleven_v3",
|
||||
defaultRealtimeModelIdFallback: "gpt-realtime-2",
|
||||
defaultSilenceTimeoutMs: 900)
|
||||
|
||||
#expect(parsed.executionMode == .realtimeClient)
|
||||
#expect(parsed.realtimeProvider == "openai")
|
||||
#expect(parsed.realtimeModelId == "gpt-realtime-2")
|
||||
}
|
||||
|
||||
@Test func formatsGenericRealtimeVoiceModeWithoutNativeProviderFallback() {
|
||||
let descriptor = TalkVoiceModeDescriptorBuilder.build(
|
||||
providerId: "realtime",
|
||||
providerLabel: "Realtime Voice",
|
||||
modelId: "gpt-realtime-2",
|
||||
voiceId: nil,
|
||||
transport: "webrtc",
|
||||
isRealtime: true)
|
||||
|
||||
#expect(descriptor.title == "Realtime Voice")
|
||||
#expect(descriptor.subtitle == "Native WebRTC • gpt-realtime-2")
|
||||
}
|
||||
|
||||
@Test func defaultsOpenAIRealtimeModelWhenProviderOmitsModel() {
|
||||
let config: [String: Any] = [
|
||||
"talk": [
|
||||
@@ -79,7 +119,60 @@ import Testing
|
||||
#expect(TalkModeRealtimeVoiceSelection.resolvedOverride("unknown") == nil)
|
||||
}
|
||||
|
||||
@Test func leavesNativeModeWhenRealtimeTransportIsNotGatewayRelay() {
|
||||
@Test func formatsOpenAIRealtimeVoiceMode() {
|
||||
let descriptor = TalkVoiceModeDescriptorBuilder.build(
|
||||
providerId: "openai",
|
||||
providerLabel: "OpenAI",
|
||||
modelId: "gpt-realtime-2",
|
||||
voiceId: "marin",
|
||||
transport: "webrtc",
|
||||
isRealtime: true)
|
||||
|
||||
#expect(descriptor.title == "GPT Realtime 2.0")
|
||||
#expect(descriptor.subtitle == "Native WebRTC • Marin")
|
||||
#expect(descriptor.accessibilityValue == "GPT Realtime 2.0, Native WebRTC • Marin")
|
||||
}
|
||||
|
||||
@Test func formatsGatewayRelayRealtimeVoiceMode() {
|
||||
let descriptor = TalkVoiceModeDescriptorBuilder.build(
|
||||
providerId: "google",
|
||||
providerLabel: "Google Live Voice",
|
||||
modelId: "gemini-live-2.5-flash-preview",
|
||||
voiceId: nil,
|
||||
transport: "gateway-relay",
|
||||
isRealtime: true)
|
||||
|
||||
#expect(descriptor.title == "Google Live Voice")
|
||||
#expect(descriptor.subtitle == "Gateway Relay • gemini-live-2.5-flash-preview")
|
||||
}
|
||||
|
||||
@Test func formatsElevenLabsVoiceMode() {
|
||||
let descriptor = TalkVoiceModeDescriptorBuilder.build(
|
||||
providerId: "elevenlabs",
|
||||
providerLabel: "ElevenLabs",
|
||||
modelId: "eleven_v3",
|
||||
voiceId: "voice-id",
|
||||
transport: "native",
|
||||
isRealtime: false)
|
||||
|
||||
#expect(descriptor.title == "ElevenLabs")
|
||||
#expect(descriptor.subtitle == "Native • eleven_v3 • voice-id")
|
||||
}
|
||||
|
||||
@Test func formatsSystemVoiceFallbackMode() {
|
||||
let descriptor = TalkVoiceModeDescriptorBuilder.build(
|
||||
providerId: "system",
|
||||
providerLabel: "iOS System Voice",
|
||||
modelId: nil,
|
||||
voiceId: "en-US",
|
||||
transport: "native",
|
||||
isRealtime: false)
|
||||
|
||||
#expect(descriptor.title == "iOS System Voice")
|
||||
#expect(descriptor.subtitle == "Native • en-US")
|
||||
}
|
||||
|
||||
@Test func usesRealtimeClientModeForWebRTCTransport() {
|
||||
let config: [String: Any] = [
|
||||
"talk": [
|
||||
"realtime": [
|
||||
@@ -97,7 +190,7 @@ import Testing
|
||||
defaultRealtimeModelIdFallback: "gpt-realtime-2",
|
||||
defaultSilenceTimeoutMs: 900)
|
||||
|
||||
#expect(parsed.executionMode == .native)
|
||||
#expect(parsed.executionMode == .realtimeClient)
|
||||
}
|
||||
|
||||
@Test func detectsPCMFormatRejectionFromElevenLabsError() {
|
||||
|
||||
@@ -15,6 +15,9 @@ packages:
|
||||
path: ../shared/OpenClawKit
|
||||
Swabble:
|
||||
path: ../swabble
|
||||
WebRTC:
|
||||
url: https://github.com/stasel/WebRTC.git
|
||||
exactVersion: 147.0.0
|
||||
|
||||
schemes:
|
||||
OpenClaw:
|
||||
@@ -57,6 +60,7 @@ targets:
|
||||
product: OpenClawProtocol
|
||||
- package: Swabble
|
||||
product: SwabbleKit
|
||||
- package: WebRTC
|
||||
- sdk: AppIntents.framework
|
||||
preBuildScripts:
|
||||
- name: SwiftFormat (lint)
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.9 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 154 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 300 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.5 MiB |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"version": "2026.5.25"
|
||||
"version": "2026.5.26"
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user