wolli 0.0.3 → 0.0.5

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.
Files changed (33) hide show
  1. package/README.md +23 -24
  2. package/built-in/plugins/discord/discord-chat.ts +47 -84
  3. package/built-in/plugins/discord/index.ts +139 -97
  4. package/built-in/plugins/discord/package.json +2 -2
  5. package/built-in/plugins/github/README.md +148 -0
  6. package/built-in/plugins/github/github-api.test.ts +260 -0
  7. package/built-in/plugins/github/github-api.ts +491 -0
  8. package/built-in/plugins/github/github-chat.ts +216 -0
  9. package/built-in/plugins/github/github-review.ts +102 -0
  10. package/built-in/plugins/github/github-workspace.ts +84 -0
  11. package/built-in/plugins/github/index.ts +795 -0
  12. package/built-in/plugins/github/package.json +14 -0
  13. package/built-in/plugins/scheduler/cron.ts +131 -0
  14. package/built-in/plugins/scheduler/index.ts +147 -151
  15. package/built-in/plugins/scheduler/package.json +3 -2
  16. package/built-in/plugins/scheduler/scheduler-due.ts +22 -0
  17. package/built-in/plugins/telegram/README.md +3 -3
  18. package/built-in/plugins/telegram/index.ts +177 -139
  19. package/built-in/plugins/telegram/package.json +2 -2
  20. package/built-in/plugins/telegram/telegram-chat.ts +79 -155
  21. package/dist/cli.js +6208 -6355
  22. package/docs/hooks.md +103 -0
  23. package/docs/index.md +10 -8
  24. package/docs/integrations.md +78 -663
  25. package/docs/introduction.md +95 -0
  26. package/docs/plugins.md +43 -36
  27. package/docs/providers.md +63 -0
  28. package/docs/sdk.md +42 -47
  29. package/docs/tools.md +81 -0
  30. package/docs/workflows.md +170 -0
  31. package/package.json +1 -1
  32. package/built-in/plugins/scheduler/scheduler-chat.ts +0 -164
  33. package/docs/extensions.md +0 -2331
