mirror of
https://github.com/cline/cline.git
synced 2026-09-02 07:42:19 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 55d78f4bfa |
@@ -370,6 +370,26 @@
|
||||
"@cline/core",
|
||||
],
|
||||
},
|
||||
"sdk/examples/plugins/gmail-work-to-do": {
|
||||
"name": "@cline/example-plugin-gmail-work-to-do",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"googleapis": "^170.0.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.3.5",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.18",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@cline/core": "*",
|
||||
"@cline/shared": "*",
|
||||
},
|
||||
"optionalPeers": [
|
||||
"@cline/core",
|
||||
"@cline/shared",
|
||||
],
|
||||
},
|
||||
"sdk/packages/agents": {
|
||||
"name": "@cline/agents",
|
||||
"version": "0.0.47",
|
||||
@@ -688,6 +708,8 @@
|
||||
|
||||
"@cline/example-multi-agent": ["@cline/example-multi-agent@workspace:apps/examples/multi-agent"],
|
||||
|
||||
"@cline/example-plugin-gmail-work-to-do": ["@cline/example-plugin-gmail-work-to-do@workspace:sdk/examples/plugins/gmail-work-to-do"],
|
||||
|
||||
"@cline/example-quickstart": ["@cline/example-quickstart@workspace:apps/examples/quickstart"],
|
||||
|
||||
"@cline/llms": ["@cline/llms@workspace:sdk/packages/llms"],
|
||||
@@ -2218,6 +2240,8 @@
|
||||
|
||||
"google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="],
|
||||
|
||||
"googleapis": ["googleapis@170.1.0", "", { "dependencies": { "google-auth-library": "^10.2.0", "googleapis-common": "^8.0.0" } }, "sha512-RLbc7yG6qzZqvAmGcgjvNIoZ7wpcCFxtc+HN+46etxDrlO4a8l5Cb7NxNQGhV91oRmL7mt56VoRoypAtEQEIKg=="],
|
||||
|
||||
"googleapis-common": ["googleapis-common@8.0.1", "", { "dependencies": { "extend": "^3.0.2", "gaxios": "^7.0.0-rc.4", "google-auth-library": "^10.1.0", "qs": "^6.7.0", "url-template": "^2.0.8" } }, "sha512-eCzNACUXPb1PW5l0ULTzMHaL/ltPRADoPgjBlT8jWsTbxkCp6siv+qKJ/1ldaybCthGwsYFYallF7u9AkU4L+A=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
@@ -309,6 +309,6 @@ See [Plugins](/customization/plugins) for the full manifest format, directory la
|
||||
|
||||
3. Register all tools in `setup()`, not in lifecycle hooks. Tools must be available before the first iteration.
|
||||
|
||||
4. Use lifecycle hooks for observation (logging, metrics, auditing), not for modifying agent behavior. If you need to modify behavior, consider using the `beforeRun` or `beforeModel` hooks to adjust the system prompt or context.
|
||||
4. Use lifecycle hooks for observation (logging, metrics, auditing), not for modifying agent behavior. If you need to modify behavior, consider using the `beforeRun` or `beforeModel` hooks to adjust the system prompt or context. `beforeRun` is the pre-inference hook: it can return `{ stop: true, reason }` to exit normally before any model call, or `appendMessages` / `replaceMessages` to hand off work gathered by a cheap startup check.
|
||||
|
||||
5. Handle errors gracefully in hooks. A thrown error in `beforeTool` will count as a tool failure. If your hook is purely observational, catch errors internally.
|
||||
|
||||
@@ -65,6 +65,8 @@ const myPlugin: AgentPlugin = {
|
||||
|
||||
Hooks are defined inside the `hooks` object, not directly on the extension. The available lifecycle hooks are `beforeRun`, `afterRun`, `beforeModel`, `afterModel`, `beforeTool`, `afterTool`, and `onEvent`.
|
||||
|
||||
`beforeRun` is the pre-inference startup gate. It runs before the first model request and before any tool execution. Return `{ stop: true, reason: "no new work, exiting" }` for a normal non-error abort, or return `appendMessages` / `replaceMessages` to hand gathered work to the agent before inference starts.
|
||||
|
||||
|
||||
## Next Steps
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ What a plugin can do:
|
||||
| [gitignore-read-files-guard.ts](./gitignore-read-files-guard.ts) | Runtime hook policy for workspace `.gitignore` boundaries | Uses `beforeTool` to inspect `read_files`, `editor`, and `apply_patch` requests and skips them when target paths match workspace `.gitignore` rules, preventing ignored files from being read or modified. |
|
||||
| [env-blocker.ts](./env-blocker.ts) | Deterministic secret protection via `beforeTool` | Uses `beforeTool` to block `read_files` and `run_commands` (e.g. `cat .env`) calls that read `.env` secret files, while leaving `.env.example`/`.env.sample`/`.env.template` readable. A hard guarantee where an AGENTS.md rule is only a suggestion. |
|
||||
| [web-search.ts](./web-search.ts) | `web_search` tool backed by an Exa API key | Adds a `web_search` tool that queries Exa for current public web results, with optional result limits, domain filters, recency windows, and country localization. Requires `EXA_API_KEY`. |
|
||||
| [gmail-work-to-do/](./gmail-work-to-do/) | Pre-inference `beforeRun` gate with durable plugin state | Checks Gmail for new matching messages before any model call. No new mail returns a normal abort (`no new mail, exiting`); new mail is handed to the agent as run context. Requires Gmail OAuth configuration and one of `GMAIL_SEARCH_QUERY`, `GMAIL_LABEL_ID`, or `GMAIL_LABEL`. |
|
||||
| [openrouter-provider.ts](./openrouter-provider.ts) | Custom model provider via `registerProvider` | Registers an OpenAI-compatible model provider (pointed at OpenRouter) plus its model catalog so the agent can run inference against it. Swap the base URL, API key env var, and models to add any OpenAI-compatible endpoint Cline does not bundle. Requires `OPENROUTER_API_KEY`. |
|
||||
| [typescript-lsp/](./typescript-lsp/) | `goto_definition` tool powered by the TypeScript Language Service | Adds `goto_definition(file, line)` for TypeScript/JavaScript projects. It loads the target project’s own TypeScript version, finds identifiers on a line, and resolves definitions through imports, re-exports, aliases, and other language-service semantics. |
|
||||
| [agents-squad/](./agents-squad/) | Multi-agent team — spin up subagents with their own models and personalities | Adds tools for starting, messaging, polling, and coordinating background subagents. It includes bundled agent presets, skill discovery/loading, and a shared handoff store for passing notes between subagents in the same conversation. |
|
||||
@@ -157,7 +158,27 @@ Hooks are typed, in-process callbacks on the same hook layer as `@cline/agents`.
|
||||
| `afterTool` | after each tool execution |
|
||||
| `onEvent` | every `AgentRuntimeEvent` emitted by the runtime |
|
||||
|
||||
`beforeRun` and `afterRun` wrap one `run()` / `continue()` invocation — in an interactive session, that's one user turn. `afterRun` is the right place for completion notifications, but it also fires on aborted and failed runs, so check `result.status === "completed"` if you only want successes.
|
||||
`beforeRun` and `afterRun` wrap one `run()` / `continue()` invocation — in an interactive session, that's one user turn. `beforeRun` is the earliest pre-inference hook: it runs before the first model request and before any tool execution. It can stop the run normally with `{ stop: true, reason: "no new work, exiting" }`, or hand off gathered work with `appendMessages` / `replaceMessages` so the first model request sees it. `afterRun` is the right place for completion notifications, but it also fires on aborted and failed runs, so check `result.status === "completed"` if you only want successes.
|
||||
|
||||
```ts
|
||||
hooks: {
|
||||
async beforeRun() {
|
||||
const work = await pollCheaply()
|
||||
if (work.length === 0) {
|
||||
return { stop: true, reason: "no new work, exiting" }
|
||||
}
|
||||
return {
|
||||
reason: `found ${work.length} item(s)`,
|
||||
appendMessages: [{
|
||||
id: `msg_work_${Date.now()}`,
|
||||
role: "user",
|
||||
createdAt: Date.now(),
|
||||
content: [{ type: "text", text: JSON.stringify(work, null, 2) }],
|
||||
}],
|
||||
}
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Plugin hooks vs file hooks
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
# Gmail Work-to-Do Gate Plugin
|
||||
|
||||
Example package plugin that uses the SDK `beforeRun` pre-inference hook to wake cheaply, check Gmail, and only let the agent start when matching mail is new to the plugin.
|
||||
|
||||
If no new matching mail exists, the hook returns:
|
||||
|
||||
```ts
|
||||
{ stop: true, reason: "no new mail, exiting" }
|
||||
```
|
||||
|
||||
That exits the run normally before any model request or tool execution.
|
||||
|
||||
## Install / load
|
||||
|
||||
From this repository:
|
||||
|
||||
```bash
|
||||
cline plugin install ./sdk/examples/plugins/gmail-work-to-do --cwd /path/to/workspace
|
||||
```
|
||||
|
||||
Or pass it directly to `ClineCore` with `pluginPaths`.
|
||||
|
||||
## Cron-gated run flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Cron["Cline SDK cron job wakes up"] --> Start["Start agent session configured with gmail-work-to-do plugin"]
|
||||
Start --> Hook["Plugin beforeRun hook executes before inference or tools"]
|
||||
Hook --> Gmail["Check Gmail using GMAIL_SEARCH_QUERY, GMAIL_LABEL_ID, or GMAIL_LABEL"]
|
||||
Gmail --> State["Compare matching messages against durable high-water state"]
|
||||
State --> Decision{"Any new matching messages?"}
|
||||
Decision -- "No" --> Abort["Return stop: true\nreason: no new mail, exiting"]
|
||||
Abort --> Exit["Clean normal exit\nNo model request\nNo tool execution"]
|
||||
Decision -- "Yes" --> Fetch["Download new message content"]
|
||||
Fetch --> Persist["Update durable processed-mail state"]
|
||||
Persist --> Handoff["Return appendMessages with Gmail work payload"]
|
||||
Handoff --> Agent["Agent loop starts with Gmail messages as context"]
|
||||
Agent --> Inference["First model request happens only after work is found"]
|
||||
```
|
||||
|
||||
## Required environment variables
|
||||
|
||||
### Search or label
|
||||
|
||||
- `GMAIL_SEARCH_QUERY` — Gmail search query passed to `users.messages.list`, for example:
|
||||
- `label:inbox newer_than:1d from:alerts@example.com`
|
||||
- `to:me subject:(Action Required)`
|
||||
- `label:work -label:processed`
|
||||
- `GMAIL_LABEL_ID` — Gmail label ID to list directly, for example `INBOX`, `Label_123456789`, or another ID from Gmail's labels API.
|
||||
- `GMAIL_LABEL` — Gmail label display name to resolve through the labels API, for example `Work/To Do`.
|
||||
|
||||
Set **one** of `GMAIL_SEARCH_QUERY`, `GMAIL_LABEL_ID`, or `GMAIL_LABEL`. If multiple are set, `GMAIL_SEARCH_QUERY` takes precedence, then `GMAIL_LABEL_ID`, then `GMAIL_LABEL`.
|
||||
|
||||
Optional:
|
||||
|
||||
- `GMAIL_MAX_RESULTS` — maximum Gmail search results to inspect per wake-up. Default `25`, max `100`.
|
||||
- `GMAIL_WORK_STATE_PATH` — override the durable state file path. By default, state is stored under `${CLINE_DATA_DIR:-~/.cline/data}/plugins/gmail-work-to-do/state.json`.
|
||||
|
||||
### OAuth
|
||||
|
||||
Use a Google OAuth token with Gmail read-only access. The plugin requests data with the Gmail API; it does not hardcode secrets.
|
||||
|
||||
For short-lived local tests, you can provide a simple access token:
|
||||
|
||||
- `GMAIL_ACCESS_TOKEN` — OAuth access token with Gmail read scope. Google access tokens usually expire in about one hour, so this is convenient for manual tests but less suitable for unattended cron unless another process refreshes it.
|
||||
|
||||
#### Creating a throwaway access token with OAuth 2.0 Playground
|
||||
|
||||
For quick local testing, you can create a short-lived token with Google's OAuth 2.0 Playground:
|
||||
|
||||
1. Go to <https://developers.google.com/oauthplayground>.
|
||||
2. In the **Input your own scopes** box on the left, paste:
|
||||
|
||||
```txt
|
||||
https://www.googleapis.com/auth/gmail.readonly
|
||||
```
|
||||
|
||||
3. Click **Authorize APIs**.
|
||||
4. Sign in with the Gmail account whose mail you want to read and grant consent.
|
||||
5. Click **Exchange authorization code for tokens**.
|
||||
6. Copy the generated access token and export it:
|
||||
|
||||
```bash
|
||||
export GMAIL_ACCESS_TOKEN="ya29.your_access_token_here"
|
||||
```
|
||||
|
||||
The access token is short-lived, typically about one hour. The Playground also shows a refresh token; use refresh-token OAuth config for unattended cron runs.
|
||||
|
||||
Or provide an access token in `GMAIL_TOKEN_PATH`:
|
||||
|
||||
```json
|
||||
{
|
||||
"access_token": "ya29.example_access_token",
|
||||
"scope": "https://www.googleapis.com/auth/gmail.readonly",
|
||||
"token_type": "Bearer"
|
||||
}
|
||||
```
|
||||
|
||||
For cron-friendly automatic refresh, provide direct refresh-token OAuth variables:
|
||||
|
||||
- `GMAIL_CLIENT_ID`
|
||||
- `GMAIL_CLIENT_SECRET`
|
||||
- `GMAIL_REFRESH_TOKEN`
|
||||
- `GMAIL_REDIRECT_URI` optional, depending on your OAuth client
|
||||
|
||||
Or provide files exported from a standard OAuth setup. If `GMAIL_TOKEN_PATH` contains `refresh_token`, the plugin uses it for automatic refresh; if it contains only `access_token`, the plugin uses that short-lived token directly:
|
||||
|
||||
- `GMAIL_CREDENTIALS_PATH` — JSON credentials file from Google Cloud Console (`installed` or `web` client shape)
|
||||
- `GMAIL_TOKEN_PATH` — JSON token file containing `access_token` or `refresh_token`
|
||||
|
||||
The OAuth token must have a scope that can read messages, such as:
|
||||
|
||||
```txt
|
||||
https://www.googleapis.com/auth/gmail.readonly
|
||||
```
|
||||
|
||||
## How new-vs-processed tracking works
|
||||
|
||||
The plugin does **not** rely on the mutable Gmail `UNREAD` label. Instead it stores durable per-plugin state:
|
||||
|
||||
```json
|
||||
{
|
||||
"maxInternalDate": "1760000000000",
|
||||
"seenIdsAtMaxInternalDate": ["message-id-at-boundary"]
|
||||
}
|
||||
```
|
||||
|
||||
Gmail message `id` and `internalDate` are stable. The high-water mark is the largest `internalDate` processed so far. Because multiple messages can share the same `internalDate`, the plugin also stores the set of message IDs already seen at that timestamp boundary.
|
||||
|
||||
On each wake-up:
|
||||
|
||||
1. Search Gmail with `GMAIL_SEARCH_QUERY`, or list messages for `GMAIL_LABEL_ID` / `GMAIL_LABEL`.
|
||||
2. Fetch full content for matching messages.
|
||||
3. Select messages with `internalDate` greater than the high-water mark, plus unseen IDs at the exact boundary timestamp.
|
||||
4. If none are new, log `no new mail, exiting` and abort normally before inference.
|
||||
5. If any are new, update the durable state and hand the messages to the agent as a pre-run user message.
|
||||
|
||||
## Error behavior
|
||||
|
||||
- Empty search result or all results already processed: normal abort, not an error.
|
||||
- Missing OAuth configuration: error.
|
||||
- Gmail API/auth failures: error.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
bun -F @cline/example-plugin-gmail-work-to-do test
|
||||
```
|
||||
|
||||
The tests cover high-water dedupe, same-timestamp boundary handling, no-new-mail abort, and handoff when new mail exists.
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@cline/example-plugin-gmail-work-to-do",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"cline": {
|
||||
"plugins": [
|
||||
{
|
||||
"paths": [
|
||||
"./src/index.ts"
|
||||
],
|
||||
"capabilities": [
|
||||
"hooks"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"googleapis": "^170.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@cline/core": "*",
|
||||
"@cline/shared": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@cline/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@cline/shared": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.3.5",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { advanceStateForProcessedMessages, selectNewMessages } from "../dedupe";
|
||||
import { runGmailWorkGate } from "../gate";
|
||||
import {
|
||||
type GmailFetchedMessage,
|
||||
resolveGmailAuthCredentials,
|
||||
} from "../gmail";
|
||||
import type { GmailWorkState } from "../state";
|
||||
|
||||
const previousEnv = { ...process.env };
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...previousEnv };
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function message(id: string, internalDate: string): GmailFetchedMessage {
|
||||
return {
|
||||
id,
|
||||
internalDate,
|
||||
subject: `Subject ${id}`,
|
||||
from: "sender@example.com",
|
||||
bodyText: `Body ${id}`,
|
||||
};
|
||||
}
|
||||
|
||||
describe("gmail-work-to-do dedupe", () => {
|
||||
it("identifies exactly 5 new messages when 5 were already processed", () => {
|
||||
const state: GmailWorkState = {
|
||||
maxInternalDate: "1005",
|
||||
seenIdsAtMaxInternalDate: ["m5"],
|
||||
};
|
||||
const messages = Array.from({ length: 10 }, (_, index) =>
|
||||
message(`m${index + 1}`, String(1001 + index)),
|
||||
);
|
||||
|
||||
const selected = selectNewMessages(messages, state);
|
||||
|
||||
expect(selected.map((item) => item.id)).toEqual([
|
||||
"m6",
|
||||
"m7",
|
||||
"m8",
|
||||
"m9",
|
||||
"m10",
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles same-timestamp high-water boundary ids", () => {
|
||||
const state: GmailWorkState = {
|
||||
maxInternalDate: "2000",
|
||||
seenIdsAtMaxInternalDate: ["seen-a", "seen-b"],
|
||||
};
|
||||
const messages = [
|
||||
message("old", "1999"),
|
||||
message("seen-a", "2000"),
|
||||
message("new-at-boundary", "2000"),
|
||||
message("newer", "2001"),
|
||||
];
|
||||
|
||||
const selected = selectNewMessages(messages, state);
|
||||
const advanced = advanceStateForProcessedMessages(state, selected);
|
||||
|
||||
expect(selected.map((item) => item.id)).toEqual([
|
||||
"new-at-boundary",
|
||||
"newer",
|
||||
]);
|
||||
expect(advanced).toEqual({
|
||||
maxInternalDate: "2001",
|
||||
seenIdsAtMaxInternalDate: ["newer"],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a normal abort when there is no new mail", async () => {
|
||||
process.env.GMAIL_SEARCH_QUERY = "label:inbox newer_than:1d";
|
||||
const writeState = vi.fn();
|
||||
const logger = {
|
||||
debug: vi.fn(),
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
const result = await runGmailWorkGate({
|
||||
logger,
|
||||
readState: () => ({
|
||||
maxInternalDate: "3000",
|
||||
seenIdsAtMaxInternalDate: ["m1"],
|
||||
}),
|
||||
writeState,
|
||||
fetchMessages: async () => [message("m1", "3000")],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
stop: true,
|
||||
reason: "no new mail, exiting",
|
||||
});
|
||||
expect(writeState).not.toHaveBeenCalled();
|
||||
expect(logger.log).toHaveBeenCalledWith(
|
||||
"Gmail work-to-do gate: no new mail, exiting",
|
||||
expect.objectContaining({ severity: "info" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("updates state and hands new mail to the agent", async () => {
|
||||
process.env.GMAIL_SEARCH_QUERY = "from:alerts@example.com";
|
||||
const writeState = vi.fn();
|
||||
|
||||
const result = await runGmailWorkGate({
|
||||
readState: () => ({
|
||||
maxInternalDate: "4000",
|
||||
seenIdsAtMaxInternalDate: ["old"],
|
||||
}),
|
||||
writeState,
|
||||
fetchMessages: async () => [
|
||||
message("old", "4000"),
|
||||
message("new-a", "4000"),
|
||||
message("new-b", "4001"),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.stop).toBeUndefined();
|
||||
expect(result.reason).toBe("found 2 new Gmail message(s)");
|
||||
expect(result.appendMessages).toHaveLength(1);
|
||||
expect(result.appendMessages?.[0]?.content[0]).toMatchObject({
|
||||
type: "text",
|
||||
text: expect.stringContaining("new-b"),
|
||||
});
|
||||
expect(writeState).toHaveBeenCalledWith({
|
||||
maxInternalDate: "4001",
|
||||
seenIdsAtMaxInternalDate: ["new-b"],
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a Gmail label id instead of a search query", async () => {
|
||||
process.env.GMAIL_LABEL_ID = "Label_123";
|
||||
const seenFetchInput: unknown[] = [];
|
||||
|
||||
const result = await runGmailWorkGate({
|
||||
readState: () => ({ seenIdsAtMaxInternalDate: [] }),
|
||||
writeState: vi.fn(),
|
||||
fetchMessages: async (input) => {
|
||||
seenFetchInput.push(input);
|
||||
return [message("label-message", "5000")];
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.stop).toBeUndefined();
|
||||
expect(seenFetchInput).toEqual([
|
||||
expect.objectContaining({
|
||||
labelId: "Label_123",
|
||||
query: undefined,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts a Gmail label name instead of a search query", async () => {
|
||||
process.env.GMAIL_LABEL = "Work/To Do";
|
||||
const seenFetchInput: unknown[] = [];
|
||||
|
||||
const result = await runGmailWorkGate({
|
||||
readState: () => ({ seenIdsAtMaxInternalDate: [] }),
|
||||
writeState: vi.fn(),
|
||||
fetchMessages: async (input) => {
|
||||
seenFetchInput.push(input);
|
||||
return [message("label-name-message", "6000")];
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.stop).toBeUndefined();
|
||||
expect(seenFetchInput).toEqual([
|
||||
expect.objectContaining({
|
||||
labelName: "Work/To Do",
|
||||
labelId: undefined,
|
||||
query: undefined,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("requires either a Gmail search query or label", async () => {
|
||||
await expect(
|
||||
runGmailWorkGate({
|
||||
readState: () => ({ seenIdsAtMaxInternalDate: [] }),
|
||||
writeState: vi.fn(),
|
||||
fetchMessages: async () => [],
|
||||
}),
|
||||
).rejects.toThrow("Set GMAIL_SEARCH_QUERY, GMAIL_LABEL_ID, or GMAIL_LABEL");
|
||||
});
|
||||
});
|
||||
|
||||
describe("gmail-work-to-do auth config", () => {
|
||||
it("accepts a simple access token from env", () => {
|
||||
expect(
|
||||
resolveGmailAuthCredentials({
|
||||
env: { GMAIL_ACCESS_TOKEN: " ya29.test " },
|
||||
}),
|
||||
).toEqual({ kind: "access-token", accessToken: "ya29.test" });
|
||||
});
|
||||
|
||||
it("accepts an access token from GMAIL_TOKEN_PATH", () => {
|
||||
expect(
|
||||
resolveGmailAuthCredentials({
|
||||
env: { GMAIL_TOKEN_PATH: "/tmp/token.json" },
|
||||
readJsonFile: () => ({ access_token: "ya29.from-file" }),
|
||||
}),
|
||||
).toEqual({ kind: "access-token", accessToken: "ya29.from-file" });
|
||||
});
|
||||
|
||||
it("keeps refresh-token OAuth config working", () => {
|
||||
expect(
|
||||
resolveGmailAuthCredentials({
|
||||
env: {
|
||||
GMAIL_CLIENT_ID: "client-id",
|
||||
GMAIL_CLIENT_SECRET: "client-secret",
|
||||
GMAIL_REFRESH_TOKEN: "refresh-token",
|
||||
GMAIL_REDIRECT_URI: "http://localhost",
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "refresh-token",
|
||||
clientId: "client-id",
|
||||
clientSecret: "client-secret",
|
||||
refreshToken: "refresh-token",
|
||||
redirectUri: "http://localhost",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { GmailWorkState } from "./state";
|
||||
|
||||
export interface GmailMessageCandidate {
|
||||
id: string;
|
||||
internalDate: string;
|
||||
}
|
||||
|
||||
function compareInternalDate(
|
||||
left: string | undefined,
|
||||
right: string | undefined,
|
||||
): number {
|
||||
const a = Number(left ?? "0");
|
||||
const b = Number(right ?? "0");
|
||||
if (!Number.isFinite(a) || !Number.isFinite(b)) {
|
||||
return String(left ?? "").localeCompare(String(right ?? ""));
|
||||
}
|
||||
return a - b;
|
||||
}
|
||||
|
||||
export function selectNewMessages<T extends GmailMessageCandidate>(
|
||||
messages: readonly T[],
|
||||
state: GmailWorkState,
|
||||
): T[] {
|
||||
const boundaryIds = new Set(state.seenIdsAtMaxInternalDate);
|
||||
return messages
|
||||
.filter((message) => {
|
||||
const compared = compareInternalDate(
|
||||
message.internalDate,
|
||||
state.maxInternalDate,
|
||||
);
|
||||
if (!state.maxInternalDate || compared > 0) {
|
||||
return true;
|
||||
}
|
||||
if (compared < 0) {
|
||||
return false;
|
||||
}
|
||||
return !boundaryIds.has(message.id);
|
||||
})
|
||||
.sort((left, right) => {
|
||||
const byDate = compareInternalDate(left.internalDate, right.internalDate);
|
||||
return byDate !== 0 ? byDate : left.id.localeCompare(right.id);
|
||||
});
|
||||
}
|
||||
|
||||
export function advanceStateForProcessedMessages(
|
||||
state: GmailWorkState,
|
||||
processed: readonly GmailMessageCandidate[],
|
||||
): GmailWorkState {
|
||||
if (processed.length === 0) {
|
||||
return {
|
||||
maxInternalDate: state.maxInternalDate,
|
||||
seenIdsAtMaxInternalDate: [...state.seenIdsAtMaxInternalDate],
|
||||
};
|
||||
}
|
||||
|
||||
let maxInternalDate = state.maxInternalDate;
|
||||
let seenAtBoundary = new Set(state.seenIdsAtMaxInternalDate);
|
||||
for (const message of processed) {
|
||||
const compared = compareInternalDate(message.internalDate, maxInternalDate);
|
||||
if (!maxInternalDate || compared > 0) {
|
||||
maxInternalDate = message.internalDate;
|
||||
seenAtBoundary = new Set([message.id]);
|
||||
continue;
|
||||
}
|
||||
if (compared === 0) {
|
||||
seenAtBoundary.add(message.id);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
maxInternalDate,
|
||||
seenIdsAtMaxInternalDate: [...seenAtBoundary].sort(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import type { BasicLogger } from "@cline/core";
|
||||
import { advanceStateForProcessedMessages, selectNewMessages } from "./dedupe";
|
||||
import type { GmailFetchedMessage } from "./gmail";
|
||||
import { type GmailWorkState, readState, writeState } from "./state";
|
||||
|
||||
export interface GmailGateOptions {
|
||||
logger?: BasicLogger;
|
||||
readState?: () => GmailWorkState;
|
||||
writeState?: (state: GmailWorkState) => void;
|
||||
fetchMessages?: (input: {
|
||||
query?: string;
|
||||
labelId?: string;
|
||||
labelName?: string;
|
||||
maxResults: number;
|
||||
}) => Promise<GmailFetchedMessage[]>;
|
||||
}
|
||||
|
||||
interface GmailWorkSource {
|
||||
description: string;
|
||||
query?: string;
|
||||
labelId?: string;
|
||||
labelName?: string;
|
||||
}
|
||||
|
||||
interface HandoffMessage {
|
||||
id: string;
|
||||
role: "user";
|
||||
createdAt: number;
|
||||
content: Array<{ type: "text"; text: string }>;
|
||||
}
|
||||
|
||||
export interface GmailGateResult {
|
||||
stop?: boolean;
|
||||
reason?: string;
|
||||
appendMessages?: HandoffMessage[];
|
||||
}
|
||||
|
||||
function env(name: string): string | undefined {
|
||||
const value = process.env[name]?.trim();
|
||||
return value ? value : undefined;
|
||||
}
|
||||
|
||||
function maxResultsFromEnv(): number {
|
||||
const parsed = Number(env("GMAIL_MAX_RESULTS") ?? "25");
|
||||
return Number.isFinite(parsed) && parsed > 0
|
||||
? Math.min(Math.trunc(parsed), 100)
|
||||
: 25;
|
||||
}
|
||||
|
||||
function resolveWorkSource(): GmailWorkSource {
|
||||
const query = env("GMAIL_SEARCH_QUERY");
|
||||
const labelId = env("GMAIL_LABEL_ID");
|
||||
const labelName = env("GMAIL_LABEL");
|
||||
if (query) {
|
||||
return { description: `query:${query}`, query };
|
||||
}
|
||||
if (labelId) {
|
||||
return { description: `label:${labelId}`, labelId };
|
||||
}
|
||||
if (labelName) {
|
||||
return { description: `label:${labelName}`, labelName };
|
||||
}
|
||||
throw new Error(
|
||||
"Set GMAIL_SEARCH_QUERY, GMAIL_LABEL_ID, or GMAIL_LABEL to use the Gmail work-to-do gate",
|
||||
);
|
||||
}
|
||||
|
||||
function truncate(value: string | undefined, max: number): string | undefined {
|
||||
if (!value) return undefined;
|
||||
const normalized = value.replace(/\s+/g, " ").trim();
|
||||
if (!normalized) return undefined;
|
||||
return normalized.length > max
|
||||
? `${normalized.slice(0, max - 3).trimEnd()}...`
|
||||
: normalized;
|
||||
}
|
||||
|
||||
export function formatMessagesForAgent(
|
||||
messages: readonly GmailFetchedMessage[],
|
||||
): string {
|
||||
return [
|
||||
`Gmail work-to-do gate found ${messages.length} new matching message(s).`,
|
||||
"Process these messages as the work for this run:",
|
||||
...messages.map((message, index) =>
|
||||
[
|
||||
`## Message ${index + 1}`,
|
||||
`ID: ${message.id}`,
|
||||
message.threadId ? `Thread ID: ${message.threadId}` : undefined,
|
||||
`Internal Date: ${message.internalDate}`,
|
||||
message.date ? `Date: ${message.date}` : undefined,
|
||||
message.from ? `From: ${message.from}` : undefined,
|
||||
message.to ? `To: ${message.to}` : undefined,
|
||||
message.subject ? `Subject: ${message.subject}` : undefined,
|
||||
message.snippet ? `Snippet: ${message.snippet}` : undefined,
|
||||
message.bodyText
|
||||
? `Body:\n${truncate(message.bodyText, 6000)}`
|
||||
: message.bodyHtml
|
||||
? `HTML Body:\n${truncate(message.bodyHtml, 6000)}`
|
||||
: undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
),
|
||||
].join("\n\n");
|
||||
}
|
||||
|
||||
function makeHandoffMessage(
|
||||
messages: readonly GmailFetchedMessage[],
|
||||
): HandoffMessage {
|
||||
return {
|
||||
id: `msg_gmail_work_${Date.now()}`,
|
||||
role: "user",
|
||||
createdAt: Date.now(),
|
||||
content: [{ type: "text", text: formatMessagesForAgent(messages) }],
|
||||
};
|
||||
}
|
||||
|
||||
export async function runGmailWorkGate(
|
||||
options: GmailGateOptions = {},
|
||||
): Promise<GmailGateResult> {
|
||||
const logger = options.logger;
|
||||
const source = resolveWorkSource();
|
||||
|
||||
const fetchMessages =
|
||||
options.fetchMessages ??
|
||||
(async ({ query, labelId, labelName, maxResults }) => {
|
||||
const { createGmailClient, resolveGmailLabelId, searchAndFetchMessages } =
|
||||
await import("./gmail");
|
||||
const gmail = await createGmailClient();
|
||||
const resolvedLabelId = labelName
|
||||
? await resolveGmailLabelId({ gmail, labelName })
|
||||
: labelId;
|
||||
return searchAndFetchMessages({
|
||||
gmail,
|
||||
query,
|
||||
labelId: resolvedLabelId,
|
||||
maxResults,
|
||||
});
|
||||
});
|
||||
const state = options.readState?.() ?? readState();
|
||||
const messages = await fetchMessages({
|
||||
query: source.query,
|
||||
labelId: source.labelId,
|
||||
labelName: source.labelName,
|
||||
maxResults: maxResultsFromEnv(),
|
||||
});
|
||||
const newMessages = selectNewMessages(messages, state);
|
||||
|
||||
if (newMessages.length === 0) {
|
||||
logger?.log("Gmail work-to-do gate: no new mail, exiting", {
|
||||
severity: "info",
|
||||
source: source.description,
|
||||
matchedCount: messages.length,
|
||||
});
|
||||
return { stop: true, reason: "no new mail, exiting" };
|
||||
}
|
||||
|
||||
const nextState = advanceStateForProcessedMessages(state, newMessages);
|
||||
if (options.writeState) {
|
||||
options.writeState(nextState);
|
||||
} else {
|
||||
writeState(nextState);
|
||||
}
|
||||
logger?.log("Gmail work-to-do gate: new mail found", {
|
||||
severity: "info",
|
||||
source: source.description,
|
||||
newCount: newMessages.length,
|
||||
messageIds: newMessages.map((message) => message.id),
|
||||
});
|
||||
|
||||
return {
|
||||
reason: `found ${newMessages.length} new Gmail message(s)`,
|
||||
appendMessages: [makeHandoffMessage(newMessages)],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
|
||||
interface GmailHeader {
|
||||
name?: string | null;
|
||||
value?: string | null;
|
||||
}
|
||||
|
||||
interface GmailMessagePart {
|
||||
mimeType?: string | null;
|
||||
body?: { data?: string | null } | null;
|
||||
parts?: GmailMessagePart[] | null;
|
||||
headers?: GmailHeader[] | null;
|
||||
}
|
||||
|
||||
interface GmailMessage {
|
||||
id?: string | null;
|
||||
threadId?: string | null;
|
||||
internalDate?: string | null;
|
||||
snippet?: string | null;
|
||||
payload?: GmailMessagePart | null;
|
||||
}
|
||||
|
||||
export interface GmailClient {
|
||||
users: {
|
||||
labels: {
|
||||
list(input: { userId: "me" }): Promise<{
|
||||
data: { labels?: Array<{ id?: string | null; name?: string | null }> };
|
||||
}>;
|
||||
};
|
||||
messages: {
|
||||
list(input: {
|
||||
userId: "me";
|
||||
q?: string;
|
||||
labelIds?: string[];
|
||||
maxResults: number;
|
||||
}): Promise<{ data: { messages?: Array<{ id?: string | null }> } }>;
|
||||
get(input: {
|
||||
userId: "me";
|
||||
id: string;
|
||||
format: "full";
|
||||
}): Promise<{ data: GmailMessage }>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface GmailFetchedMessage {
|
||||
id: string;
|
||||
threadId?: string;
|
||||
internalDate: string;
|
||||
snippet?: string;
|
||||
subject?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
date?: string;
|
||||
bodyText?: string;
|
||||
bodyHtml?: string;
|
||||
}
|
||||
|
||||
function readJsonFile(path: string): unknown {
|
||||
if (!existsSync(path)) {
|
||||
throw new Error(`File does not exist: ${path}`);
|
||||
}
|
||||
return JSON.parse(readFileSync(path, "utf8"));
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
function asTrimmedString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function decodeBase64Url(value: string | undefined): string | undefined {
|
||||
if (!value) return undefined;
|
||||
return Buffer.from(
|
||||
value.replace(/-/g, "+").replace(/_/g, "/"),
|
||||
"base64",
|
||||
).toString("utf8");
|
||||
}
|
||||
|
||||
function headerValue(message: GmailMessage, name: string): string | undefined {
|
||||
const header = message.payload?.headers?.find(
|
||||
(entry) => entry.name?.toLowerCase() === name.toLowerCase(),
|
||||
);
|
||||
return header?.value ?? undefined;
|
||||
}
|
||||
|
||||
function collectBodyParts(
|
||||
part: GmailMessagePart | null | undefined,
|
||||
output: { text?: string; html?: string } = {},
|
||||
): { text?: string; html?: string } {
|
||||
if (!part) return output;
|
||||
const decoded = decodeBase64Url(part.body?.data ?? undefined);
|
||||
if (decoded) {
|
||||
if (part.mimeType === "text/plain") {
|
||||
output.text = output.text ? `${output.text}\n${decoded}` : decoded;
|
||||
} else if (part.mimeType === "text/html") {
|
||||
output.html = output.html ? `${output.html}\n${decoded}` : decoded;
|
||||
}
|
||||
}
|
||||
for (const child of part.parts ?? []) {
|
||||
collectBodyParts(child, output);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export type GmailAuthCredentials =
|
||||
| {
|
||||
kind: "access-token";
|
||||
accessToken: string;
|
||||
}
|
||||
| {
|
||||
kind: "refresh-token";
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
redirectUri?: string;
|
||||
refreshToken: string;
|
||||
};
|
||||
|
||||
export function resolveGmailAuthCredentials(input?: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
readJsonFile?: (path: string) => unknown;
|
||||
}): GmailAuthCredentials {
|
||||
const sourceEnv = input?.env ?? process.env;
|
||||
const readJson = input?.readJsonFile ?? readJsonFile;
|
||||
const getEnv = (name: string): string | undefined => {
|
||||
const value = sourceEnv[name]?.trim();
|
||||
return value ? value : undefined;
|
||||
};
|
||||
|
||||
const tokenPath = getEnv("GMAIL_TOKEN_PATH");
|
||||
const token = tokenPath ? asRecord(readJson(tokenPath)) : {};
|
||||
const accessToken =
|
||||
getEnv("GMAIL_ACCESS_TOKEN") ?? asTrimmedString(token.access_token);
|
||||
if (accessToken) {
|
||||
return { kind: "access-token", accessToken };
|
||||
}
|
||||
|
||||
let clientId = getEnv("GMAIL_CLIENT_ID");
|
||||
let clientSecret = getEnv("GMAIL_CLIENT_SECRET");
|
||||
let redirectUri = getEnv("GMAIL_REDIRECT_URI");
|
||||
|
||||
const credentialsPath = getEnv("GMAIL_CREDENTIALS_PATH");
|
||||
if ((!clientId || !clientSecret) && credentialsPath) {
|
||||
const credentials = asRecord(readJson(credentialsPath));
|
||||
const installed = asRecord(credentials.installed);
|
||||
const web = asRecord(credentials.web);
|
||||
const source = Object.keys(installed).length > 0 ? installed : web;
|
||||
clientId = clientId ?? asTrimmedString(source.client_id);
|
||||
clientSecret = clientSecret ?? asTrimmedString(source.client_secret);
|
||||
const redirectUris = Array.isArray(source.redirect_uris)
|
||||
? source.redirect_uris
|
||||
: [];
|
||||
redirectUri = redirectUri ?? asTrimmedString(redirectUris[0]);
|
||||
}
|
||||
|
||||
const refreshToken =
|
||||
getEnv("GMAIL_REFRESH_TOKEN") ?? asTrimmedString(token.refresh_token);
|
||||
|
||||
if (!clientId || !clientSecret || !refreshToken) {
|
||||
throw new Error(
|
||||
"Gmail OAuth is not configured. Set GMAIL_ACCESS_TOKEN for short-lived tests, set GMAIL_CLIENT_ID, GMAIL_CLIENT_SECRET, and GMAIL_REFRESH_TOKEN, or provide GMAIL_CREDENTIALS_PATH plus GMAIL_TOKEN_PATH.",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "refresh-token",
|
||||
clientId,
|
||||
clientSecret,
|
||||
redirectUri,
|
||||
refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createGmailClient(): Promise<GmailClient> {
|
||||
// `googleapis` is a package-plugin dependency. Import it lazily so unit tests
|
||||
// for the pure dedupe/gate logic can run before the optional example package
|
||||
// dependency has been installed.
|
||||
const importPackage = new Function(
|
||||
"specifier",
|
||||
"return import(specifier)",
|
||||
) as (specifier: string) => Promise<unknown>;
|
||||
const { google } = (await importPackage("googleapis")) as {
|
||||
google: {
|
||||
auth: {
|
||||
OAuth2: new (
|
||||
...args: unknown[]
|
||||
) => { setCredentials(value: unknown): void };
|
||||
};
|
||||
gmail(input: unknown): GmailClient;
|
||||
};
|
||||
};
|
||||
const credentials = resolveGmailAuthCredentials();
|
||||
const auth =
|
||||
credentials.kind === "refresh-token"
|
||||
? new google.auth.OAuth2(
|
||||
credentials.clientId,
|
||||
credentials.clientSecret,
|
||||
credentials.redirectUri,
|
||||
)
|
||||
: new google.auth.OAuth2();
|
||||
auth.setCredentials(
|
||||
credentials.kind === "refresh-token"
|
||||
? { refresh_token: credentials.refreshToken }
|
||||
: { access_token: credentials.accessToken },
|
||||
);
|
||||
return google.gmail({ version: "v1", auth });
|
||||
}
|
||||
|
||||
export async function searchAndFetchMessages(input: {
|
||||
gmail: GmailClient;
|
||||
query?: string;
|
||||
labelId?: string;
|
||||
maxResults: number;
|
||||
}): Promise<GmailFetchedMessage[]> {
|
||||
const list = await input.gmail.users.messages.list({
|
||||
userId: "me",
|
||||
...(input.query ? { q: input.query } : {}),
|
||||
...(input.labelId ? { labelIds: [input.labelId] } : {}),
|
||||
maxResults: input.maxResults,
|
||||
});
|
||||
const refs = list.data.messages ?? [];
|
||||
const fetched: GmailFetchedMessage[] = [];
|
||||
for (const ref of refs) {
|
||||
if (!ref.id) continue;
|
||||
const response = await input.gmail.users.messages.get({
|
||||
userId: "me",
|
||||
id: ref.id,
|
||||
format: "full",
|
||||
});
|
||||
const message = response.data;
|
||||
if (!message.id || !message.internalDate) continue;
|
||||
const body = collectBodyParts(message.payload);
|
||||
fetched.push({
|
||||
id: message.id,
|
||||
threadId: message.threadId ?? undefined,
|
||||
internalDate: message.internalDate,
|
||||
snippet: message.snippet ?? undefined,
|
||||
subject: headerValue(message, "Subject"),
|
||||
from: headerValue(message, "From"),
|
||||
to: headerValue(message, "To"),
|
||||
date: headerValue(message, "Date"),
|
||||
bodyText: body.text,
|
||||
bodyHtml: body.html,
|
||||
});
|
||||
}
|
||||
return fetched;
|
||||
}
|
||||
|
||||
export async function resolveGmailLabelId(input: {
|
||||
gmail: GmailClient;
|
||||
labelName: string;
|
||||
}): Promise<string> {
|
||||
const normalizedWanted = input.labelName.trim().toLowerCase();
|
||||
if (!normalizedWanted) {
|
||||
throw new Error("Gmail label name cannot be empty");
|
||||
}
|
||||
const response = await input.gmail.users.labels.list({ userId: "me" });
|
||||
const labels = response.data.labels ?? [];
|
||||
const match = labels.find((label) => {
|
||||
const name = label.name?.trim().toLowerCase();
|
||||
const id = label.id?.trim().toLowerCase();
|
||||
return name === normalizedWanted || id === normalizedWanted;
|
||||
});
|
||||
if (!match?.id) {
|
||||
throw new Error(`Gmail label not found: ${input.labelName}`);
|
||||
}
|
||||
return match.id;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { AgentPlugin, BasicLogger } from "@cline/core";
|
||||
import { runGmailWorkGate } from "./gate";
|
||||
|
||||
let setupLogger: BasicLogger | undefined;
|
||||
|
||||
const plugin: AgentPlugin = {
|
||||
name: "gmail-work-to-do-gate",
|
||||
manifest: {
|
||||
capabilities: ["hooks"],
|
||||
},
|
||||
|
||||
setup(_api, ctx) {
|
||||
setupLogger = ctx.logger;
|
||||
},
|
||||
|
||||
hooks: {
|
||||
beforeRun() {
|
||||
return runGmailWorkGate({ logger: setupLogger });
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export { plugin };
|
||||
export default plugin;
|
||||
export {
|
||||
advanceStateForProcessedMessages,
|
||||
selectNewMessages,
|
||||
} from "./dedupe";
|
||||
export { runGmailWorkGate } from "./gate";
|
||||
export type { GmailFetchedMessage } from "./gmail";
|
||||
export type { GmailWorkState } from "./state";
|
||||
@@ -0,0 +1,70 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { resolveClineDataDir } from "@cline/shared/storage";
|
||||
|
||||
export interface GmailWorkState {
|
||||
/**
|
||||
* Largest Gmail `internalDate` processed so far, encoded as a decimal
|
||||
* millisecond timestamp string. Gmail documents `id` and `internalDate` as
|
||||
* stable for a message; using `internalDate` gives a monotonic high-water mark
|
||||
* without relying on mutable labels such as unread/read.
|
||||
*/
|
||||
maxInternalDate?: string;
|
||||
/** Message ids already processed at `maxInternalDate`. */
|
||||
seenIdsAtMaxInternalDate: string[];
|
||||
}
|
||||
|
||||
export const EMPTY_GMAIL_WORK_STATE: GmailWorkState = {
|
||||
seenIdsAtMaxInternalDate: [],
|
||||
};
|
||||
|
||||
export function resolveStatePath(): string {
|
||||
const explicitPath = process.env.GMAIL_WORK_STATE_PATH?.trim();
|
||||
if (explicitPath) {
|
||||
return explicitPath;
|
||||
}
|
||||
return join(
|
||||
resolveClineDataDir(),
|
||||
"plugins",
|
||||
"gmail-work-to-do",
|
||||
"state.json",
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeState(value: unknown): GmailWorkState {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return { ...EMPTY_GMAIL_WORK_STATE };
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
return {
|
||||
maxInternalDate:
|
||||
typeof record.maxInternalDate === "string" &&
|
||||
record.maxInternalDate.trim()
|
||||
? record.maxInternalDate.trim()
|
||||
: undefined,
|
||||
seenIdsAtMaxInternalDate: Array.isArray(record.seenIdsAtMaxInternalDate)
|
||||
? record.seenIdsAtMaxInternalDate.filter(
|
||||
(id): id is string => typeof id === "string" && id.trim().length > 0,
|
||||
)
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function readState(statePath = resolveStatePath()): GmailWorkState {
|
||||
if (!existsSync(statePath)) {
|
||||
return { ...EMPTY_GMAIL_WORK_STATE };
|
||||
}
|
||||
return normalizeState(JSON.parse(readFileSync(statePath, "utf8")));
|
||||
}
|
||||
|
||||
export function writeState(
|
||||
state: GmailWorkState,
|
||||
statePath = resolveStatePath(),
|
||||
): void {
|
||||
mkdirSync(dirname(statePath), { recursive: true });
|
||||
writeFileSync(
|
||||
statePath,
|
||||
`${JSON.stringify(normalizeState(state), null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"types": ["node", "vitest/globals"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
@@ -748,6 +748,105 @@ describe("AgentRuntime", () => {
|
||||
expect(result.outputText).toBe("done");
|
||||
});
|
||||
|
||||
it("runs plugin beforeRun before any model request", async () => {
|
||||
const model = new ScriptedModel([
|
||||
() => [
|
||||
{ type: "text-delta", text: "started" },
|
||||
{ type: "finish", reason: "stop" },
|
||||
],
|
||||
]);
|
||||
const beforeRun = vi.fn(() => {
|
||||
expect(model.requests).toHaveLength(0);
|
||||
return undefined;
|
||||
});
|
||||
const plugin: AgentRuntimePlugin = {
|
||||
name: "prelaunch-observer",
|
||||
setup: () => ({ hooks: { beforeRun } }),
|
||||
};
|
||||
|
||||
const runtime = new AgentRuntime({ model, plugins: [plugin] });
|
||||
const result = await runtime.run("Start");
|
||||
|
||||
expect(beforeRun).toHaveBeenCalledOnce();
|
||||
expect(model.requests).toHaveLength(1);
|
||||
expect(result.status).toBe("completed");
|
||||
});
|
||||
|
||||
it("lets plugin beforeRun abort before any model request", async () => {
|
||||
const model = new ScriptedModel([
|
||||
() => [
|
||||
{ type: "text-delta", text: "should not happen" },
|
||||
{ type: "finish", reason: "stop" },
|
||||
],
|
||||
]);
|
||||
const beforeRun = vi.fn(() => ({
|
||||
stop: true,
|
||||
reason: "no new work, exiting",
|
||||
}));
|
||||
const plugin: AgentRuntimePlugin = {
|
||||
name: "prelaunch-gate",
|
||||
setup: () => ({ hooks: { beforeRun } }),
|
||||
};
|
||||
const events: string[] = [];
|
||||
const runtime = new AgentRuntime({ model, plugins: [plugin] });
|
||||
runtime.subscribe((event) => events.push(event.type));
|
||||
|
||||
const result = await runtime.run("Start");
|
||||
|
||||
expect(beforeRun).toHaveBeenCalledOnce();
|
||||
expect(model.requests).toHaveLength(0);
|
||||
expect(result.status).toBe("aborted");
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.reason).toBe("no new work, exiting");
|
||||
expect(events).toEqual(["run-finished"]);
|
||||
});
|
||||
|
||||
it("lets plugin beforeRun hand off gathered messages before first model request", async () => {
|
||||
const handoffMessage: AgentMessage = {
|
||||
id: "msg_handoff",
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Pre-launch work: process Gmail message msg_1",
|
||||
},
|
||||
],
|
||||
createdAt: 1,
|
||||
};
|
||||
const model = new ScriptedModel([
|
||||
(request) => {
|
||||
expect(request.systemPrompt).toBe("handoff system");
|
||||
expect(request.messages.at(-1)).toEqual(handoffMessage);
|
||||
return [
|
||||
{ type: "text-delta", text: "processed handoff" },
|
||||
{ type: "finish", reason: "stop" },
|
||||
];
|
||||
},
|
||||
]);
|
||||
const plugin: AgentRuntimePlugin = {
|
||||
name: "prelaunch-handoff",
|
||||
setup: () => ({
|
||||
hooks: {
|
||||
beforeRun: () => ({
|
||||
appendMessages: [handoffMessage],
|
||||
systemPrompt: "handoff system",
|
||||
}),
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const runtime = new AgentRuntime({
|
||||
model,
|
||||
systemPrompt: "original system",
|
||||
plugins: [plugin],
|
||||
});
|
||||
const result = await runtime.run("Start");
|
||||
|
||||
expect(model.requests).toHaveLength(1);
|
||||
expect(result.status).toBe("completed");
|
||||
expect(result.messages).toContainEqual(handoffMessage);
|
||||
});
|
||||
|
||||
it("supports plugin-contributed tools and hooks", async () => {
|
||||
const beforeRun = vi.fn();
|
||||
const plugin: AgentRuntimePlugin = {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createGateway, type GatewayProviderSettings } from "@cline/llms";
|
||||
import type {
|
||||
AgentAfterToolResult,
|
||||
AgentBeforeModelResult,
|
||||
AgentBeforeRunResult,
|
||||
AgentBeforeToolResult,
|
||||
AgentMessage,
|
||||
AgentMessagePart,
|
||||
@@ -220,6 +221,11 @@ class ControlledStopError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
interface BeforeRunControl extends AgentBeforeRunResult {
|
||||
appendMessages?: AgentMessage[];
|
||||
replaceMessages?: AgentMessage[];
|
||||
}
|
||||
|
||||
export class AgentRuntimeAbortError extends Error {
|
||||
readonly reason?: unknown;
|
||||
|
||||
@@ -273,6 +279,30 @@ function cloneMessages(messages: readonly AgentMessage[]): AgentMessage[] {
|
||||
}));
|
||||
}
|
||||
|
||||
function mergeBeforeRunControl(
|
||||
current: BeforeRunControl | undefined,
|
||||
next: AgentBeforeRunResult | undefined,
|
||||
): BeforeRunControl | undefined {
|
||||
if (!next) {
|
||||
return current;
|
||||
}
|
||||
const appendMessages = [
|
||||
...(current?.appendMessages ?? []),
|
||||
...(next.appendMessages ? cloneMessages(next.appendMessages) : []),
|
||||
];
|
||||
return {
|
||||
...current,
|
||||
...next,
|
||||
stop: current?.stop === true || next.stop === true ? true : undefined,
|
||||
reason: next.reason ?? current?.reason,
|
||||
appendMessages: appendMessages.length > 0 ? appendMessages : undefined,
|
||||
replaceMessages: next.replaceMessages
|
||||
? cloneMessages(next.replaceMessages)
|
||||
: current?.replaceMessages,
|
||||
systemPrompt: next.systemPrompt ?? current?.systemPrompt,
|
||||
};
|
||||
}
|
||||
|
||||
function usageDelta(
|
||||
start: AgentUsage,
|
||||
end: AgentUsage,
|
||||
@@ -386,6 +416,7 @@ export class AgentRuntime {
|
||||
};
|
||||
private initialization?: Promise<void>;
|
||||
private abortController?: AbortController;
|
||||
private effectiveSystemPrompt?: string;
|
||||
|
||||
constructor(config: AgentRuntimeConfig) {
|
||||
const resolved = resolveRuntimeConfig(config);
|
||||
@@ -555,9 +586,11 @@ export class AgentRuntime {
|
||||
this.state.pendingToolCalls = [];
|
||||
this.state.lastError = undefined;
|
||||
this.state.usage = cloneUsage(DEFAULT_USAGE);
|
||||
this.effectiveSystemPrompt = this.config.systemPrompt;
|
||||
|
||||
try {
|
||||
await this.callBeforeRunHooks();
|
||||
const beforeRunControl = await this.callBeforeRunHooks();
|
||||
this.applyBeforeRunControl(beforeRunControl);
|
||||
await this.emit({ type: "run-started", snapshot: this.snapshot() });
|
||||
|
||||
for (const message of input ? normalizeInput(input) : []) {
|
||||
@@ -569,6 +602,8 @@ export class AgentRuntime {
|
||||
});
|
||||
}
|
||||
|
||||
this.applyBeforeRunHandoff(beforeRunControl);
|
||||
|
||||
const completionToolReminder = this.getCompletionToolReminderMessage();
|
||||
if (completionToolReminder) {
|
||||
await this.addUserReminderMessage(completionToolReminder);
|
||||
@@ -701,8 +736,16 @@ export class AgentRuntime {
|
||||
outputText: textFromMessage(this.findLastAssistantMessage()),
|
||||
messages: cloneMessages(this.state.messages),
|
||||
usage: cloneUsage(this.state.usage),
|
||||
reason: isControlledStop ? normalized.message : undefined,
|
||||
error: status === "failed" ? normalized : undefined,
|
||||
};
|
||||
if (isControlledStop) {
|
||||
this.config.logger?.log?.("Agent run stopped before completion", {
|
||||
severity: "info",
|
||||
reason: normalized.message,
|
||||
runId: result.runId,
|
||||
});
|
||||
}
|
||||
await this.callAfterRunHooks(result);
|
||||
if (status === "failed") {
|
||||
await this.emit({
|
||||
@@ -723,12 +766,54 @@ export class AgentRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
private async callBeforeRunHooks(): Promise<void> {
|
||||
private async callBeforeRunHooks(): Promise<BeforeRunControl | undefined> {
|
||||
let aggregate: BeforeRunControl | undefined;
|
||||
for (const hook of this.hooks.beforeRun) {
|
||||
const control = (await hook({
|
||||
snapshot: this.snapshot(),
|
||||
})) as AgentStopControl | undefined;
|
||||
this.applyStopControl(control);
|
||||
})) as AgentBeforeRunResult | undefined;
|
||||
aggregate = mergeBeforeRunControl(aggregate, control);
|
||||
if (control?.stop) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return aggregate;
|
||||
}
|
||||
|
||||
private applyBeforeRunControl(control: BeforeRunControl | undefined): void {
|
||||
if (!control) {
|
||||
return;
|
||||
}
|
||||
if (control?.systemPrompt !== undefined) {
|
||||
this.effectiveSystemPrompt = control.systemPrompt;
|
||||
}
|
||||
if (!control.stop) {
|
||||
const handoffCount =
|
||||
(control.appendMessages?.length ?? 0) +
|
||||
(control.replaceMessages?.length ?? 0);
|
||||
if (handoffCount > 0 || control?.systemPrompt !== undefined) {
|
||||
this.config.logger?.log?.("Agent beforeRun handoff accepted", {
|
||||
severity: "info",
|
||||
appendMessageCount: control.appendMessages?.length ?? 0,
|
||||
replaceMessageCount: control.replaceMessages?.length ?? 0,
|
||||
systemPromptOverridden: control.systemPrompt !== undefined,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.config.logger?.log?.("Agent beforeRun stopped run", {
|
||||
severity: "info",
|
||||
reason: control.reason,
|
||||
});
|
||||
this.applyStopControl(control);
|
||||
}
|
||||
|
||||
private applyBeforeRunHandoff(control: BeforeRunControl | undefined): void {
|
||||
if (control?.replaceMessages) {
|
||||
this.state.messages = cloneMessages(control.replaceMessages);
|
||||
}
|
||||
if (control?.appendMessages) {
|
||||
this.state.messages.push(...cloneMessages(control.appendMessages));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -744,7 +829,7 @@ export class AgentRuntime {
|
||||
}> {
|
||||
const usageBeforeModel = cloneUsage(this.state.usage);
|
||||
let request: AgentModelRequest = {
|
||||
systemPrompt: this.config.systemPrompt,
|
||||
systemPrompt: this.effectiveSystemPrompt ?? this.config.systemPrompt,
|
||||
messages: cloneMessages(this.state.messages),
|
||||
tools: [...this.tools.values()].map<AgentToolDefinition>((tool) => ({
|
||||
name: tool.name,
|
||||
@@ -1332,6 +1417,7 @@ export class AgentRuntime {
|
||||
textFromMessage(assistantMessage ?? this.findLastAssistantMessage()),
|
||||
messages: cloneMessages(this.state.messages),
|
||||
usage: cloneUsage(this.state.usage),
|
||||
reason: status === "aborted" ? this.state.lastError : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
export type {
|
||||
AgentAfterToolResult,
|
||||
AgentBeforeModelResult,
|
||||
AgentBeforeRunResult,
|
||||
AgentBeforeToolResult,
|
||||
AgentMessage,
|
||||
AgentMessagePart,
|
||||
|
||||
@@ -8,6 +8,7 @@ export * as Llms from "@cline/llms";
|
||||
// Shared contracts and path helpers re-exported for app consumers.
|
||||
export type {
|
||||
AddProviderActionRequest,
|
||||
AgentBeforeRunResult,
|
||||
AgentConfig,
|
||||
AgentEvent,
|
||||
AgentExtension as AgentPlugin, // Public-facing alias for extensions
|
||||
@@ -15,6 +16,7 @@ export type {
|
||||
AgentExtensionCommand as AgentPluginCommand,
|
||||
AgentExtensionCommandResult,
|
||||
AgentHooks,
|
||||
AgentMessage,
|
||||
AgentMode,
|
||||
AgentResult,
|
||||
AgentRunResult,
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
import type { AgentRuntime } from "@cline/agents";
|
||||
import { createAgentRuntime } from "@cline/agents";
|
||||
import {
|
||||
type AgentBeforeRunResult,
|
||||
type AgentConfig,
|
||||
type AgentEvent,
|
||||
type AgentExtension,
|
||||
@@ -101,6 +102,25 @@ function mergeSystemPromptRules(
|
||||
return base || additional;
|
||||
}
|
||||
|
||||
function mergeBeforeRunResults(
|
||||
current: AgentBeforeRunResult | undefined,
|
||||
next: AgentBeforeRunResult,
|
||||
): AgentBeforeRunResult {
|
||||
const appendMessages = [
|
||||
...(current?.appendMessages ?? []),
|
||||
...(next.appendMessages ?? []),
|
||||
];
|
||||
return {
|
||||
...current,
|
||||
...next,
|
||||
stop: current?.stop === true || next.stop === true ? true : undefined,
|
||||
reason: next.reason ?? current?.reason,
|
||||
appendMessages: appendMessages.length > 0 ? appendMessages : undefined,
|
||||
replaceMessages: next.replaceMessages ?? current?.replaceMessages,
|
||||
systemPrompt: next.systemPrompt ?? current?.systemPrompt,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeRuntimeHooks(
|
||||
layers: Array<Partial<AgentRuntimeHooks> | undefined>,
|
||||
): Partial<AgentRuntimeHooks> {
|
||||
@@ -113,11 +133,14 @@ function mergeRuntimeHooks(
|
||||
|
||||
return {
|
||||
beforeRun: async (ctx) => {
|
||||
let aggregate: AgentBeforeRunResult | undefined;
|
||||
for (const hook of hooks) {
|
||||
const result = await hook.beforeRun?.(ctx);
|
||||
if (result?.stop) return result;
|
||||
if (!result) continue;
|
||||
aggregate = mergeBeforeRunResults(aggregate, result);
|
||||
if (result.stop) return aggregate;
|
||||
}
|
||||
return undefined;
|
||||
return aggregate;
|
||||
},
|
||||
afterRun: async (ctx) => {
|
||||
for (const hook of hooks) {
|
||||
|
||||
@@ -274,6 +274,24 @@ export interface AgentStopControl {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface AgentBeforeRunResult extends AgentStopControl {
|
||||
/**
|
||||
* Messages to append to the run transcript before the first model request.
|
||||
*
|
||||
* This is the pre-inference handoff channel for plugins that fetch work in
|
||||
* `beforeRun` (for example cron-gated inbox checks) and want the model to see
|
||||
* that gathered context without a separate tool call.
|
||||
*/
|
||||
appendMessages?: readonly AgentMessage[];
|
||||
/**
|
||||
* Replace the run transcript before the first model request. Use sparingly;
|
||||
* `appendMessages` is preferred for normal handoff data.
|
||||
*/
|
||||
replaceMessages?: readonly AgentMessage[];
|
||||
/** Override the effective system prompt for this run only. */
|
||||
systemPrompt?: string;
|
||||
}
|
||||
|
||||
export interface AgentBeforeModelResult {
|
||||
stop?: boolean;
|
||||
reason?: string;
|
||||
@@ -333,7 +351,10 @@ export interface AgentRunLifecycleContext {
|
||||
export interface AgentRuntimeHooks {
|
||||
beforeRun?: (
|
||||
context: AgentRunLifecycleContext,
|
||||
) => AgentStopControl | undefined | Promise<AgentStopControl | undefined>;
|
||||
) =>
|
||||
| AgentBeforeRunResult
|
||||
| undefined
|
||||
| Promise<AgentBeforeRunResult | undefined>;
|
||||
afterRun?: (
|
||||
context: AgentRunLifecycleContext & { result: AgentRunResult },
|
||||
) => void | Promise<void>;
|
||||
@@ -557,5 +578,7 @@ export interface AgentRunResult {
|
||||
outputText: string;
|
||||
messages: readonly AgentMessage[];
|
||||
usage: AgentUsage;
|
||||
/** Human-readable control-flow reason, most often for routine aborts. */
|
||||
reason?: string;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user