co-maintainer 0.4.12 → 0.5.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +57 -38
- package/dist/main.js +2 -3
- package/dist/package.json +21 -15
- package/dist/src/ai/batch.d.ts +24 -1
- package/dist/src/ai/batch.js +75 -23
- package/dist/src/ai/estimate.d.ts +40 -0
- package/dist/src/ai/estimate.js +113 -0
- package/dist/src/ai/fake.d.ts +1 -1
- package/dist/src/ai/fake.js +1 -1
- package/dist/src/ai/hetzner.js +1 -1
- package/dist/src/ai/openrouter.d.ts +13 -0
- package/dist/src/ai/openrouter.js +66 -12
- package/dist/src/ai/pricing.d.ts +14 -0
- package/dist/src/ai/pricing.js +77 -0
- package/dist/src/ai/provider.js +8 -0
- package/dist/src/ai/verify.d.ts +25 -0
- package/dist/src/ai/verify.js +78 -0
- package/dist/src/cli/args.js +25 -29
- package/dist/src/cli/commands/config.d.ts +44 -0
- package/dist/src/cli/commands/config.js +220 -0
- package/dist/src/cli/commands/probe.js +70 -103
- package/dist/src/cli/commands/registry.d.ts +57 -0
- package/dist/src/cli/commands/registry.js +713 -0
- package/dist/src/cli/commands/review.js +66 -43
- package/dist/src/cli/commands/serve.d.ts +4 -2
- package/dist/src/cli/commands/serve.js +14 -10
- package/dist/src/cli/commands/set.d.ts +2 -1
- package/dist/src/cli/commands/set.js +63 -17
- package/dist/src/cli/commands/view.d.ts +1 -0
- package/dist/src/cli/commands/view.js +180 -0
- package/dist/src/cli/error.d.ts +39 -0
- package/dist/src/cli/error.js +64 -0
- package/dist/src/cli/main.d.ts +7 -0
- package/dist/src/cli/main.js +82 -1
- package/dist/src/cli/prompt.js +21 -7
- package/dist/src/cli/review_args.d.ts +4 -0
- package/dist/src/cli/review_args.js +22 -9
- package/dist/src/cli/review_output.d.ts +2 -2
- package/dist/src/cli/review_output.js +7 -6
- package/dist/src/cli/review_result.d.ts +59 -8
- package/dist/src/cli/review_result.js +145 -103
- package/dist/src/config.d.ts +15 -0
- package/dist/src/config.js +31 -0
- package/dist/src/github/app.d.ts +13 -0
- package/dist/src/github/app.js +23 -0
- package/dist/src/github/app_manifest.d.ts +50 -0
- package/dist/src/github/app_manifest.js +138 -0
- package/dist/src/github/client.js +7 -1
- package/dist/src/github/collect.js +1 -1
- package/dist/src/github/gh.js +75 -18
- package/dist/src/knowledge/facts.js +8 -3
- package/dist/src/knowledge/guide.d.ts +8 -0
- package/dist/src/knowledge/guide.js +32 -8
- package/dist/src/knowledge/probe.js +13 -3
- package/dist/src/knowledge/sections.d.ts +9 -0
- package/dist/src/knowledge/sections.js +16 -0
- package/dist/src/knowledge/skill.d.ts +5 -0
- package/dist/src/knowledge/skill.js +68 -21
- package/dist/src/knowledge/synthesis.d.ts +17 -1
- package/dist/src/knowledge/synthesis.js +70 -15
- package/dist/src/knowledge/types.d.ts +5 -0
- package/dist/src/local/codegraph_prepare.d.ts +3 -2
- package/dist/src/local/codegraph_prepare.js +4 -2
- package/dist/src/local/git_ops.d.ts +5 -4
- package/dist/src/local/git_ops.js +8 -9
- package/dist/src/local/review_local.js +36 -14
- package/dist/src/pr/checkout.js +14 -3
- package/dist/src/pr/codegraph_tools.js +3 -3
- package/dist/src/pr/diff_summary.js +3 -3
- package/dist/src/pr/findings.js +16 -5
- package/dist/src/pr/findings_json.d.ts +26 -0
- package/dist/src/pr/findings_json.js +187 -0
- package/dist/src/pr/review_copy.d.ts +20 -0
- package/dist/src/pr/review_copy.js +40 -0
- package/dist/src/pr/reviewer.d.ts +2 -1
- package/dist/src/pr/reviewer.js +84 -91
- package/dist/src/remote/client.js +42 -42
- package/dist/src/remote/http.d.ts +5 -0
- package/dist/src/remote/http.js +49 -0
- package/dist/src/remote/server/guides.d.ts +22 -0
- package/dist/src/remote/server/guides.js +81 -0
- package/dist/src/remote/server/routes.js +24 -0
- package/dist/src/review/blocking.d.ts +35 -0
- package/dist/src/review/blocking.js +45 -0
- package/dist/src/review/carry_over.d.ts +12 -1
- package/dist/src/review/carry_over.js +35 -9
- package/dist/src/review/engine.d.ts +1 -0
- package/dist/src/review/guides.js +28 -1
- package/dist/src/server/api/installations.js +1 -1
- package/dist/src/server/api/repos.js +86 -3
- package/dist/src/server/api/settings.js +3 -3
- package/dist/src/server/app.d.ts +3 -0
- package/dist/src/server/app.js +12 -5
- package/dist/src/server/pages/activity.d.ts +1 -0
- package/dist/src/server/pages/activity.js +2 -2
- package/dist/src/server/pages/add_repo.js +73 -5
- package/dist/src/server/pages/client.d.ts +1 -1
- package/dist/src/server/pages/client.js +1 -1
- package/dist/src/server/pages/home.d.ts +6 -1
- package/dist/src/server/pages/home.js +25 -2
- package/dist/src/server/pages/knowledge.js +1 -1
- package/dist/src/server/pages/layout.js +2 -2
- package/dist/src/server/pages/repo.d.ts +1 -1
- package/dist/src/server/pages/repo.js +21 -2
- package/dist/src/server/pages/repo_prs.d.ts +5 -1
- package/dist/src/server/pages/repo_prs.js +36 -1
- package/dist/src/server/pages/repo_remote.js +2 -2
- package/dist/src/server/pages/repo_settings.js +2 -2
- package/dist/src/server/pages/router.d.ts +6 -0
- package/dist/src/server/pages/router.js +129 -9
- package/dist/src/server/pages/settings.d.ts +1 -1
- package/dist/src/server/pages/settings.js +87 -9
- package/dist/src/server/pages/styles.d.ts +1 -1
- package/dist/src/server/pages/styles.js +1 -1
- package/dist/src/services/probe.d.ts +28 -0
- package/dist/src/services/probe.js +133 -0
- package/dist/src/services/remake_cron.d.ts +1 -1
- package/dist/src/services/remake_cron.js +3 -3
- package/dist/src/services/remote_review.js +14 -4
- package/dist/src/services/review.d.ts +11 -0
- package/dist/src/services/review.js +39 -11
- package/dist/src/services/setup.js +50 -19
- package/dist/src/services/setup_checklist.d.ts +10 -0
- package/dist/src/services/setup_checklist.js +87 -0
- package/dist/src/store/app_db.d.ts +3 -1
- package/dist/src/store/app_db.js +10 -3
- package/dist/src/store/cache_db.d.ts +4 -0
- package/dist/src/store/cache_db.js +27 -4
- package/dist/src/store/deliveries.d.ts +7 -0
- package/dist/src/store/deliveries.js +15 -0
- package/dist/src/tools/codegraph.d.ts +30 -0
- package/dist/src/tools/codegraph.js +49 -12
- package/dist/src/types.d.ts +10 -0
- package/dist/src/util/log.d.ts +3 -0
- package/dist/src/util/log.js +12 -0
- package/dist/src/util/run_summary.d.ts +33 -0
- package/dist/src/util/run_summary.js +47 -0
- package/dist/src/util/webhook_reachability.d.ts +11 -0
- package/dist/src/util/webhook_reachability.js +64 -0
- package/package.json +21 -15
package/LICENSE
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 Murat Kirazkaya
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Murat Kirazkaya
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -2,15 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/co-maintainer) [](https://muratkirazkaya.com/blogs/co-maintainer-an-imitator-of-yours)
|
|
4
4
|
|
|
5
|
-
[Website](https://
|
|
6
|
-
[Docs](https://
|
|
7
|
-
[Cloud](https://
|
|
5
|
+
[Website](https://co-maintainer.com) ·
|
|
6
|
+
[Docs](https://co-maintainer.com/docs) ·
|
|
7
|
+
[Cloud](https://cloud.co-maintainer.com) (hosted, free, early access, invite only for now)
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
`SKILL.md` guidance that helps developers contribute changes more reliably.
|
|
9
|
+
An imitator of yours, A PR review tool like co-maintainer.
|
|
11
10
|
|
|
12
11
|
It collects current code and workflows, selected pull requests and diffs, and
|
|
13
|
-
default-branch commits. A low-cost model extracts evidence-bound observations
|
|
12
|
+
default-branch commits. A low-cost model extracts evidence-bound observations. A
|
|
14
13
|
higher-reasoning model turns them into concise contribution guidance for
|
|
15
14
|
implementation, testing, review, and release decisions.
|
|
16
15
|
|
|
@@ -27,7 +26,7 @@ Requires Node.js 24 or newer.
|
|
|
27
26
|
```sh
|
|
28
27
|
co-maintainer probe owner/repo --auth=gh
|
|
29
28
|
|
|
30
|
-
co-maintainer init owner/repo --auth=gh --ai=openrouter
|
|
29
|
+
co-maintainer init owner/repo --auth=gh --ai=openrouter \
|
|
31
30
|
--low-model=openai/gpt-oss-120b \
|
|
32
31
|
--high-model=openai/gpt-5.6-luna \
|
|
33
32
|
--include-codebase --include-pull-requests \
|
|
@@ -37,30 +36,39 @@ co-maintainer init owner/repo --auth=gh --ai=openrouter --token=... \
|
|
|
37
36
|
co-maintainer review owner/repo 123 --improve-matrix=2 --debug
|
|
38
37
|
|
|
39
38
|
co-maintainer review # local branch (staged, unstaged, untracked)
|
|
40
|
-
co-maintainer review --json # machine-readable stdout
|
|
39
|
+
co-maintainer review --json # machine-readable stdout, logs on stderr
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Settings live in `config.json`, so a flag you use every time can be saved once:
|
|
41
43
|
|
|
42
|
-
|
|
44
|
+
```sh
|
|
45
|
+
co-maintainer config set ai-key sk-or-... # OpenRouter key
|
|
46
|
+
co-maintainer config set high-model openai/gpt-5.6-luna
|
|
47
|
+
co-maintainer config set low-model openai/gpt-oss-120b
|
|
48
|
+
co-maintainer config list # every key, its value and its source
|
|
43
49
|
```
|
|
44
50
|
|
|
45
|
-
Documentation: [Getting started](https://
|
|
46
|
-
(install
|
|
51
|
+
Documentation: [Getting started](https://co-maintainer.com/docs/getting-started.html)
|
|
52
|
+
(install to first review) and the [full docs site](https://co-maintainer.com/docs),
|
|
47
53
|
sources in [`docs/md/`](docs/md/).
|
|
48
54
|
|
|
49
55
|
Use `co-maintainer help`, `co-maintainer -h`, or `co-maintainer --help` for the
|
|
50
|
-
full CLI help.
|
|
56
|
+
full CLI help. Every guide the CLI writes is also readable from the dashboard,
|
|
57
|
+
and the dashboard keeps its guides separate from the CLI's. See
|
|
58
|
+
[dashboard.md](docs/md/dashboard.md).
|
|
51
59
|
|
|
52
60
|
## Compared to other tools
|
|
53
61
|
|
|
54
62
|
co-maintainer is a new tool, so it has not been compared with many others yet.
|
|
55
|
-
It was benchmarked against [OCR](https://open-codereview.ai/)
|
|
56
|
-
|
|
63
|
+
It was benchmarked against [OCR](https://open-codereview.ai/) on `core_v2`. The
|
|
64
|
+
datasets are small, so read the numbers as a signal, not a guarantee.
|
|
57
65
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
66
|
+
| Claim, as measured on `core_v2` | Result |
|
|
67
|
+
| ------------------------------- | ------------------ |
|
|
68
|
+
| Result quality against OCR | 2 to 3x better |
|
|
69
|
+
| Speed against OCR | 2 to 8x faster |
|
|
70
|
+
| Tokens per review against OCR | 70 to 200x fewer |
|
|
71
|
+
| Cost per review against OCR | 60 to 270x cheaper |
|
|
64
72
|
|
|
65
73
|
```mermaid
|
|
66
74
|
xychart-beta
|
|
@@ -71,26 +79,37 @@ xychart-beta
|
|
|
71
79
|
line "co-maintainer" [2, 10, 20, 40, 60, 80, 100, 120, 140, 160, 200]
|
|
72
80
|
```
|
|
73
81
|
|
|
74
|
-
_Calculated
|
|
82
|
+
_Calculated from co-maintainer's `core_v2` benchmark, which reports co-maintainer as 65x cheaper per review than OCR ($0.025/review vs. $1.62/review). Assumes 80 PR reviews per developer per month (40 PRs merged, about 2 reviews each)._
|
|
75
83
|
|
|
76
84
|
## Benchmarks
|
|
77
85
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
hand-verified defects in a dedicated target repo, half of the PRs kept as
|
|
85
|
-
defect-free controls so precision is measurable too, not just recall. Full
|
|
86
|
-
breakdown, per-PR results, and methodology:
|
|
86
|
+
`core_v3` fits co-maintainer's use case better than the OCR comparison, so it is
|
|
87
|
+
the more meaningful metric here. Unlike the OCR comparison (real human review
|
|
88
|
+
comments as gold, see `benchmark/swe-prbench`), `bench core v3` scores against
|
|
89
|
+
deliberately seeded, hand-verified defects in a dedicated target repo. Half of
|
|
90
|
+
the PRs are kept as defect-free controls, so precision is measurable too, not
|
|
91
|
+
just recall. Full breakdown, per-PR results, and methodology:
|
|
87
92
|
[`benchmark/core_v3/README.md`](benchmark/core_v3/README.md).
|
|
88
93
|
|
|
89
|
-
| metric | value |
|
|
90
|
-
| ------------- | ----------------------------- |
|
|
91
|
-
| PRs reviewed | 30 (14 defective, 16 control) |
|
|
92
|
-
| F1 | 0.500 |
|
|
93
|
-
| Precision | 0.353 |
|
|
94
|
-
| Recall | 0.857 |
|
|
95
|
-
| Avg cost / PR | $0.0045 |
|
|
96
|
-
| Avg time / PR | 28.9s |
|
|
94
|
+
| metric | value | context |
|
|
95
|
+
| ------------- | ----------------------------- | --------------------------- |
|
|
96
|
+
| PRs reviewed | 30 (14 defective, 16 control) | one target repo |
|
|
97
|
+
| F1 | 0.500 | against seeded defects only |
|
|
98
|
+
| Precision | 0.353 | 1.00 on the control PRs |
|
|
99
|
+
| Recall | 0.857 | 12 of 14 defects found |
|
|
100
|
+
| Avg cost / PR | $0.0045 | low and high model together |
|
|
101
|
+
| Avg time / PR | 28.9s | sequential, no concurrency |
|
|
102
|
+
|
|
103
|
+
## Development
|
|
104
|
+
|
|
105
|
+
From a checkout:
|
|
106
|
+
|
|
107
|
+
```sh
|
|
108
|
+
npm install
|
|
109
|
+
npm test # unit, script and benchmark tests
|
|
110
|
+
npm run check # tsc --noEmit
|
|
111
|
+
npm run docs:build # rebuild docs/ from docs/md/
|
|
112
|
+
npm run e2e # end-to-end CLI loop
|
|
113
|
+
npm run review-local-e2e # fake-AI local review loop
|
|
114
|
+
npm run dashboard:smoke # browser smoke test
|
|
115
|
+
```
|
package/dist/main.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { run } from "./src/cli/main.js";
|
|
2
|
+
import { reportCliError, run } from "./src/cli/main.js";
|
|
3
3
|
try {
|
|
4
4
|
await run(process.argv.slice(2));
|
|
5
5
|
}
|
|
6
6
|
catch (error) {
|
|
7
|
-
|
|
8
|
-
process.exit(1);
|
|
7
|
+
reportCliError(error);
|
|
9
8
|
}
|
package/dist/package.json
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "co-maintainer",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.5.0-beta.1",
|
|
4
|
+
"description": "An imitator of yours, A PR review tool like co-maintainer.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ai",
|
|
7
|
+
"code-review",
|
|
8
|
+
"github",
|
|
9
|
+
"maintainer",
|
|
10
|
+
"pull-request"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://co-maintainer.com",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/GroophyLifefor/co-maintainer/issues"
|
|
15
|
+
},
|
|
5
16
|
"license": "MIT",
|
|
6
17
|
"repository": {
|
|
7
18
|
"type": "git",
|
|
8
19
|
"url": "https://github.com/GroophyLifefor/co-maintainer"
|
|
9
20
|
},
|
|
10
|
-
"homepage": "https://github.com/GroophyLifefor/co-maintainer#readme",
|
|
11
|
-
"bugs": {
|
|
12
|
-
"url": "https://github.com/GroophyLifefor/co-maintainer/issues"
|
|
13
|
-
},
|
|
14
|
-
"keywords": [
|
|
15
|
-
"github",
|
|
16
|
-
"pull-request",
|
|
17
|
-
"code-review",
|
|
18
|
-
"maintainer",
|
|
19
|
-
"ai"
|
|
20
|
-
],
|
|
21
21
|
"bin": {
|
|
22
22
|
"co-maintainer": "dist/main.js"
|
|
23
23
|
},
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"build": "npm run assets:embed && tsc -p tsconfig.build.json",
|
|
35
35
|
"assets:embed": "node scripts/embed_assets.mjs",
|
|
36
36
|
"prepack": "npm run build",
|
|
37
|
-
"test": "node --test \"src/**/*.test.ts\" \"benchmark/**/*.test.ts\"",
|
|
37
|
+
"test": "node --test \"src/**/*.test.ts\" \"scripts/**/*.test.ts\" \"benchmark/**/*.test.ts\"",
|
|
38
38
|
"check": "tsc --noEmit",
|
|
39
39
|
"lint": "oxlint",
|
|
40
40
|
"fmt": "oxfmt --write .",
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"probe": "node main.ts probe",
|
|
44
44
|
"init": "node main.ts init",
|
|
45
45
|
"remake": "node main.ts remake",
|
|
46
|
+
"sync": "node main.ts sync",
|
|
46
47
|
"review": "node main.ts review",
|
|
47
48
|
"serve": "node main.ts serve",
|
|
48
49
|
"bench": "node benchmark/core_v2/run.ts",
|
|
@@ -52,7 +53,11 @@
|
|
|
52
53
|
"swe-prbench-prepare": "node benchmark/swe-prbench/prepare_dataset.ts",
|
|
53
54
|
"e2e": "node scripts/e2e.ts",
|
|
54
55
|
"review-local-e2e": "node scripts/review_local_e2e.ts",
|
|
55
|
-
"docs:build": "node scripts/build_docs.ts"
|
|
56
|
+
"docs:build": "node scripts/build_docs.ts",
|
|
57
|
+
"pack:smoke": "node scripts/pack_smoke.ts",
|
|
58
|
+
"downgrade:check": "node scripts/downgrade_check.ts",
|
|
59
|
+
"dx:scenario": "node scripts/dx_scenario.ts",
|
|
60
|
+
"dashboard:smoke": "node scripts/dashboard_smoke.ts"
|
|
56
61
|
},
|
|
57
62
|
"dependencies": {
|
|
58
63
|
"marked": "^15.0.7"
|
|
@@ -61,6 +66,7 @@
|
|
|
61
66
|
"@types/node": "^24.0.0",
|
|
62
67
|
"oxfmt": "^0.68.0",
|
|
63
68
|
"oxlint": "^1.83.0",
|
|
69
|
+
"playwright": "^1.63.0",
|
|
64
70
|
"typescript": "^5.7.0"
|
|
65
71
|
},
|
|
66
72
|
"engines": {
|
package/dist/src/ai/batch.d.ts
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
import type { AiProvider, AiRequest, AiResponse } from "../types.ts";
|
|
2
2
|
type UsageSink = (job: string, response: AiResponse) => Promise<void>;
|
|
3
|
+
/** Returns `null` when a response is usable, or a short reason when it is not.
|
|
4
|
+
* `synthesis.ts` supplies one that parses the model text, because a syntactically
|
|
5
|
+
* fine HTTP response can still be unparseable output, and caching that is what
|
|
6
|
+
* poisoned every later `sync` (CORE-30 / F04). */
|
|
7
|
+
export type AiValidator = (request: AiRequest, response: AiResponse) => string | null;
|
|
8
|
+
/** A unit whose output never parsed, kept so the run can say what it dropped. */
|
|
9
|
+
export type SkippedUnit = {
|
|
10
|
+
index: number;
|
|
11
|
+
job: string;
|
|
12
|
+
reason: string;
|
|
13
|
+
};
|
|
3
14
|
export declare class AiBatch {
|
|
4
15
|
private records;
|
|
5
16
|
private loaded;
|
|
@@ -8,8 +19,20 @@ export declare class AiBatch {
|
|
|
8
19
|
private readonly repo;
|
|
9
20
|
private readonly concurrency;
|
|
10
21
|
private readonly profile;
|
|
11
|
-
|
|
22
|
+
private readonly validate?;
|
|
23
|
+
private readonly skipped;
|
|
24
|
+
constructor(provider: AiProvider, repo: string, concurrency: number, profile: string, validate?: AiValidator);
|
|
25
|
+
/** Units whose output did not parse this run (or on re-try), in request order. */
|
|
26
|
+
skippedUnits(): SkippedUnit[];
|
|
12
27
|
run(requests: AiRequest[], usage?: UsageSink): Promise<(AiResponse | undefined)[]>;
|
|
28
|
+
/** Runs one unit, caching its response only when the validator accepts it.
|
|
29
|
+
* A rejected response is retried once; a second rejection is recorded as
|
|
30
|
+
* skipped and never cached, so the next `sync` retries it rather than
|
|
31
|
+
* replaying the same unusable output (CORE-30 / F04). */
|
|
32
|
+
private completeUnit;
|
|
33
|
+
/** Records a unit as skipped: no cache write, so it stays retried and its
|
|
34
|
+
* absence is never mistaken for a deliberate empty result. */
|
|
35
|
+
private markSkipped;
|
|
13
36
|
private load;
|
|
14
37
|
private persist;
|
|
15
38
|
}
|
package/dist/src/ai/batch.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { cacheGet, cacheSet } from "../store/cache_db.js";
|
|
2
2
|
import { isNotFound, readTextFile } from "../util/runtime.js";
|
|
3
|
+
import { formatError } from "../cli/error.js";
|
|
3
4
|
async function digest(value) {
|
|
4
5
|
const bytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
|
|
5
6
|
return [...new Uint8Array(bytes)]
|
|
@@ -14,11 +15,18 @@ export class AiBatch {
|
|
|
14
15
|
repo;
|
|
15
16
|
concurrency;
|
|
16
17
|
profile;
|
|
17
|
-
|
|
18
|
+
validate;
|
|
19
|
+
skipped = [];
|
|
20
|
+
constructor(provider, repo, concurrency, profile, validate) {
|
|
18
21
|
this.provider = provider;
|
|
19
22
|
this.repo = repo;
|
|
20
23
|
this.concurrency = concurrency;
|
|
21
24
|
this.profile = profile;
|
|
25
|
+
this.validate = validate;
|
|
26
|
+
}
|
|
27
|
+
/** Units whose output did not parse this run (or on re-try), in request order. */
|
|
28
|
+
skippedUnits() {
|
|
29
|
+
return [...this.skipped];
|
|
22
30
|
}
|
|
23
31
|
async run(requests, usage) {
|
|
24
32
|
await this.load();
|
|
@@ -35,8 +43,20 @@ export class AiBatch {
|
|
|
35
43
|
}));
|
|
36
44
|
const cached = this.records[id];
|
|
37
45
|
if (cached?.status === "done" && cached.response) {
|
|
38
|
-
|
|
39
|
-
|
|
46
|
+
// A `done` record from 0.4.x can hold output that never parsed. Treat
|
|
47
|
+
// that as a miss and delete it so the retry below replaces it; the
|
|
48
|
+
// corrupted record heals on its own the next time its unit runs.
|
|
49
|
+
const reason = this.validate?.(request, cached.response) ?? null;
|
|
50
|
+
if (reason === null) {
|
|
51
|
+
results[index] = cached.response;
|
|
52
|
+
console.log(`[ai] cache hit ${request.job} ${id.slice(0, 8)}`);
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
delete this.records[id];
|
|
56
|
+
await this.persist();
|
|
57
|
+
console.log(`[ai] dropped unusable cache record ${request.job} ${id.slice(0, 8)}: ${reason}`);
|
|
58
|
+
pending.push({ index, id, request });
|
|
59
|
+
}
|
|
40
60
|
}
|
|
41
61
|
else if (cached?.status === "quarantine") {
|
|
42
62
|
console.log(`[ai] quarantined job skipped: ${request.job} ${id.slice(0, 8)}`);
|
|
@@ -57,26 +77,7 @@ export class AiBatch {
|
|
|
57
77
|
console.log(`[ai] still running ${item.request.job} ${item.id.slice(0, 8)} · ${seconds}s`);
|
|
58
78
|
}, 15_000);
|
|
59
79
|
try {
|
|
60
|
-
|
|
61
|
-
this.records[item.id] = {
|
|
62
|
-
status: "done",
|
|
63
|
-
response,
|
|
64
|
-
updatedAt: new Date().toISOString(),
|
|
65
|
-
};
|
|
66
|
-
results[item.index] = response;
|
|
67
|
-
await this.persist();
|
|
68
|
-
if (usage)
|
|
69
|
-
await usage(item.request.job, response);
|
|
70
|
-
console.log(`[ai] done ${item.request.job} ${item.id.slice(0, 8)} · ${Math.round((Date.now() - startedAt) / 1000)}s`);
|
|
71
|
-
}
|
|
72
|
-
catch (error) {
|
|
73
|
-
this.records[item.id] = {
|
|
74
|
-
status: "quarantine",
|
|
75
|
-
error: String(error),
|
|
76
|
-
updatedAt: new Date().toISOString(),
|
|
77
|
-
};
|
|
78
|
-
await this.persist();
|
|
79
|
-
console.log(`[ai] quarantined ${item.request.job} ${item.id.slice(0, 8)}: ${String(error)}`);
|
|
80
|
+
await this.completeUnit(item, results, usage, startedAt);
|
|
80
81
|
}
|
|
81
82
|
finally {
|
|
82
83
|
clearInterval(heartbeat);
|
|
@@ -87,6 +88,57 @@ export class AiBatch {
|
|
|
87
88
|
await this.saveChain;
|
|
88
89
|
return results;
|
|
89
90
|
}
|
|
91
|
+
/** Runs one unit, caching its response only when the validator accepts it.
|
|
92
|
+
* A rejected response is retried once; a second rejection is recorded as
|
|
93
|
+
* skipped and never cached, so the next `sync` retries it rather than
|
|
94
|
+
* replaying the same unusable output (CORE-30 / F04). */
|
|
95
|
+
async completeUnit(item, results, usage, startedAt) {
|
|
96
|
+
for (const attempt of [1, 2]) {
|
|
97
|
+
try {
|
|
98
|
+
const response = await this.provider.complete(item.request);
|
|
99
|
+
const reason = this.validate?.(item.request, response) ?? null;
|
|
100
|
+
if (reason !== null) {
|
|
101
|
+
if (attempt === 2) {
|
|
102
|
+
this.markSkipped(item, reason);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
console.log(`[ai] retrying ${item.request.job} ${item.id.slice(0, 8)}: ${reason}`);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
this.records[item.id] = {
|
|
109
|
+
status: "done",
|
|
110
|
+
response,
|
|
111
|
+
updatedAt: new Date().toISOString(),
|
|
112
|
+
};
|
|
113
|
+
results[item.index] = response;
|
|
114
|
+
await this.persist();
|
|
115
|
+
if (usage)
|
|
116
|
+
await usage(item.request.job, response);
|
|
117
|
+
console.log(`[ai] ${attempt === 1 ? "done" : "retried"} ${item.request.job} ${item.id.slice(0, 8)} · ${Math.round((Date.now() - startedAt) / 1000)}s`);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
this.records[item.id] = {
|
|
122
|
+
status: "quarantine",
|
|
123
|
+
error: formatError(error),
|
|
124
|
+
updatedAt: new Date().toISOString(),
|
|
125
|
+
};
|
|
126
|
+
await this.persist();
|
|
127
|
+
console.log(`[ai] quarantined ${item.request.job} ${item.id.slice(0, 8)}: ${formatError(error)}`);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/** Records a unit as skipped: no cache write, so it stays retried and its
|
|
133
|
+
* absence is never mistaken for a deliberate empty result. */
|
|
134
|
+
markSkipped(item, reason) {
|
|
135
|
+
this.skipped.push({
|
|
136
|
+
index: item.index,
|
|
137
|
+
job: item.request.job,
|
|
138
|
+
reason,
|
|
139
|
+
});
|
|
140
|
+
console.log(`[ai] skipped ${item.request.job} ${item.index}: ${reason}`);
|
|
141
|
+
}
|
|
90
142
|
async load() {
|
|
91
143
|
if (this.loaded)
|
|
92
144
|
return;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ModelPrice } from "./pricing.ts";
|
|
2
|
+
type RecordedJob = {
|
|
3
|
+
tokensIn: number;
|
|
4
|
+
tokensOut: number;
|
|
5
|
+
seconds: number;
|
|
6
|
+
};
|
|
7
|
+
/** Averages this repository's recorded AI jobs from cache.db. `recordAiCost`
|
|
8
|
+
* keys each job `${repo}:${uuid}`, so the history belongs to the repo the
|
|
9
|
+
* estimate is for. Fewer than four jobs is not a trend, so it is ignored. */
|
|
10
|
+
export declare function readJobHistory(repo: string): Promise<RecordedJob | undefined>;
|
|
11
|
+
export type EstimateInput = {
|
|
12
|
+
/** How many pull requests the recommended flags would read. */
|
|
13
|
+
pullRequests: number;
|
|
14
|
+
/** Whether the recommended flags read the codebase. */
|
|
15
|
+
includeCodebase: boolean;
|
|
16
|
+
lowModel?: string;
|
|
17
|
+
highModel?: string;
|
|
18
|
+
prices: Map<string, ModelPrice>;
|
|
19
|
+
history?: RecordedJob;
|
|
20
|
+
};
|
|
21
|
+
export type Estimate = {
|
|
22
|
+
extract: number;
|
|
23
|
+
synth: number;
|
|
24
|
+
seconds: [number, number];
|
|
25
|
+
tokensIn: [number, number];
|
|
26
|
+
tokensOut: [number, number];
|
|
27
|
+
/** Absent when no price is known for the models. */
|
|
28
|
+
usd?: [number, number];
|
|
29
|
+
basis: "history" | "calibration";
|
|
30
|
+
};
|
|
31
|
+
/** The number of `extract_unit` jobs the recommended init would queue: one per
|
|
32
|
+
* pull request plus one for the codebase/documents, matching
|
|
33
|
+
* `extractAiFacts`. */
|
|
34
|
+
export declare function extractJobCount(input: {
|
|
35
|
+
pullRequests: number;
|
|
36
|
+
includeCodebase: boolean;
|
|
37
|
+
includeHowRepoWorks: boolean;
|
|
38
|
+
}): number;
|
|
39
|
+
export declare function estimateInit(input: EstimateInput): Estimate;
|
|
40
|
+
export {};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/** Cost and time estimate for the recommended `init` (CORE-24, F17).
|
|
2
|
+
*
|
|
3
|
+
* Three sources, in order of trust:
|
|
4
|
+
*
|
|
5
|
+
* 1. Job counts come from the same inputs the real run uses — the number of
|
|
6
|
+
* pull requests, whether the codebase is read, and the ten synthesis
|
|
7
|
+
* sections — so the shape is exact.
|
|
8
|
+
* 2. Dollars come from the live OpenRouter price for the configured models.
|
|
9
|
+
* When prices are unavailable the estimate reports tokens but no dollars
|
|
10
|
+
* rather than guessing one.
|
|
11
|
+
* 3. Time and per-job tokens come from this repository's recorded jobs when
|
|
12
|
+
* there are enough, and from the calibration below otherwise.
|
|
13
|
+
*
|
|
14
|
+
* Calibration (plan §CORE-24): cm-dx-lab, 4 pull requests and 8 files, 5
|
|
15
|
+
* extract and 9 synth jobs, 151 s, $0.0014 — about 151/14 ≈ 10.8 s per job.
|
|
16
|
+
* Token counts per job are near 2.4k in and 0.7k out. The text always says
|
|
17
|
+
* the numbers are an estimate. */
|
|
18
|
+
import { cacheValues } from "../store/cache_db.js";
|
|
19
|
+
import { sectionKeys } from "../knowledge/sections.js";
|
|
20
|
+
/** Measured averages used when no history exists (cm-dx-lab calibration). */
|
|
21
|
+
const CALIBRATION = {
|
|
22
|
+
seconds: 10.8,
|
|
23
|
+
tokensIn: 2_400,
|
|
24
|
+
tokensOut: 700,
|
|
25
|
+
};
|
|
26
|
+
/** A spread around the point estimate, since a real run varies. */
|
|
27
|
+
const SPREAD = 0.25;
|
|
28
|
+
const MAX_HISTORY_ROWS = 200;
|
|
29
|
+
/** Averages this repository's recorded AI jobs from cache.db. `recordAiCost`
|
|
30
|
+
* keys each job `${repo}:${uuid}`, so the history belongs to the repo the
|
|
31
|
+
* estimate is for. Fewer than four jobs is not a trend, so it is ignored. */
|
|
32
|
+
export async function readJobHistory(repo) {
|
|
33
|
+
let rows;
|
|
34
|
+
try {
|
|
35
|
+
rows = await cacheValues("cost", `${repo}:`);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
if (rows.length < 4)
|
|
41
|
+
return undefined;
|
|
42
|
+
let tokensIn = 0;
|
|
43
|
+
let tokensOut = 0;
|
|
44
|
+
let seconds = 0;
|
|
45
|
+
let counted = 0;
|
|
46
|
+
for (const raw of rows.slice(0, MAX_HISTORY_ROWS)) {
|
|
47
|
+
try {
|
|
48
|
+
const row = JSON.parse(raw);
|
|
49
|
+
tokensIn += Number(row.tokensIn ?? 0);
|
|
50
|
+
tokensOut += Number(row.tokensOut ?? 0);
|
|
51
|
+
seconds += Number(row.seconds ?? CALIBRATION.seconds);
|
|
52
|
+
counted++;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// A malformed row is skipped, not counted as a zero.
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (counted === 0)
|
|
59
|
+
return undefined;
|
|
60
|
+
return {
|
|
61
|
+
tokensIn: tokensIn / counted,
|
|
62
|
+
tokensOut: tokensOut / counted,
|
|
63
|
+
seconds: seconds / counted,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/** A low/high band around a point estimate. */
|
|
67
|
+
function band(value) {
|
|
68
|
+
return [value * (1 - SPREAD), value * (1 + SPREAD)];
|
|
69
|
+
}
|
|
70
|
+
/** The number of `extract_unit` jobs the recommended init would queue: one per
|
|
71
|
+
* pull request plus one for the codebase/documents, matching
|
|
72
|
+
* `extractAiFacts`. */
|
|
73
|
+
export function extractJobCount(input) {
|
|
74
|
+
let count = input.pullRequests;
|
|
75
|
+
if (input.includeCodebase || input.includeHowRepoWorks)
|
|
76
|
+
count += 1;
|
|
77
|
+
return count;
|
|
78
|
+
}
|
|
79
|
+
export function estimateInit(input) {
|
|
80
|
+
const extract = extractJobCount({
|
|
81
|
+
pullRequests: input.pullRequests,
|
|
82
|
+
includeCodebase: input.includeCodebase,
|
|
83
|
+
includeHowRepoWorks: true,
|
|
84
|
+
});
|
|
85
|
+
const synth = sectionKeys.length;
|
|
86
|
+
const jobs = extract + synth;
|
|
87
|
+
const per = input.history ?? CALIBRATION;
|
|
88
|
+
const seconds = band(jobs * per.seconds);
|
|
89
|
+
const tokensIn = band(jobs * per.tokensIn);
|
|
90
|
+
const tokensOut = band(jobs * per.tokensOut);
|
|
91
|
+
// Price the extract jobs with the low model and the synth jobs with the
|
|
92
|
+
// high model, which is how `init` splits the work. A model with no known
|
|
93
|
+
// price makes the dollar range unavailable rather than zero.
|
|
94
|
+
const low = input.lowModel ? input.prices.get(input.lowModel) : undefined;
|
|
95
|
+
const high = input.highModel ? input.prices.get(input.highModel) : undefined;
|
|
96
|
+
let usd;
|
|
97
|
+
if (low && high) {
|
|
98
|
+
const costPerJob = (per.tokensIn / 1_000_000) * low.usdPerMillionIn +
|
|
99
|
+
(per.tokensOut / 1_000_000) * low.usdPerMillionOut;
|
|
100
|
+
const costPerSynth = (per.tokensIn / 1_000_000) * high.usdPerMillionIn +
|
|
101
|
+
(per.tokensOut / 1_000_000) * high.usdPerMillionOut;
|
|
102
|
+
usd = band(extract * costPerJob + synth * costPerSynth);
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
extract,
|
|
106
|
+
synth,
|
|
107
|
+
seconds,
|
|
108
|
+
tokensIn,
|
|
109
|
+
tokensOut,
|
|
110
|
+
usd,
|
|
111
|
+
basis: input.history ? "history" : "calibration",
|
|
112
|
+
};
|
|
113
|
+
}
|
package/dist/src/ai/fake.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AiProvider, AiRequest, AiResponse } from "../types.ts";
|
|
2
|
-
export declare const FAKE_REVIEW_MARKDOWN = "## Findings\n\n### [P2 \u00B7 non-blocking] `src/app.ts
|
|
2
|
+
export declare const FAKE_REVIEW_MARKDOWN = "## Findings\n\n### [P2 \u00B7 non-blocking] `src/app.ts`: `helper()`\nLocation: `src/app.ts:4`\n\nThe helper ignores its argument, so the new behavior is never applied.\n\nThe helper accepts an argument but returns the same result without reading it.\n\nIf you'd like me to explain it in more detail, please ask.\n";
|
|
3
3
|
export declare class FakeAiProvider implements AiProvider {
|
|
4
4
|
private readonly text;
|
|
5
5
|
constructor(text?: string);
|
package/dist/src/ai/fake.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { getEnv, readTextFileSync } from "../util/runtime.js";
|
|
2
2
|
export const FAKE_REVIEW_MARKDOWN = `## Findings
|
|
3
3
|
|
|
4
|
-
### [P2 · non-blocking] \`src/app.ts
|
|
4
|
+
### [P2 · non-blocking] \`src/app.ts\`: \`helper()\`
|
|
5
5
|
Location: \`src/app.ts:4\`
|
|
6
6
|
|
|
7
7
|
The helper ignores its argument, so the new behavior is never applied.
|
package/dist/src/ai/hetzner.js
CHANGED
|
@@ -59,7 +59,7 @@ export class HetznerProvider {
|
|
|
59
59
|
const wait = this.nextRequest - Date.now();
|
|
60
60
|
if (wait > 0) {
|
|
61
61
|
const jittered = this.jitter(wait);
|
|
62
|
-
console.log(`[ai] hetzner quota spacing
|
|
62
|
+
console.log(`[ai] hetzner quota spacing, waiting ${Math.round(jittered / 1000)}s`);
|
|
63
63
|
await this.sleeper(jittered);
|
|
64
64
|
}
|
|
65
65
|
}
|
|
@@ -1,9 +1,22 @@
|
|
|
1
1
|
import type { AiProvider, AiRequest, AiResponse } from "../types.ts";
|
|
2
|
+
import { CliError } from "../cli/error.ts";
|
|
3
|
+
/** Provider failures as actionable text (CORE-12, F24/F28). A 401 and a 400 for
|
|
4
|
+
* an unknown model are preconditions the user can fix, so they are usage
|
|
5
|
+
* errors; anything else is a runtime failure. Only the provider's own
|
|
6
|
+
* `error.message` is surfaced: the raw body can carry account metadata
|
|
7
|
+
* (`user_id`) that has no business in a terminal. */
|
|
8
|
+
export declare function openRouterError(model: string, status: number, body: string): CliError;
|
|
2
9
|
export declare class OpenRouterProvider implements AiProvider {
|
|
3
10
|
readonly supportsTools = true;
|
|
11
|
+
/** `CM_OPENROUTER_URL` points the provider at a local fake in tests. It is
|
|
12
|
+
* only a default override: with the env unset the real endpoint is used, so
|
|
13
|
+
* nothing changes for users. */
|
|
4
14
|
private readonly endpoint;
|
|
5
15
|
private readonly apiKey;
|
|
6
16
|
private readonly model;
|
|
7
17
|
constructor(apiKey: string, model: string);
|
|
18
|
+
/** One POST. Returns the raw response so `complete` can decide whether the
|
|
19
|
+
* failure is worth a retry without the schema. */
|
|
20
|
+
private post;
|
|
8
21
|
complete(request: AiRequest): Promise<AiResponse>;
|
|
9
22
|
}
|