@@ -0,0 +1,148 @@
1
+ # GitHub
2
+
3
+ Connect a wolli agent to GitHub. The integration **polls** GitHub's REST API (wolli has no
4
+ inbound webhook surface), gives each issue/PR conversation its own wolli session, and replies
5
+ in place as comments. Auth is a **GitHub App** — no public URL, no webhook, no TLS.
6
+
7
+ ## How the bot behaves
8
+
9
+ The transport polls each watched repo on a timer and emits a generic event per new item; the
10
+ routing workflows decide what to act on based on the triggers you pick during onboarding.
11
+
12
+ | Trigger | Behavior |
13
+ |---------|----------|
14
+ | **mention** | When a comment on an issue or PR @mentions the App (`@<app-slug>`), that conversation gets a wolli session and the comment (mention stripped) is delivered to the agent. The App reacts `eyes` on the summoning comment to acknowledge it. If the mention is on a **pull request**, the PR's head commit is checked out under the agent's `workspace/` (see below) and the agent reviews the real source tree with its own file tools. |
15
+ | **auto** | When a pull request is opened or updated, the PR is checked out and a "review this PR" turn is seeded into the PR's session. Deduped on head SHA, so the same commits are reviewed once. |
16
+
17
+ The agent's reply is posted back as a comment on the same issue/PR.
18
+
19
+ ### PR checkout
20
+
21
+ For a pull request, the `checkoutPullRequest` action fetches the head + base commits (`--depth 1`,
22
+ by SHA) into `workspace/reviews/<owner>__<repo>__<number>/` under the agent home, checks out the
23
+ head detached, and **configures no remote**. So the agent gets the full source tree at the head
24
+ commit — it can `read`/`grep` any file and `git diff <base> <head>` to see the change — but there
25
+ is no push target and the App token is never written to disk (it is passed only to the one `fetch`,
26
+ inside the daemon process, never to the agent). The checkout is refreshed in place on later turns.
27
+
28
+ > Note: in the default `local-os` sandbox the agent's writes are jailed to the agent home but the
29
+ > network is not restricted, and the host may carry your own git credentials. Removing the remote
30
+ > makes `push` a deliberate act rather than a footgun; a hard read-only guarantee needs a
31
+ > network-jailed sandbox (`WOLLI_SANDBOX=docker` with egress control) or a reviewer without `bash`. Comments the App itself
32
+ authored (`<app-slug>[bot]`), comments by any Bot account, and comments carrying wolli's own
33
+ `<!-- wolli:github -->` marker are ignored, so the bot never answers itself.
34
+
35
+ ### What is polled
36
+
37
+ Four cheap repo-level "since"-pollable streams, each with its own cursor and a conditional
38
+ (ETag) request so an unchanged poll returns `304` and does not count against the rate limit:
39
+
40
+ | Stream | Endpoint |
41
+ |--------|----------|
42
+ | `issue_comment` | `GET /repos/{o}/{r}/issues/comments?since=` |
43
+ | `pull_request_review_comment` | `GET /repos/{o}/{r}/pulls/comments?since=` |
44
+ | `issues` | `GET /repos/{o}/{r}/issues?since=` (PRs filtered out) |
45
+ | `pull_request` | `GET /repos/{o}/{r}/pulls?sort=updated` (deduped by head SHA) |
46
+
47
+ ## Session model
48
+
49
+ Each issue/PR conversation gets its **own wolli session**, bound by a `github:thread` tag of
50
+ the form `owner/repo#number`:
51
+
52
+ - **Inbound** — an @mention routes to the session tagged for that conversation. If none exists,
53
+ one is created and tagged on the spot. Histories never bleed across conversations.
54
+ - **Outbound** — the reply rides the producing session's tag, so the answer returns to the
55
+ conversation that started the turn.
56
+
57
+ ## Setup
58
+
59
+ ### 1. Create a GitHub App
60
+
61
+ Open **Settings → Developer settings → GitHub Apps → New GitHub App** (personal:
62
+ https://github.com/settings/apps/new, or your org's equivalent).
63
+
64
+ - **Webhook:** clear the **Active** checkbox — this integration polls, it does not receive webhooks.
65
+ - **Repository permissions:** **Contents** read-only, **Issues** read & write, **Pull requests**
66
+ read & write, **Metadata** read-only.
67
+ - Create the App, then under **Private keys** generate a key and download the `.pem`.
68
+ - Note the **App ID** shown on the App's settings page.
69
+ - **Install** the App (left sidebar → *Install App*) on the account/org that owns the repos you
70
+ want to watch, and grant it those repositories.
71
+
72
+ > **Proposed: one-click App creation from a manifest.** GitHub can pre-configure an App from a
73
+ > [manifest](https://docs.github.com/en/apps/sharing-github-apps/registering-a-github-app-from-a-manifest)
74
+ > — the user clicks a single link, GitHub creates the App with the exact permissions below and
75
+ > **no webhook**, and hands back the App ID + private key automatically. This would replace the
76
+ > whole manual "create App → set permissions → uncheck webhook → generate key → copy App ID"
77
+ > dance above with a guided flow. Not yet implemented; the manifest POST-back needs a transient
78
+ > local callback, so it is tracked as a future onboarding enhancement.
79
+
80
+ ### 2. Install and onboard in wolli
81
+
82
+ ```bash
83
+ wolli <agent> plugins install ./built-in/plugins/github
84
+ ```
85
+
86
+ On an interactive terminal this runs onboarding immediately. It asks for:
87
+
88
+ 1. the **App ID**,
89
+ 2. the **private key** — paste the PEM, or (recommended, since a PEM is multi-line) a reference
90
+ like `$GITHUB_APP_PRIVATE_KEY` or `!cat /path/to/key.pem`,
91
+ 3. the **repositories** to watch, comma-separated as `owner/repo, owner/repo`,
92
+ 4. two yes/no **trigger** questions (react to @mentions? auto-review PRs?).
93
+
94
+ Wolli verifies the credentials with a live `GET /app`, reads the App slug as the bot login, and
95
+ validates that the App is installed on each repo (dropping any it is not). If you installed
96
+ non-interactively, onboard later with `wolli <agent> plugins configure github`.
97
+
98
+ ### 3. Restart the agent
99
+
100
+ ```bash
101
+ wolli restart <agent>
102
+ ```
103
+
104
+ The poll producer starts on the next daemon start, so restart once for the bot to come online.
105
+
106
+ ## Configuration reference
107
+
108
+ Configuration lives per agent in `~/.wolli/agents/<name>/integrations.json` under `github`:
109
+
110
+ ```json
111
+ {
112
+ "github": {
113
+ "appId": "123456",
114
+ "privateKey": "$GITHUB_APP_PRIVATE_KEY",
115
+ "botLogin": "my-app",
116
+ "repositories": ["acme/widgets", "acme/docs"],
117
+ "triggers": ["mention", "auto"],
118
+ "pollIntervalMs": 60000
119
+ }
120
+ }
121
+ ```
122
+
123
+ | Field | Required | Default | Purpose |
124
+ |-------|----------|---------|---------|
125
+ | `appId` | Yes | — | The GitHub App ID. |
126
+ | `privateKey` | Yes | — | The App's PEM private key. A `$ENV` / `!cmd` reference resolves on read. |
127
+ | `botLogin` | Yes | — | The App slug; used for @mention detection and self/loop filtering. Set by onboarding. |
128
+ | `repositories` | Yes | — | Repos to watch, as `owner/repo`. |
129
+ | `triggers` | Yes | — | Any of `"mention"`, `"auto"`. |
130
+ | `pollIntervalMs` | No | `60000` | Poll interval floor in ms. The effective interval also honors GitHub's `X-Poll-Interval`. |
131
+
132
+ ## Not supported
133
+
134
+ - **Webhooks / real-time delivery** — the transport polls, so there is up to one poll interval
135
+ of latency, and the daemon must be running to see activity (a restart resumes from the stored
136
+ cursor, so nothing is lost while it was up).
137
+ - **True webhook `action`s** — the `issues` and `pull_request` streams carry a *best-effort*
138
+ `action` (`opened` / `edited` / `closed` / `synchronize`) synthesized from state and
139
+ timestamps; polling cannot see the exact webhook action.
140
+ - **`pull_request_review` and `push`** — no clean repo-level "since" cursor, so they are not polled.
141
+ - **Multi-line PEM in the input prompt** — paste a `$ENV` / `!cmd` reference instead.
142
+
143
+ ## Security
144
+
145
+ - The App's private key grants control of the App — never commit it. Prefer a `$ENV` / `!cmd`
146
+ reference over pasting the PEM. Wolli writes `integrations.json` with mode `0600`.
147
+ - The App only sees repos it was explicitly installed on and granted, scoped to the permissions
148
+ above. Anyone who can comment on a watched repo can drive the agent via `@mention`.
@@ -0,0 +1,260 @@
1
+ import { createPublicKey, createVerify, generateKeyPairSync } from "node:crypto";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import {
4
+ chunkComment,
5
+ clearInstallationTokenCache,
6
+ createAppJwt,
7
+ createInstallationToken,
8
+ extractMention,
9
+ githubRequest,
10
+ GITHUB_COMMENT_MARKER,
11
+ isIgnoredInboundComment,
12
+ normalizeIssue,
13
+ normalizeIssueComment,
14
+ normalizePullRequest,
15
+ normalizeReviewComment,
16
+ parseThreadTag,
17
+ selectPullRequests,
18
+ selectSinceItems,
19
+ } from "./github-api.ts";
20
+
21
+ function keyPair(): { privateKey: string; publicKey: string } {
22
+ const generated = generateKeyPairSync("rsa", { modulusLength: 2048 });
23
+ return {
24
+ privateKey: generated.privateKey.export({ format: "pem", type: "pkcs8" }).toString(),
25
+ publicKey: generated.publicKey.export({ format: "pem", type: "spki" }).toString(),
26
+ };
27
+ }
28
+
29
+ describe("createAppJwt", () => {
30
+ it("produces a valid RS256 JWT with the right iss/iat/exp", () => {
31
+ const { privateKey, publicKey } = keyPair();
32
+ const nowMs = Date.parse("2026-06-01T00:00:00Z");
33
+ const jwt = createAppJwt("12345", privateKey, nowMs);
34
+ const [header, payload, signature] = jwt.split(".");
35
+
36
+ expect(JSON.parse(Buffer.from(header, "base64url").toString("utf8"))).toMatchObject({
37
+ alg: "RS256",
38
+ typ: "JWT",
39
+ });
40
+ const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
41
+ const nowSeconds = Math.floor(nowMs / 1000);
42
+ expect(claims.iss).toBe("12345");
43
+ expect(claims.iat).toBe(nowSeconds - 60);
44
+ expect(claims.exp).toBe(nowSeconds + 600);
45
+
46
+ const verifier = createVerify("RSA-SHA256");
47
+ verifier.update(`${header}.${payload}`);
48
+ expect(verifier.verify(createPublicKey(publicKey), Buffer.from(signature, "base64url"))).toBe(true);
49
+ });
50
+ });
51
+
52
+ describe("createInstallationToken", () => {
53
+ beforeEach(() => clearInstallationTokenCache());
54
+
55
+ it("exchanges and caches installation tokens", async () => {
56
+ const { privateKey } = keyPair();
57
+ const fetchMock = vi.fn().mockResolvedValue(
58
+ new Response(JSON.stringify({ token: "ghs_installation", expires_at: "2099-06-01T01:00:00Z" })),
59
+ );
60
+
61
+ const first = await createInstallationToken({ appId: "1", privateKey }, 99, {
62
+ baseUrl: "https://github.test",
63
+ fetchImpl: fetchMock,
64
+ });
65
+ const second = await createInstallationToken({ appId: "1", privateKey }, 99, {
66
+ baseUrl: "https://github.test",
67
+ fetchImpl: fetchMock,
68
+ });
69
+
70
+ expect(first).toBe("ghs_installation");
71
+ expect(second).toBe("ghs_installation");
72
+ expect(fetchMock).toHaveBeenCalledTimes(1);
73
+ expect(fetchMock.mock.calls[0][0]).toBe("https://github.test/app/installations/99/access_tokens");
74
+ });
75
+ });
76
+
77
+ describe("githubRequest", () => {
78
+ it("returns a 304 without throwing so a conditional poll can skip", async () => {
79
+ // The Response constructor forbids a 304 (null-body) status, so mock the shape githubRequest reads.
80
+ const fetchMock = vi.fn().mockResolvedValue({
81
+ ok: false,
82
+ status: 304,
83
+ headers: new Headers(),
84
+ text: async () => "",
85
+ });
86
+ const res = await githubRequest("GET", "/repos/o/r/issues/comments", {
87
+ token: "t",
88
+ etag: '"abc"',
89
+ fetchImpl: fetchMock,
90
+ });
91
+ expect(res.status).toBe(304);
92
+ expect(new Headers(fetchMock.mock.calls[0][1].headers).get("if-none-match")).toBe('"abc"');
93
+ });
94
+
95
+ it("surfaces the X-Poll-Interval header in ms and the etag", async () => {
96
+ const fetchMock = vi
97
+ .fn()
98
+ .mockResolvedValue(new Response("[]", { status: 200, headers: { etag: '"e"', "x-poll-interval": "90" } }));
99
+ const res = await githubRequest("GET", "/x", { fetchImpl: fetchMock });
100
+ expect(res.etag).toBe('"e"');
101
+ expect(res.pollIntervalMs).toBe(90_000);
102
+ });
103
+
104
+ it("throws on a non-2xx that is not 304", async () => {
105
+ const fetchMock = vi.fn().mockResolvedValue(new Response("nope", { status: 404 }));
106
+ await expect(githubRequest("GET", "/x", { fetchImpl: fetchMock })).rejects.toThrow(/HTTP 404/);
107
+ });
108
+ });
109
+
110
+ describe("normalizers", () => {
111
+ it("marks a PR timeline comment and reads the issue number from issue_url", () => {
112
+ const payload = normalizeIssueComment("o/r", {
113
+ id: 5,
114
+ body: "hi",
115
+ html_url: "https://github.com/o/r/pull/12#issuecomment-5",
116
+ issue_url: "https://api.github.com/repos/o/r/issues/12",
117
+ user: { login: "alice", type: "User" },
118
+ });
119
+ expect(payload).toMatchObject({
120
+ repo: "o/r",
121
+ isPullRequest: true,
122
+ issueNumber: 12,
123
+ commentId: 5,
124
+ authorLogin: "alice",
125
+ authorType: "User",
126
+ });
127
+ });
128
+
129
+ it("treats an /issues/ comment as not a pull request", () => {
130
+ const payload = normalizeIssueComment("o/r", {
131
+ id: 6,
132
+ html_url: "https://github.com/o/r/issues/7#issuecomment-6",
133
+ issue_url: "https://api.github.com/repos/o/r/issues/7",
134
+ });
135
+ expect(payload.isPullRequest).toBe(false);
136
+ expect(payload.issueNumber).toBe(7);
137
+ });
138
+
139
+ it("reads the PR number from a review comment's pull_request_url", () => {
140
+ const payload = normalizeReviewComment("o/r", {
141
+ id: 9,
142
+ in_reply_to_id: 8,
143
+ pull_request_url: "https://api.github.com/repos/o/r/pulls/34",
144
+ user: { login: "bob", type: "User" },
145
+ });
146
+ expect(payload).toMatchObject({ pullRequestNumber: 34, commentId: 9, inReplyToId: 8 });
147
+ });
148
+
149
+ it("synthesizes a best-effort action for issues", () => {
150
+ expect(normalizeIssue("o/r", { number: 1, created_at: "t", updated_at: "t" }).action).toBe("opened");
151
+ expect(normalizeIssue("o/r", { number: 1, created_at: "t", updated_at: "u" }).action).toBe("edited");
152
+ expect(normalizeIssue("o/r", { number: 1, state: "closed" }).action).toBe("closed");
153
+ });
154
+
155
+ it("reads the head SHA and draft flag for pull requests", () => {
156
+ const payload = normalizePullRequest("o/r", {
157
+ number: 3,
158
+ head: { sha: "deadbeef" },
159
+ draft: true,
160
+ state: "open",
161
+ created_at: "t",
162
+ updated_at: "t",
163
+ user: { login: "carol" },
164
+ });
165
+ expect(payload).toMatchObject({ pullRequestNumber: 3, headSha: "deadbeef", draft: true, action: "opened" });
166
+ });
167
+ });
168
+
169
+ describe("mention + loop gating", () => {
170
+ it("strips the bot mention and returns the message", () => {
171
+ expect(extractMention("@mybot please review", "mybot")).toEqual({ message: "please review" });
172
+ expect(extractMention("hey @mybot look here", "mybot")).toEqual({ message: "hey look here" });
173
+ });
174
+
175
+ it("returns null when the bot is not mentioned", () => {
176
+ expect(extractMention("no mention here", "mybot")).toBeNull();
177
+ expect(extractMention("@other do it", "mybot")).toBeNull();
178
+ });
179
+
180
+ it("ignores our own marker, other bots, and our own bot login", () => {
181
+ expect(isIgnoredInboundComment(`done ${GITHUB_COMMENT_MARKER}`, "someone", "User", "mybot")).toBe(true);
182
+ expect(isIgnoredInboundComment("x", "some-bot", "Bot", "mybot")).toBe(true);
183
+ expect(isIgnoredInboundComment("x", "mybot[bot]", "User", "mybot")).toBe(true);
184
+ expect(isIgnoredInboundComment("@mybot hi", "human", "User", "mybot")).toBe(false);
185
+ });
186
+ });
187
+
188
+ describe("chunkComment", () => {
189
+ it("returns a single chunk when short", () => {
190
+ expect(chunkComment("hello", 100)).toEqual(["hello"]);
191
+ });
192
+
193
+ it("splits over the limit into bounded chunks", () => {
194
+ const body = "a".repeat(250);
195
+ const chunks = chunkComment(body, 100);
196
+ expect(chunks.length).toBeGreaterThan(1);
197
+ for (const c of chunks) expect(c.length).toBeLessThanOrEqual(100);
198
+ });
199
+ });
200
+
201
+ describe("parseThreadTag", () => {
202
+ it("splits owner/repo#number", () => {
203
+ expect(parseThreadTag("acme/widgets#42")).toEqual({ repo: "acme/widgets", number: 42 });
204
+ });
205
+ });
206
+
207
+ describe("selectSinceItems", () => {
208
+ it("emits strictly-newer items and advances the cursor", () => {
209
+ const result = selectSinceItems(
210
+ [
211
+ { id: 1, ts: "2026-01-01T00:00:00Z" },
212
+ { id: 2, ts: "2026-01-01T00:00:05Z" },
213
+ ],
214
+ "2026-01-01T00:00:00Z",
215
+ [1],
216
+ );
217
+ expect(result.toEmit.map((i) => i.id)).toEqual([2]);
218
+ expect(result.nextCursor).toBe("2026-01-01T00:00:05Z");
219
+ expect(result.nextSeenIds).toEqual([2]);
220
+ });
221
+
222
+ it("emits a genuinely-new same-second item but not an already-seen one", () => {
223
+ const result = selectSinceItems(
224
+ [
225
+ { id: 1, ts: "2026-01-01T00:00:00Z" },
226
+ { id: 2, ts: "2026-01-01T00:00:00Z" },
227
+ ],
228
+ "2026-01-01T00:00:00Z",
229
+ [1],
230
+ );
231
+ expect(result.toEmit.map((i) => i.id)).toEqual([2]);
232
+ expect(result.nextCursor).toBe("2026-01-01T00:00:00Z");
233
+ expect(result.nextSeenIds.sort()).toEqual([1, 2]);
234
+ });
235
+
236
+ it("emits nothing on a boundary re-fetch of already-seen items", () => {
237
+ const result = selectSinceItems([{ id: 1, ts: "2026-01-01T00:00:00Z" }], "2026-01-01T00:00:00Z", [1]);
238
+ expect(result.toEmit).toEqual([]);
239
+ expect(result.nextCursor).toBe("2026-01-01T00:00:00Z");
240
+ });
241
+ });
242
+
243
+ describe("selectPullRequests", () => {
244
+ it("emits new and head-advanced PRs, skips unchanged, and rebuilds the head map", () => {
245
+ const result = selectPullRequests(
246
+ [
247
+ { number: 1, headSha: "aaa" },
248
+ { number: 2, headSha: "ccc" },
249
+ ],
250
+ { "1": "aaa", "2": "bbb" },
251
+ );
252
+ expect(result.toEmit.map((p) => p.number)).toEqual([2]);
253
+ expect(result.nextPrHeads).toEqual({ "1": "aaa", "2": "ccc" });
254
+ });
255
+
256
+ it("emits a brand-new PR", () => {
257
+ const result = selectPullRequests([{ number: 9, headSha: "zzz" }], {});
258
+ expect(result.toEmit.map((p) => p.number)).toEqual([9]);
259
+ });
260
+ });