diff --git a/.env.example b/.env.example index d5f8163671..dd08cd0689 100644 --- a/.env.example +++ b/.env.example @@ -46,9 +46,6 @@ PRINCIPAL_KEY_ENCRYPTION_KEY=111111111111111111111111111111111111111111111111111 # RERANK_MAX_DOC_CHARS= # RERANK_TIMEOUT_MS= -# Markdown-to-PDF rendering through a Gotenberg server. -# GOTENBERG_URL=http://localhost:3000 - # Sidecars run as child processes of the hub. Both optional. # PROCESS_PROVISIONER_SIDECAR_ENTRY= # PROCESS_PROVISIONER_RUNTIME= diff --git a/agents/myra/test/definition.test.ts b/agents/myra/test/definition.test.ts index 7e2cc0bebb..ffb44ec86d 100644 --- a/agents/myra/test/definition.test.ts +++ b/agents/myra/test/definition.test.ts @@ -9,7 +9,6 @@ import type { StepPrimitive, WorkflowDefinition } from "@intx/workflow"; import { ASSISTANT_STEP_ID, ASSISTANT_SYSTEM_PROMPT, - ASSISTANT_TOOL_PACKAGE_PINS, ASSISTANT_WORKFLOW_ID, buildAssistantWorkflow, serializeAssistantWorkflow, diff --git a/apps/web/src/chat/styles.css b/apps/web/src/chat/styles.css index 58fc19d7ad..2a92840fc8 100644 --- a/apps/web/src/chat/styles.css +++ b/apps/web/src/chat/styles.css @@ -467,7 +467,7 @@ padding: 0; } -/* A static fact ("this agent has @corbits/github-tools") is not a button +/* A static fact ("this agent has @corbits/example-tools") is not a button and should not wear button chrome — no border, a muted tint fill only, never the bordered-and-transparent look `Button variant="outline"` and `Badge tone="neutral"` share. */ diff --git a/apps/web/src/chat/workbench-settings/agents-section.test.ts b/apps/web/src/chat/workbench-settings/agents-section.test.ts index b312c05c1f..f144fb2833 100644 --- a/apps/web/src/chat/workbench-settings/agents-section.test.ts +++ b/apps/web/src/chat/workbench-settings/agents-section.test.ts @@ -29,8 +29,8 @@ describe("summarizeHistoryMessage", () => { }); test("a capability addition names what was added", () => { - expect(summarizeHistoryMessage("Add @corbits/github-tools to Myra")).toBe( - "Added tool: @corbits/github-tools", + expect(summarizeHistoryMessage("Add @corbits/example-tools to Myra")).toBe( + "Added tool: @corbits/example-tools", ); expect(summarizeHistoryMessage("Add research skill to Myra")).toBe("Added skill: research"); expect(summarizeHistoryMessage("Set Myra's model to anthropic/claude-sonnet")).toBe( diff --git a/apps/web/test/chat/agents-section.test.tsx b/apps/web/test/chat/agents-section.test.tsx index 7cf70a9555..e234bd64bc 100644 --- a/apps/web/test/chat/agents-section.test.tsx +++ b/apps/web/test/chat/agents-section.test.tsx @@ -582,13 +582,16 @@ describe("Agents section — Capabilities", () => { test("lists current tools/skills/model and offers only what's not already attached", async () => { const withCapabilities: AgentFixture = { ...MYRA, - toolPackagePins: [{ name: "@corbits/github-tools", version: "*" }], + toolPackagePins: [{ name: "@corbits/example-tools", version: "*" }], skills: ["research"], }; stubFetch({ agents: [withCapabilities], capabilityInventory: { - toolPackages: [{ name: "@corbits/github-tools" }, { name: "@corbits/granola-tools" }], + toolPackages: [ + { name: "@corbits/example-tools" }, + { name: "@corbits/other-example-tools" }, + ], skills: [{ name: "research" }, { name: "writing" }], models: [{ canonicalName: "anthropic/claude-sonnet" }], }, @@ -599,7 +602,7 @@ describe("Agents section — Capabilities", () => { await settle(); const listText = el.querySelector(".chat-settings-capability-list")?.textContent; - expect(listText).toContain("@corbits/github-tools"); + expect(listText).toContain("@corbits/example-tools"); expect(listText).toContain("research"); const choiceSelect = el.querySelectorAll( @@ -608,14 +611,14 @@ describe("Agents section — Capabilities", () => { const toolOptions = Array.from(choiceSelect?.options ?? []).map((option) => option.value); // The already-pinned tool package is not offered again; the // not-yet-pinned one is. - expect(toolOptions).not.toContain("@corbits/github-tools"); - expect(toolOptions).toContain("@corbits/granola-tools"); + expect(toolOptions).not.toContain("@corbits/example-tools"); + expect(toolOptions).toContain("@corbits/other-example-tools"); }); test("a static capability chip is a plain caption tint, not button chrome", async () => { const withCapabilities: AgentFixture = { ...MYRA, - toolPackagePins: [{ name: "@corbits/github-tools", version: "*" }], + toolPackagePins: [{ name: "@corbits/example-tools", version: "*" }], skills: [], }; stubFetch({ agents: [withCapabilities] }); @@ -673,7 +676,7 @@ describe("Agents section — Capabilities", () => { let addedBody: unknown; const { refreshCalls } = stubFetch({ capabilityInventory: { - toolPackages: [{ name: "@corbits/github-tools" }], + toolPackages: [{ name: "@corbits/example-tools" }], skills: [], models: [], }, @@ -696,7 +699,7 @@ describe("Agents section — Capabilities", () => { )[1] as HTMLSelectElement | null; act(() => { if (choiceSelect !== null) { - choiceSelect.value = "@corbits/github-tools"; + choiceSelect.value = "@corbits/example-tools"; choiceSelect.dispatchEvent(new Event("change", { bubbles: true })); } }); @@ -711,19 +714,19 @@ describe("Agents section — Capabilities", () => { expect(addedBody).toEqual({ kind: "toolPackage", - name: "@corbits/github-tools", + name: "@corbits/example-tools", }); expect(refreshCalls).toEqual(["myra@acme.example"]); const badges = Array.from(el.querySelectorAll(".chat-settings-capability-list"))[0] ?.textContent; - expect(badges).toContain("@corbits/github-tools"); + expect(badges).toContain("@corbits/example-tools"); }); test("a rejected capability add shows an inline error and never claims success", async () => { stubFetch({ addCapabilityFails: true, capabilityInventory: { - toolPackages: [{ name: "@corbits/github-tools" }], + toolPackages: [{ name: "@corbits/example-tools" }], skills: [], models: [], }, @@ -738,7 +741,7 @@ describe("Agents section — Capabilities", () => { )[1] as HTMLSelectElement | null; act(() => { if (choiceSelect !== null) { - choiceSelect.value = "@corbits/github-tools"; + choiceSelect.value = "@corbits/example-tools"; choiceSelect.dispatchEvent(new Event("change", { bubbles: true })); } }); diff --git a/bun.lock b/bun.lock index 74c0f3fde6..bb8d149729 100644 --- a/bun.lock +++ b/bun.lock @@ -233,21 +233,6 @@ "typescript": "catalog:", }, }, - "packages/code-review": { - "name": "@corbits/code-review", - "version": "0.0.2", - "dependencies": { - "arktype": "catalog:", - }, - "devDependencies": { - "@corbits/agent-directory": "workspace:*", - "@types/bun": "catalog:", - "typescript": "catalog:", - }, - "peerDependencies": { - "@corbits/github-tools": "0.0.11", - }, - }, "packages/collections": { "name": "@corbits/collections", "version": "0.0.1", @@ -437,53 +422,6 @@ "@intx/types": "0.3.0", }, }, - "tools/github": { - "name": "@corbits/github-tools", - "version": "0.0.11", - "dependencies": { - "arktype": "catalog:", - }, - "devDependencies": { - "@types/bun": "catalog:", - "typescript": "catalog:", - }, - "peerDependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", - }, - }, - "tools/gotenberg": { - "name": "@corbits/gotenberg", - "version": "0.0.1", - "devDependencies": { - "@types/bun": "catalog:", - "arktype": "catalog:", - "typescript": "catalog:", - }, - "peerDependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", - }, - }, - "tools/granola": { - "name": "@corbits/granola-tools", - "version": "0.0.5", - "dependencies": { - "arktype": "catalog:", - }, - "devDependencies": { - "@intx/authz": "0.3.0", - "@intx/crypto": "0.3.0", - "@intx/db": "workspace:*", - "@intx/harness": "workspace:*", - "@types/bun": "catalog:", - "typescript": "catalog:", - }, - "peerDependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", - }, - }, "tools/interaction": { "name": "@corbits/interaction-tools", "version": "0.0.11", @@ -500,40 +438,6 @@ "@intx/types": "0.3.0", }, }, - "tools/linear": { - "name": "@corbits/linear-tools", - "version": "0.0.5", - "dependencies": { - "arktype": "catalog:", - }, - "devDependencies": { - "@intx/authz": "0.3.0", - "@intx/crypto": "0.3.0", - "@intx/db": "workspace:*", - "@intx/harness": "workspace:*", - "@types/bun": "catalog:", - "typescript": "catalog:", - }, - "peerDependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", - }, - }, - "tools/reddit": { - "name": "@corbits/reddit-tools", - "version": "0.0.2", - "dependencies": { - "arktype": "catalog:", - }, - "devDependencies": { - "@types/bun": "catalog:", - "typescript": "catalog:", - }, - "peerDependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", - }, - }, "tools/skills": { "name": "@corbits/skills-tools", "version": "0.0.9", @@ -549,21 +453,6 @@ "@intx/types": "0.3.0", }, }, - "tools/web-search": { - "name": "@corbits/web-search-tools", - "version": "0.0.4", - "dependencies": { - "arktype": "catalog:", - }, - "devDependencies": { - "@types/bun": "catalog:", - "typescript": "catalog:", - }, - "peerDependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0", - }, - }, "tools/workflow-authoring": { "name": "@corbits/workflow-authoring-tools", "version": "0.0.8", @@ -971,8 +860,6 @@ "@corbits/catalog-tools": ["@corbits/catalog-tools@workspace:tools/catalog"], - "@corbits/code-review": ["@corbits/code-review@workspace:packages/code-review"], - "@corbits/codex-provider": ["@corbits/codex-provider@github:corbitsdev/corbits-codex-provider#a1f475f", { "dependencies": { "@corbits/oauth-core": "github:corbitsdev/corbits-oauth-core", "@corbits/openai-responses": "github:corbitsdev/corbits-openai-responses", "arktype": "2.2.3" }, "peerDependencies": { "@intx/inference": ">=0.3.0", "@intx/types": ">=0.3.0" } }, "corbitsdev-corbits-codex-provider-a1f475f", "sha512-D6d0lblWx/4KudfLwQoQGrLB8GKRLP/HVp1cx08L2/hOGZAgH6txLPpeEtBqjl+R520qXSSPfM0kDC+3XNaAfw=="], "@corbits/collections": ["@corbits/collections@workspace:packages/collections"], @@ -985,18 +872,10 @@ "@corbits/error-sink": ["@corbits/error-sink@workspace:packages/error-sink"], - "@corbits/github-tools": ["@corbits/github-tools@workspace:tools/github"], - - "@corbits/gotenberg": ["@corbits/gotenberg@workspace:tools/gotenberg"], - - "@corbits/granola-tools": ["@corbits/granola-tools@workspace:tools/granola"], - "@corbits/inference-catalog": ["@corbits/inference-catalog@workspace:packages/inference-catalog"], "@corbits/interaction-tools": ["@corbits/interaction-tools@workspace:tools/interaction"], - "@corbits/linear-tools": ["@corbits/linear-tools@workspace:tools/linear"], - "@corbits/mailbox": ["@corbits/mailbox@github:corbitsdev/corbits-mailbox#e85b81e", { "dependencies": { "@hono/standard-validator": "0.2.3", "@intx/crypto": "0.3.0", "@standard-community/standard-json": "0.3.5", "@standard-community/standard-openapi": "0.2.9", "arktype": "2.1.29", "hono-openapi": "1.3.1" }, "peerDependencies": { "@intx/log": "^0.2.2", "drizzle-orm": "^0.45.2", "hono": "^4.12.0", "postgres": "^3.4.0" } }, "corbitsdev-corbits-mailbox-e85b81e", "sha512-ATPP/pll1sYG1DLKV1HvtboK8q1tFkqayQqRXdUmRUIsFJx5wqzd8YA1XELjXzLu1P2f2uCMe2CjsJHGv6E90Q=="], "@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], @@ -1011,16 +890,12 @@ "@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], - "@corbits/reddit-tools": ["@corbits/reddit-tools@workspace:tools/reddit"], - "@corbits/skills-tools": ["@corbits/skills-tools@workspace:tools/skills"], "@corbits/tool-registry-publish": ["@corbits/tool-registry-publish@workspace:packages/tool-registry-publish"], "@corbits/url-path": ["@corbits/url-path@workspace:packages/url-path"], - "@corbits/web-search-tools": ["@corbits/web-search-tools@workspace:tools/web-search"], - "@corbits/webhook-triggers": ["@corbits/webhook-triggers@workspace:packages/webhook-triggers"], "@corbits/workflow-authoring-tools": ["@corbits/workflow-authoring-tools@workspace:tools/workflow-authoring"], @@ -2187,8 +2062,6 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@corbits/inference-catalog/@corbits/ollama-adapter": ["@corbits/ollama-adapter@github:corbitsdev/corbits-ollama-adapter#0387eb1", { "dependencies": { "arktype": "2.2.3" }, "peerDependencies": { "@intx/inference": ">=0.3.0", "@intx/types": ">=0.3.0" } }, "corbitsdev-corbits-ollama-adapter-0387eb1", "sha512-O1dl5lSwWzNGJibQLlpYDFKJZWpstgHX519bl8oe1ALL/KHszT/tBDlpsD9jN3hmQD3jV2L1jNwvOwLOBLtpyw=="], - "@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], @@ -2207,16 +2080,8 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "@workbench/hub/@corbits/artifacts": ["@corbits/artifacts@github:corbitsdev/corbits-artifacts#5309a15", { "dependencies": { "@hono/standard-validator": "^0.2.3" }, "peerDependencies": { "@intx/hub-api": "^0.3.0", "@intx/types": "^0.3.0", "arktype": "^2.1.29", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", "hono-openapi": "^1.2.0", "postgres": "^3.4.9" } }, "corbitsdev-corbits-artifacts-5309a15", "sha512-zGHkq++juclUrOd0O8/Z5ziTldmJZ6gqyH/c9cs5S10UPU4gqn8CHwq174SiJXY0ujJ3cNpJXnrr+0fcqlfQ1w=="], - - "@workbench/hub/@corbits/codex-provider": ["@corbits/codex-provider@github:corbitsdev/corbits-codex-provider#a1f475f", { "dependencies": { "@corbits/oauth-core": "github:corbitsdev/corbits-oauth-core", "@corbits/openai-responses": "github:corbitsdev/corbits-openai-responses", "arktype": "2.2.3" }, "peerDependencies": { "@intx/inference": ">=0.3.0", "@intx/types": ">=0.3.0" } }, "corbitsdev-corbits-codex-provider-a1f475f", "sha512-D6d0lblWx/4KudfLwQoQGrLB8GKRLP/HVp1cx08L2/hOGZAgH6txLPpeEtBqjl+R520qXSSPfM0kDC+3XNaAfw=="], - - "@workbench/hub/@corbits/mailbox": ["@corbits/mailbox@github:corbitsdev/corbits-mailbox#e85b81e", { "dependencies": { "@hono/standard-validator": "0.2.3", "@intx/crypto": "0.3.0", "@standard-community/standard-json": "0.3.5", "@standard-community/standard-openapi": "0.2.9", "arktype": "2.1.29", "hono-openapi": "1.3.1" }, "peerDependencies": { "@intx/log": "^0.2.2", "drizzle-orm": "^0.45.2", "hono": "^4.12.0", "postgres": "^3.4.0" } }, "corbitsdev-corbits-mailbox-e85b81e", "sha512-ATPP/pll1sYG1DLKV1HvtboK8q1tFkqayQqRXdUmRUIsFJx5wqzd8YA1XELjXzLu1P2f2uCMe2CjsJHGv6E90Q=="], - "@workbench/hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#e74da20", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-e74da20", "sha512-/uA9xTcF7XwMXgnR8OZamierzkLG4g4mhw3u9Fl3Uj6ait8pH3wcoQ6V32on1nMdaqtPRdYVFnxof5O+4ZacGA=="], - "@workbench/hub/@corbits/xai-provider": ["@corbits/xai-provider@github:corbitsdev/corbits-xai-provider#9f2d4ba", { "dependencies": { "@corbits/oauth-core": "github:corbitsdev/corbits-oauth-core", "@corbits/openai-responses": "github:corbitsdev/corbits-openai-responses", "arktype": "2.2.3" }, "peerDependencies": { "@intx/inference": ">=0.3.0", "@intx/types": ">=0.3.0" } }, "corbitsdev-corbits-xai-provider-9f2d4ba", "sha512-ZjvKxGcp+QzCh8zeXHg7kNbtkpR/itZkmQAcdG1aOAOjHaNwfGVpkPc/c/XtwPNsQyaDjF8W9CHb4rH1xI2uCA=="], - "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="], "body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], @@ -2307,16 +2172,6 @@ "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], - "@workbench/hub/@corbits/codex-provider/@corbits/oauth-core": ["@corbits/oauth-core@github:corbitsdev/corbits-oauth-core#1b7f9fb", { "dependencies": { "arktype": "2.2.3" } }, "corbitsdev-corbits-oauth-core-1b7f9fb", "sha512-PscyiDAl46umTM44NQPN1q5+PsGUV53Rxr4Wz/Ofz+k9LOOz6FBKamRFtWR8YWdKJt9I8sPOLOyEcMNNN2i+YQ=="], - - "@workbench/hub/@corbits/codex-provider/@corbits/openai-responses": ["@corbits/openai-responses@github:corbitsdev/corbits-openai-responses#7efe000", { "dependencies": { "arktype": "2.2.3" }, "peerDependencies": { "@intx/inference": ">=0.3.0", "@intx/types": ">=0.3.0" } }, "corbitsdev-corbits-openai-responses-7efe000", "sha512-pH6C8T1WDJjqM+YbnQpofd7HgP5krEj48JCCQK16KI98a6kaz+gffpgdOdQpU3aNVZnFlyADXod1Z4tqUG/3ww=="], - - "@workbench/hub/@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], - - "@workbench/hub/@corbits/xai-provider/@corbits/oauth-core": ["@corbits/oauth-core@github:corbitsdev/corbits-oauth-core#1b7f9fb", { "dependencies": { "arktype": "2.2.3" } }, "corbitsdev-corbits-oauth-core-1b7f9fb", "sha512-PscyiDAl46umTM44NQPN1q5+PsGUV53Rxr4Wz/Ofz+k9LOOz6FBKamRFtWR8YWdKJt9I8sPOLOyEcMNNN2i+YQ=="], - - "@workbench/hub/@corbits/xai-provider/@corbits/openai-responses": ["@corbits/openai-responses@github:corbitsdev/corbits-openai-responses#7efe000", { "dependencies": { "arktype": "2.2.3" }, "peerDependencies": { "@intx/inference": ">=0.3.0", "@intx/types": ">=0.3.0" } }, "corbitsdev-corbits-openai-responses-7efe000", "sha512-pH6C8T1WDJjqM+YbnQpofd7HgP5krEj48JCCQK16KI98a6kaz+gffpgdOdQpU3aNVZnFlyADXod1Z4tqUG/3ww=="], - "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], diff --git a/compose.yml b/compose.yml deleted file mode 100644 index d93af979b8..0000000000 --- a/compose.yml +++ /dev/null @@ -1,9 +0,0 @@ -# Local services for running a bench outside its test suites. -# -# Usage: -# docker compose up -d -services: - gotenberg: - image: gotenberg/gotenberg:8 - ports: - - "3010:3000" diff --git a/packages/agent-directory/src/agent-definition-drafting.test.ts b/packages/agent-directory/src/agent-definition-drafting.test.ts index f23b82e2a9..0ad64b649e 100644 --- a/packages/agent-directory/src/agent-definition-drafting.test.ts +++ b/packages/agent-directory/src/agent-definition-drafting.test.ts @@ -19,12 +19,12 @@ const INVENTORY_SOURCES: InventorySources = { async listUsableToolPackages() { return [ { - name: "@corbits/granola-tools", - connectorId: "granola", + name: "@corbits/example-tools", + connectorId: "example", credentialBinding: { - package: "@corbits/granola-tools", - handle: "granola", - provider: "granola", + package: "@corbits/example-tools", + handle: "example", + provider: "example", locator: "tenant", }, }, @@ -43,12 +43,12 @@ const INVENTORY: PlannerInventory = { agents: [], toolPackages: [ { - name: "@corbits/granola-tools", - connectorId: "granola", + name: "@corbits/example-tools", + connectorId: "example", credentialBinding: { - package: "@corbits/granola-tools", - handle: "granola", - provider: "granola", + package: "@corbits/example-tools", + handle: "example", + provider: "example", locator: "tenant", }, }, @@ -68,7 +68,7 @@ function buildDeps( systemPrompt: "You review incident reports and summarize them.", description: "Summarizes incident reports", modelPreference: "anthropic/claude-sonnet-5", - toolPackagePins: ["@corbits/granola-tools"], + toolPackagePins: ["@corbits/example-tools"], skills: ["incident-review"], }), runId: "wfr_draft_1", @@ -232,7 +232,7 @@ describe("createMyraAgentDefinitionDrafting", () => { systemPrompt: "You review incident reports and summarize them.", description: "Summarizes incident reports", modelPreference: "anthropic/claude-sonnet-5", - toolPackagePins: ["@corbits/granola-tools"], + toolPackagePins: ["@corbits/example-tools"], skills: ["incident-review"], }); }); diff --git a/packages/agent-directory/src/agent-workflow.ts b/packages/agent-directory/src/agent-workflow.ts index d89e776a93..f8b01bedff 100644 --- a/packages/agent-directory/src/agent-workflow.ts +++ b/packages/agent-directory/src/agent-workflow.ts @@ -338,7 +338,7 @@ export interface AgentDefinitionWorkflowInput { readonly model?: string; /** * Tool packages pinned directly on this definition — connector tool - * bundles (e.g. `@corbits/granola-tools`) a planner-created agent + * bundles (e.g. `@corbits/example-tools`) a planner-created agent * needs beyond what skills reindexing pins. Additive: undeclared or * empty behaves exactly like a definition built before this field * existed. `defineAgent`'s own `DefineAgentConfig` has no field for diff --git a/packages/agent-directory/test/agent-workflow.test.ts b/packages/agent-directory/test/agent-workflow.test.ts index 7d211d58b0..10d54fad1a 100644 --- a/packages/agent-directory/test/agent-workflow.test.ts +++ b/packages/agent-directory/test/agent-workflow.test.ts @@ -68,7 +68,7 @@ test("an omitted toolPackagePins leaves the agent with no pins, exactly like a d }); test("a supplied toolPackagePins lands on the step's agent verbatim", () => { - const pins = [{ name: "@corbits/granola-tools", version: "^1.0.0" }]; + const pins = [{ name: "@corbits/example-tools", version: "^1.0.0" }]; const definition = buildAgentDefinitionWorkflow({ ...INPUT, toolPackagePins: pins, diff --git a/packages/agent-directory/test/routes.test.ts b/packages/agent-directory/test/routes.test.ts index 99ed7f3425..75a4dc7ded 100644 --- a/packages/agent-directory/test/routes.test.ts +++ b/packages/agent-directory/test/routes.test.ts @@ -50,7 +50,7 @@ function readAssetBlobFor(workflowBytes: Uint8Array): AssetService["readAssetBlo const fakeCapabilityInventory: CapabilityInventoryProvider = { resolve: () => Promise.resolve({ - toolPackages: [{ name: "@corbits/github-tools" }], + toolPackages: [{ name: "@corbits/example-tools" }], skills: [{ name: "research" }], models: [{ canonicalName: "anthropic/claude-sonnet" }], }), @@ -162,7 +162,7 @@ function liveDefinitionAsset(initial: Uint8Array): AssetService { // fixed version — enough for `resolvePinnedVersion` to resolve // without each concurrent-write test needing its own tarball list. listAssetBlobs: () => - Promise.resolve(["corbits-github-tools-3.1.0.tgz", "corbits-memory-tools-1.4.0.tgz"]), + Promise.resolve(["corbits-example-tools-3.1.0.tgz", "corbits-memory-tools-1.4.0.tgz"]), }); } @@ -618,7 +618,10 @@ test("a create request with toolPackagePins pins each named package at its highe return Promise.resolve({ commitSha: "deadbeef" }); }, listAssetBlobs: () => - Promise.resolve(["corbits-memory-tools-1.4.0.tgz", "corbits-web-search-tools-2.1.0.tgz"]), + Promise.resolve([ + "corbits-memory-tools-1.4.0.tgz", + "corbits-other-example-tools-2.1.0.tgz", + ]), }), withToolPackageRegistryQueries(fakeCreateDb()), ); @@ -626,13 +629,13 @@ test("a create request with toolPackagePins pins each named package at its highe name: "Scout", handle: "scout", systemPrompt: "You are Scout.", - toolPackagePins: ["@corbits/memory-tools", "@corbits/web-search-tools"], + toolPackagePins: ["@corbits/memory-tools", "@corbits/other-example-tools"], }); expect(response.status).toBe(201); const workflowJson = definitionFrom(writtenFiles); expect(pinsFrom(workflowJson)).toEqual([ { name: "@corbits/memory-tools", version: "1.4.0" }, - { name: "@corbits/web-search-tools", version: "2.1.0" }, + { name: "@corbits/other-example-tools", version: "2.1.0" }, ]); }); @@ -1667,7 +1670,7 @@ test("adding a tool package pin merges it into the definition in one commit, nam writtenMessage = params.tree.message; return Promise.resolve({ commitSha: "deadbeef" }); }, - listAssetBlobs: () => Promise.resolve(["corbits-github-tools-3.1.0.tgz"]), + listAssetBlobs: () => Promise.resolve(["corbits-example-tools-3.1.0.tgz"]), }), withToolPackageRegistryQueries( fakeInstructionsDb({ @@ -1679,18 +1682,18 @@ test("adding a tool package pin merges it into the definition in one commit, nam ); const response = await postTo(app, "/def_1/capabilities", { kind: "toolPackage", - name: "@corbits/github-tools", + name: "@corbits/example-tools", }); expect(response.status).toBe(200); expect(Object.keys(writtenFiles ?? {})).toEqual(SOURCE_TREE_PATHS); expect(pinsFrom(definitionFrom(writtenFiles))).toEqual([ - { name: "@corbits/github-tools", version: "3.1.0" }, + { name: "@corbits/example-tools", version: "3.1.0" }, ]); - expect(writtenMessage).toBe("Add @corbits/github-tools to research-buddy"); + expect(writtenMessage).toBe("Add @corbits/example-tools to research-buddy"); const body = (await response.json()) as { toolPackagePins: { name: string; version: string }[]; }; - expect(body.toolPackagePins).toEqual([{ name: "@corbits/github-tools", version: "3.1.0" }]); + expect(body.toolPackagePins).toEqual([{ name: "@corbits/example-tools", version: "3.1.0" }]); }); test("re-adding an already-pinned tool package keeps its stored version after a newer tarball lands", async () => { @@ -1925,7 +1928,7 @@ test("two concurrent capability-adds on the same definition both land", async () const inventory: CapabilityInventoryProvider = { resolve: () => Promise.resolve({ - toolPackages: [{ name: "@corbits/github-tools" }, { name: "@corbits/memory-tools" }], + toolPackages: [{ name: "@corbits/example-tools" }, { name: "@corbits/memory-tools" }], skills: [{ name: "research" }], models: [{ canonicalName: "anthropic/claude-sonnet" }], }), @@ -1948,7 +1951,7 @@ test("two concurrent capability-adds on the same definition both land", async () const [first, second] = await Promise.all([ postTo(app, "/def_1/capabilities", { kind: "toolPackage", - name: "@corbits/github-tools", + name: "@corbits/example-tools", }), postTo(app, "/def_1/capabilities", { kind: "toolPackage", @@ -1962,7 +1965,7 @@ test("two concurrent capability-adds on the same definition both land", async () const names = pinsFrom(workflowJson) .map((pin) => pin.name) .toSorted(); - expect(names).toEqual(["@corbits/github-tools", "@corbits/memory-tools"]); + expect(names).toEqual(["@corbits/example-tools", "@corbits/memory-tools"]); }); test("concurrent PUT instructions and DELETE model both land", async () => { @@ -2041,7 +2044,7 @@ test("concurrent DELETE model and a capability-add both land", async () => { app.request("/def_1/capabilities/model", { method: "DELETE" }), postTo(app, "/def_1/capabilities", { kind: "toolPackage", - name: "@corbits/github-tools", + name: "@corbits/example-tools", }), ]); expect(delRes.status).toBe(200); @@ -2049,7 +2052,7 @@ test("concurrent DELETE model and a capability-add both land", async () => { const workflowJson = await readAgentDefinitionWorkflowJson(assetService, "ast_1"); expect(modelFrom(workflowJson)).toBeUndefined(); - expect(pinsFrom(workflowJson).map((pin) => pin.name)).toContain("@corbits/github-tools"); + expect(pinsFrom(workflowJson).map((pin) => pin.name)).toContain("@corbits/example-tools"); }); test("concurrent pin_skill and DELETE model both land", async () => { diff --git a/packages/code-review/LICENSE b/packages/code-review/LICENSE deleted file mode 100644 index 173c3a21d0..0000000000 --- a/packages/code-review/LICENSE +++ /dev/null @@ -1,176 +0,0 @@ -GNU LESSER GENERAL PUBLIC LICENSE - -Version 2.1, February 1999 - -Copyright (C) 1991, 1999 Free Software Foundation, Inc. -51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - -Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. - -[This is the first released version of the Lesser GPL. It also counts as the successor of the GNU Library Public License, version 2, hence the version number 2.1.] - -Preamble - -The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public Licenses are intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. - -This license, the Lesser General Public License, applies to some specially designated software packages--typically libraries--of the Free Software Foundation and other authors who decide to use it. You can use it too, but we suggest you first think carefully about whether this license or the ordinary General Public License is the better strategy to use in any particular case, based on the explanations below. - -When we speak of free software, we are referring to freedom of use, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish); that you receive source code or can get it if you want it; that you can change the software and use pieces of it in new free programs; and that you are informed that you can do these things. - -To protect your rights, we need to make restrictions that forbid distributors to deny you these rights or to ask you to surrender these rights. These restrictions translate to certain responsibilities for you if you distribute copies of the library or if you modify it. - -For example, if you distribute copies of the library, whether gratis or for a fee, you must give the recipients all the rights that we gave you. You must make sure that they, too, receive or can get the source code. If you link other code with the library, you must provide complete object files to the recipients, so that they can relink them with the library after making changes to the library and recompiling it. And you must show them these terms so they know their rights. - -We protect your rights with a two-step method: (1) we copyright the library, and (2) we offer you this license, which gives you legal permission to copy, distribute and/or modify the library. - -To protect each distributor, we want to make it very clear that there is no warranty for the free library. Also, if the library is modified by someone else and passed on, the recipients should know that what they have is not the original version, so that the original author's reputation will not be affected by problems that might be introduced by others. - -Finally, software patents pose a constant threat to the existence of any free program. We wish to make sure that a company cannot effectively restrict the users of a free program by obtaining a restrictive license from a patent holder. Therefore, we insist that any patent license obtained for a version of the library must be consistent with the full freedom of use specified in this license. - -Most GNU software, including some libraries, is covered by the ordinary GNU General Public License. This license, the GNU Lesser General Public License, applies to certain designated libraries, and is quite different from the ordinary General Public License. We use this license for certain libraries in order to permit linking those libraries into non-free programs. - -When a program is linked with a library, whether statically or using a shared library, the combination of the two is legally speaking a combined work, a derivative of the original library. The ordinary General Public License therefore permits such linking only if the entire combination fits its criteria of freedom. The Lesser General Public License permits more lax criteria for linking other code with the library. - -We call this license the "Lesser" General Public License because it does Less to protect the user's freedom than the ordinary General Public License. It also provides other free software developers Less of an advantage over competing non-free programs. These disadvantages are the reason we use the ordinary General Public License for many libraries. However, the Lesser license provides advantages in certain special circumstances. - -For example, on rare occasions, there may be a special need to encourage the widest possible use of a certain library, so that it becomes a de-facto standard. To achieve this, non-free programs must be allowed to use the library. A more frequent case is that a free library does the same job as widely used non-free libraries. In this case, there is little to gain by limiting the free library to free software only, so we use the Lesser General Public License. - -In other cases, permission to use a particular library in non-free programs enables a greater number of people to use a large body of free software. For example, permission to use the GNU C Library in non-free programs enables many more people to use the whole GNU operating system, as well as its variant, the GNU/Linux operating system. - -Although the Lesser General Public License is Less protective of the users' freedom, it does ensure that the user of a program that is linked with the Library has the freedom and the wherewithal to run that program using a modified version of the Library. - -The precise terms and conditions for copying, distribution and modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, whereas the latter must be combined with the library in order to run. - -GNU LESSER GENERAL PUBLIC LICENSE -TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - -0. This License Agreement applies to any software library or other program which contains a notice placed by the copyright holder or other authorized party saying it may be distributed under the terms of this Lesser General Public License (also called "this License"). Each licensee is addressed as "you". - -A "library" means a collection of software functions and/or data prepared so as to be conveniently linked with application programs (which use some of those functions and data) to form executables. - -The "Library", below, refers to any such software library or work which has been distributed under these terms. A "work based on the Library" means either the Library or any derivative work under copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with modifications and/or translated straightforwardly into another language. (Hereinafter, translation is included without limitation in the term "modification".) - -"Source code" for a work means the preferred form of the work for making modifications to it. For a library, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library. - -Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running a program using the Library is not restricted, and output from such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does. - -1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and distribute a copy of this License along with the Library. - -You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. - -2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: - - a) The modified work must itself be a software library. - - b) You must cause the files modified to carry prominent notices stating that you changed the files and the date of any change. - - c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms of this License. - - d) If a facility in the modified Library refers to a function or a table of data to be supplied by an application program that uses the facility, other than as an argument passed when the facility is invoked, then you must make a good faith effort to ensure that, in the event an application does not supply such function or table, the facility still operates, and performs whatever part of its purpose remains meaningful. - -(For example, a function in a library to compute square roots has a purpose that is entirely well-defined independent of the application. Therefore, Subsection 2d requires that any application-supplied function or table used by this function must be optional: if the application does not supply it, the square root function must still compute square roots.) - -These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Library, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Library, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. - -Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Library. - -In addition, mere aggregation of another work not based on the Library with the Library (or with a work based on the Library) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. - -3. You may opt to apply the terms of the ordinary GNU General Public License instead of this License to a given copy of the Library. To do this, you must alter all the notices that refer to this License, so that they refer to the ordinary GNU General Public License, version 2, instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. - -Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. - -This option is useful when you wish to copy part of the code of the Library into a program that is not a library. - -4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange. - -If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code, even though third parties are not compelled to copy the source along with the object code. - -5. A program that contains no derivative of any portion of the Library, but is designed to work with the Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in isolation, is not a derivative work of the Library, and therefore falls outside the scope of this License. - -However, linking a "work that uses the Library" with the Library creates an executable that is a derivative of the Library (because it contains portions of the Library), rather than a "work that uses the library". The executable is therefore covered by this License. Section 6 states terms for distribution of such executables. - -When a "work that uses the Library" uses material from a header file that is part of the Library, the object code for the work may be a derivative work of the Library even though the source code is not. Whether this is true is especially significant if the work can be linked without the Library, or if the work is itself a library. The threshold for this to be true is not precisely defined by law. - -If such an object file uses only numerical parameters, data structure layouts and accessors, and small macros and small inline functions (ten lines or less in length), then the use of the object file is unrestricted, regardless of whether it is legally a derivative work. (Executables containing this object code plus portions of the Library will still fall under Section 6.) - -Otherwise, if the work is a derivative of the Library, you may distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. - -6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work under terms of your choice, provided that the terms permit modification of the work for the customer's own use and reverse engineering for debugging such modifications. - -You must give prominent notice with each copy of the work that the Library is used in it and that the Library and its use are covered by this License. You must supply a copy of this License. If the work during execution displays copyright notices, you must include the copyright notice for the Library among them, as well as a reference directing the user to the copy of this License. Also, you must do one of these things: - - a) Accompany the work with the complete corresponding machine-readable source code for the Library including whatever changes were used in the work (which must be distributed under Sections 1 and 2 above); and, if the work is an executable linked with the Library, with the complete machine-readable "work that uses the Library", as object code and/or source code, so that the user can modify the Library and then relink to produce a modified executable containing the modified Library. (It is understood that the user who changes the contents of definitions files in the Library will not necessarily be able to recompile the application to use the modified definitions.) - - b) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (1) uses at run time a copy of the library already present on the user's computer system, rather than copying library functions into the executable, and (2) will operate properly with a modified version of the library, if the user installs one, as long as the modified version is interface-compatible with the version that the work was made with. - - c) Accompany the work with a written offer, valid for at least three years, to give the same user the materials specified in Subsection 6a, above, for a charge no more than the cost of performing this distribution. - - d) If distribution of the work is made by offering access to copy from a designated place, offer equivalent access to copy the above specified materials from the same place. - - e) Verify that the user has already received a copy of these materials or that you have already sent this user a copy. - -For an executable, the required form of the "work that uses the Library" must include any data and utility programs needed for reproducing the executable from it. However, as a special exception, the materials to be distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. - -It may happen that this requirement contradicts the license restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. - -7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined library, provided that the separate distribution of the work based on the Library and of the other library facilities is otherwise permitted, and provided that you do these two things: - - a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities. This must be distributed under the terms of the Sections above. - - b) Give prominent notice with the combined library of the fact that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. - -8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, link with, or distribute the Library is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. - -9. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Library or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Library (or any work based on the Library), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Library or works based on it. - -10. Each time you redistribute the Library (or any work based on the Library), the recipient automatically receives a license from the original licensor to copy, distribute, link with or modify the Library subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties with this License. - -11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Library at all. For example, if a patent license would not permit royalty-free redistribution of the Library by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Library. - -If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply, and the section as a whole is intended to apply in other circumstances. - -It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. - -This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. - -12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Library under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. - -13. The Free Software Foundation may publish revised and/or new versions of the Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. - -Each version is given a distinguishing version number. If the Library specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. - -14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. - -NO WARRANTY - -15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - -16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. - -END OF TERMS AND CONDITIONS - -How to Apply These Terms to Your New Libraries - -If you develop a new library, and you want it to be of the greatest possible use to the public, we recommend making it free software that everyone can redistribute and change. You can do so by permitting redistribution under these terms (or, alternatively, under the terms of the ordinary General Public License). - -To apply these terms, attach the following notices to the library. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. - - one line to give the library's name and an idea of what it does. - Copyright (C) year name of author - - This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; version 2.1 of the License. - - This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Also add information on how to contact you by electronic and paper mail. - -You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the library, if necessary. Here is a sample; alter the names: - -Yoyodyne, Inc., hereby disclaims all copyright interest in -the library `Frob' (a library for tweaking knobs) written -by James Random Hacker. - -signature of Ty Coon, 1 April 1990 -Ty Coon, President of Vice -That's all there is to it! diff --git a/packages/code-review/README.md b/packages/code-review/README.md deleted file mode 100644 index ee38141cff..0000000000 --- a/packages/code-review/README.md +++ /dev/null @@ -1,118 +0,0 @@ -# @corbits/code-review - -The mechanism behind a multi-reviewer pull-request review: the reviewer -roster, the turn input, the parse of what a reviewer reports, the -aggregation into one review, and the run that ties them together. - -## The roster - -Three reviewers, each a lens rather than a whole opinion: - -| Reviewer | What it judges | -| --------------------- | ---------------------------------------------------------------------------------------- | -| Correctness reviewer | Defects, with the file, the line, and the input that triggers them | -| Architecture reviewer | Whether the shape is sound: invariants, where a constraint is owned, what it costs later | -| Release-risk reviewer | What blocks shipping, what ships with a note, what is filed for later | - -`codeReviewAgentRequests()` returns the same three as create-agent -requests, so the roster a review fans out to is the roster a person can -see and edit in the workbench. - -Each reviewer also carries a canned `introduction` — one or two sentences -in its own voice, naming the job and the repos it will watch. -`reviewerIntroductions(repoNames)` (`src/introductions.ts`, -`@corbits/code-review/introductions`) renders all three in roster order; -a host posts them once a person starts reviewing, so the room shows who -just came online without running any inference. - -## The report contract - -Every reviewer replies under `REVIEWER_REPORT_CONTRACT` -(`src/reviewers.ts`), parsed by `parseReviewerReport` against an arktype -schema (`src/report.ts`). A reply outside the shape is a named parse -failure, never coerced into an empty report. - -- **At most 5 findings per reviewer.** The prompt asks for the ones that - matter most, not a full list — noise crowds out the real findings. -- **Severity is triaged, not defaulted.** `blocking` is a defect, a - broken invariant, or a signature drift a caller depends on — something - that should not ship as-is. `should-fix` is real but not urgent. - `later` is a good idea with no urgency. The prompt states the bar for - each so a reviewer cannot mark everything blocking to be read first. -- **Noise is suppressed in the prompt, not filtered after the fact.** - Typos, docstring wording, import ordering, and formatting nits are - told to reviewers to skip outright — never reported even as `later`. -- **`existingCode`/`suggestedFix` is a before/after code pair, never - prose.** `existingCode` is the exact lines quoted from the diff; - `suggestedFix` is the literal replacement. Aggregation only renders a - `\`\`\`suggestion\`\`\``fence when`existingCode`is verified against -the diff at that finding's file — a finding whose`suggestedFix`is -prose, or whose`existingCode` cannot be found in the diff, keeps its - text and drops the fence rather than posting a broken commit - suggestion. - -`./reviewers` and `./agent-requests` are also exported as their own -subpaths (`@corbits/code-review/reviewers`, -`@corbits/code-review/agent-requests`), separate from the package root. -Both files have zero imports of their own; the root also re-exports the -review run and GitHub client, which pull in `@corbits/github-tools` and -`@intx/agent`'s full provider surface. A caller installing the reviewer -roster into an agent-directory (`codeReviewAgentRequests`) imports it -through these subpaths for exactly that reason. - -## The run - -```ts -const result = await runPullRequestReview( - { - github: createGitHubReviewClient({ apiKey: token }), - runReviewerTurn: ({ reviewer, prompt }) => askModel(reviewer, prompt), - }, - { owner: "acme", repo: "widgets", number: 7 }, -); -``` - -One diff read, one turn per reviewer, one review posted at the head sha. -GitHub reach and the reviewer turn are seams: the host that owns the -connection's credential and inference supplies them, which is also why -the whole loop is testable without a network. - -A reviewer that fails or replies outside the report shape does not fail -the review — the posted review names it under "Reviewers that did not -report", so a partial review never reads as a complete one. - -The result is `{ skipped: true, reason }` when the pull request's author -reads as a bot login (`isBotAuthor`, `src/bot-guard.ts`) — no inference -runs and nothing is posted, so a bot pushing a change can never trigger -a review that something downstream reacts to. - -## Aggregation - -- A finding two reviewers raise is one entry crediting both, at the more - severe of the two severities, keyed by fingerprint (see below) rather - than an exact string match. -- Order is blocking, then worth fixing, then for later. -- An inline comment is made only for a line the diff reported as - changed; every other finding stays in the body, where GitHub cannot - reject it. -- No findings reads as a clean review, not an empty one. - -## Fingerprints and re-runs - -Every finding gets a stable id — `fingerprintOf` (`src/fingerprint.ts`) -SHA-256 hashes its file, line, and normalized summary — embedded as an -HTML comment marker in the posted text (invisible in rendered markdown). -Aggregation dedupes on this fingerprint, which is what lets two lenses -that raise the same problem in different words still collapse into one -entry. - -`runPullRequestReview` reads back every comment already posted on the -pull request before aggregating, and `aggregateReview`'s third argument -drops any finding whose fingerprint is already out there. A re-run — -after a `synchronize`, for instance — never raises a finding it already -raised. - -## Posting - -A posted review is comment-only: never an approval, a change request, or -a merge. That is why it is not approval-gated. diff --git a/packages/code-review/package.json b/packages/code-review/package.json deleted file mode 100644 index 9b9f98477f..0000000000 --- a/packages/code-review/package.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "name": "@corbits/code-review", - "version": "0.0.2", - "description": "Multi-reviewer pull-request review: three reviewer definitions, one aggregated review, posted to GitHub", - "license": "LGPL-2.1-only", - "repository": { - "type": "git", - "url": "https://github.com/corbitsdev/workbench.git", - "directory": "packages/code-review" - }, - "files": [ - "src", - "LICENSE", - "README.md" - ], - "type": "module", - "exports": { - ".": "./src/index.ts", - "./reviewers": "./src/reviewers.ts", - "./agent-requests": "./src/agent-requests.ts", - "./introductions": "./src/introductions.ts" - }, - "publishConfig": { - "access": "public" - }, - "scripts": { - "typecheck": "tsc --noEmit", - "test": "bun test" - }, - "dependencies": { - "arktype": "catalog:" - }, - "devDependencies": { - "@corbits/agent-directory": "workspace:*", - "@types/bun": "catalog:", - "typescript": "catalog:" - }, - "peerDependencies": { - "@corbits/github-tools": "0.0.11" - } -} diff --git a/packages/code-review/src/agent-requests.test.ts b/packages/code-review/src/agent-requests.test.ts deleted file mode 100644 index 8be2855e27..0000000000 --- a/packages/code-review/src/agent-requests.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { expect, test } from "bun:test"; -import { type } from "arktype"; -import { CreateAgentDefinitionInput } from "@corbits/agent-directory"; - -import { codeReviewAgentRequests } from "./agent-requests"; -import { CODE_REVIEW_REVIEWERS, reviewerReportPrompt } from "./reviewers"; - -test("every reviewer installs through the agent create path unchanged", () => { - const requests = codeReviewAgentRequests(); - expect(requests.length).toBe(3); - for (const request of requests) { - const parsed = CreateAgentDefinitionInput(request); - if (parsed instanceof type.errors) { - throw new Error(`${request.handle}: ${parsed.summary}`); - } - } -}); - -test("each reviewer gets its own handle", () => { - const handles = codeReviewAgentRequests().map((request) => request.handle); - expect(new Set(handles).size).toBe(handles.length); -}); - -test("an installed reviewer answers a person in prose — the JSON report contract is never in its system prompt", () => { - for (const request of codeReviewAgentRequests()) { - expect(request.systemPrompt).not.toContain("Reply with JSON"); - expect(request.systemPrompt).not.toContain('"findings"'); - } -}); - -test("the report contract rides along only on the path that parses JSON back", () => { - for (const reviewer of CODE_REVIEW_REVIEWERS) { - const prompt = reviewerReportPrompt(reviewer); - expect(prompt).toContain(reviewer.systemPrompt); - expect(prompt).toContain("Reply with JSON"); - } -}); diff --git a/packages/code-review/src/agent-requests.ts b/packages/code-review/src/agent-requests.ts deleted file mode 100644 index 03bb6fa22d..0000000000 --- a/packages/code-review/src/agent-requests.ts +++ /dev/null @@ -1,28 +0,0 @@ -// The same three reviewer definitions, in the shape the agent-directory -// create path takes. Installing them makes each reviewer a real agent in -// the workbench — visible in the directory, addressable on its own — so -// the roster a review fans out to is the roster a person can see and -// edit, not a hidden constant. -import { CODE_REVIEW_REVIEWERS, type ReviewerDefinition } from "./reviewers"; - -/** One create-agent request; mirrors the create route's own body. */ -export interface CodeReviewAgentRequest { - readonly name: string; - readonly handle: string; - readonly description: string; - readonly systemPrompt: string; -} - -function toRequest(reviewer: ReviewerDefinition): CodeReviewAgentRequest { - return { - name: reviewer.displayName, - handle: reviewer.handle, - description: reviewer.description, - systemPrompt: reviewer.systemPrompt, - }; -} - -/** The create-agent requests that install the reviewer roster. */ -export function codeReviewAgentRequests(): readonly CodeReviewAgentRequest[] { - return CODE_REVIEW_REVIEWERS.map(toRequest); -} diff --git a/packages/code-review/src/aggregate.test.ts b/packages/code-review/src/aggregate.test.ts deleted file mode 100644 index 022ccd70b6..0000000000 --- a/packages/code-review/src/aggregate.test.ts +++ /dev/null @@ -1,305 +0,0 @@ -import { expect, test } from "bun:test"; -import type { PullRequestDiff } from "@corbits/github-tools"; - -import { aggregateReview, type ReviewerPass } from "./aggregate"; -import { fingerprintOf } from "./fingerprint"; -import { reviewerById } from "./reviewers"; - -const DIFF: PullRequestDiff = { - ref: { owner: "acme", repo: "widgets", number: 7 }, - title: "Add the review loop", - description: "", - url: "https://github.com/acme/widgets/pull/7", - author: "octocat", - headSha: "headsha", - baseSha: "basesha", - files: [ - { - path: "src/loop.ts", - status: "modified", - additions: 2, - deletions: 0, - patch: "@@ -1,1 +1,3 @@\n context\n+added\n+more", - changedLines: [1, 2, 3], - }, - ], - truncated: false, -}; - -function pass(id: string, report: unknown): ReviewerPass { - return { - reviewer: reviewerById(id), - ok: true, - reply: JSON.stringify(report), - }; -} - -test("a finding two reviewers raise is one entry crediting both", () => { - const finding = { - severity: "blocking", - file: "src/loop.ts", - line: 2, - summary: "The second pass is dropped", - }; - const review = aggregateReview( - [ - pass("correctness", { summary: "read it", findings: [finding] }), - pass("architecture", { - summary: "read it", - findings: [{ ...finding, summary: "the second pass is DROPPED" }], - }), - ], - DIFF, - ); - const occurrences = review.body.match(/second pass is/gi) ?? []; - expect(occurrences.length).toBe(1); - expect(review.body).toContain("Correctness reviewer, Architecture reviewer"); - expect(review.comments.length).toBe(1); -}); - -test("blocking findings come before should-fix and later", () => { - const review = aggregateReview( - [ - pass("correctness", { - summary: "", - findings: [ - { severity: "later", file: "a.ts", summary: "rename this" }, - { severity: "blocking", file: "b.ts", summary: "crashes on empty" }, - { severity: "should-fix", file: "c.ts", summary: "no test" }, - ], - }), - ], - DIFF, - ); - const blocking = review.body.indexOf("crashes on empty"); - const shouldFix = review.body.indexOf("no test"); - const later = review.body.indexOf("rename this"); - expect(blocking).toBeLessThan(shouldFix); - expect(shouldFix).toBeLessThan(later); -}); - -test("an inline comment is made only for a line the diff can anchor", () => { - const review = aggregateReview( - [ - pass("correctness", { - summary: "", - findings: [ - { - severity: "blocking", - file: "src/loop.ts", - line: 2, - summary: "anchorable", - existingCode: "added", - suggestedFix: "const fixed = true;", - }, - { - severity: "blocking", - file: "src/loop.ts", - line: 99, - summary: "outside the diff", - }, - { severity: "blocking", file: "src/absent.ts", summary: "no line" }, - ], - }), - ], - DIFF, - ); - expect(review.comments.map((comment) => comment.line)).toEqual([2]); - expect(review.comments[0]?.body).toContain("```suggestion"); - expect(review.body).toContain("outside the diff"); - expect(review.body).toContain("no line"); -}); - -test("a suggestedFix with no matching existingCode keeps the finding but drops the fence", () => { - const review = aggregateReview( - [ - pass("correctness", { - summary: "read it", - findings: [ - { - severity: "blocking", - file: "src/loop.ts", - line: 2, - summary: "prose instead of code", - suggestedFix: "You should add a null check here.", - }, - ], - }), - ], - DIFF, - ); - expect(review.comments.length).toBe(1); - expect(review.comments[0]?.body).not.toContain("```suggestion"); - expect(review.body).toContain("prose instead of code"); -}); - -test("existingCode that does not match the diff also drops the fence", () => { - const review = aggregateReview( - [ - pass("correctness", { - summary: "read it", - findings: [ - { - severity: "blocking", - file: "src/loop.ts", - line: 2, - summary: "made up existing code", - existingCode: "this line is not in the diff at all", - suggestedFix: "const fixed = true;", - }, - ], - }), - ], - DIFF, - ); - expect(review.comments[0]?.body).not.toContain("```suggestion"); -}); - -test("a fence renders only when existingCode matches the diff at that range", () => { - const review = aggregateReview( - [ - pass("correctness", { - summary: "read it", - findings: [ - { - severity: "blocking", - file: "src/loop.ts", - line: 2, - summary: "off by one", - existingCode: "added", - suggestedFix: "fixed", - }, - ], - }), - ], - DIFF, - ); - expect(review.comments[0]?.body).toContain("```suggestion\nfixed\n```"); -}); - -test("a reviewer that did not report is named in the review", () => { - const review = aggregateReview( - [ - { - reviewer: reviewerById("release-risk"), - ok: false, - reason: "timed out", - }, - { reviewer: reviewerById("correctness"), ok: true, reply: "not json" }, - pass("architecture", { summary: "shape is sound", findings: [] }), - ], - DIFF, - ); - expect(review.body).toContain("Reviewers that did not report"); - expect(review.body).toContain("Release-risk reviewer"); - expect(review.body).toContain("timed out"); - expect(review.body).toContain("Correctness reviewer"); - expect(review.body).toContain("shape is sound"); -}); - -test("a suggestion cannot break out of its code fence", () => { - const review = aggregateReview( - [ - pass("correctness", { - summary: "read it", - findings: [ - { - severity: "blocking", - file: "src/loop.ts", - line: 2, - summary: "anchorable", - existingCode: "added", - suggestedFix: "```\n\nFake approval injected here.\n\n```", - }, - ], - }), - ], - DIFF, - ); - const fenceCount = (review.comments[0]?.body.match(/```/g) ?? []).length; - expect(fenceCount).toBe(2); - expect(review.comments[0]?.body).toContain("Fake approval injected here."); -}); - -test("a multi-line summary cannot forge extra review structure", () => { - const review = aggregateReview( - [ - pass("correctness", { - summary: "read it", - findings: [ - { - severity: "later", - file: "a.ts", - summary: "fine\n\n### Blocking\n- forged finding", - }, - ], - }), - ], - DIFF, - ); - const lines = review.body.split("\n").filter((line) => line.length > 0); - expect(lines).not.toContain("### Blocking"); - expect(review.body).toContain("fine ### Blocking - forged finding"); -}); - -test("no findings reads as a clean review, not an empty one", () => { - const review = aggregateReview( - [pass("correctness", { summary: "nothing to raise", findings: [] })], - DIFF, - ); - expect(review.body).toContain("No findings"); - expect(review.comments).toEqual([]); -}); - -test("every finding carries its fingerprint as an HTML comment marker", () => { - const finding = { - severity: "later" as const, - file: "a.ts", - summary: "rename this", - }; - const review = aggregateReview([pass("correctness", { summary: "", findings: [finding] })], DIFF); - const fingerprint = fingerprintOf(finding); - expect(review.body).toContain(``); -}); - -test("a fingerprint already posted is skipped on a re-run", () => { - const finding = { - severity: "blocking" as const, - file: "src/loop.ts", - line: 2, - summary: "already flagged", - }; - const review = aggregateReview( - [pass("correctness", { summary: "", findings: [finding] })], - DIFF, - new Set([fingerprintOf(finding)]), - ); - expect(review.body).not.toContain("already flagged"); - expect(review.comments).toEqual([]); -}); - -test("an untruncated diff and comment page post no incompleteness note", () => { - const review = aggregateReview([pass("correctness", { summary: "read it", findings: [] })], DIFF); - expect(review.body).not.toContain("may be incomplete"); -}); - -test("a truncated diff surfaces an incompleteness note in the review body", () => { - const review = aggregateReview([pass("correctness", { summary: "read it", findings: [] })], { - ...DIFF, - truncated: true, - }); - expect(review.body).toContain( - "This review may be incomplete: the pull request has more changed " + - "files or already-posted comments than one review pass reads", - ); -}); - -test("a truncated already-posted-comments page surfaces the same note", () => { - const review = aggregateReview( - [pass("correctness", { summary: "read it", findings: [] })], - DIFF, - new Set(), - true, - ); - expect(review.body).toContain("This review may be incomplete"); -}); diff --git a/packages/code-review/src/aggregate.ts b/packages/code-review/src/aggregate.ts deleted file mode 100644 index 4c2cc378ce..0000000000 --- a/packages/code-review/src/aggregate.ts +++ /dev/null @@ -1,247 +0,0 @@ -// Three reviewer passes become one review. Aggregation is where the -// review earns being a single comment instead of three: findings the -// reviewers agree on are one entry crediting all of them, order runs -// blocking first, and a finding anchored to a line GitHub cannot accept -// a comment on still appears in the body rather than vanishing. -import type { - PullRequestDiff, - PullRequestReviewComment, - PullRequestReviewDraft, -} from "@corbits/github-tools"; - -import { fingerprintMarker, fingerprintOf } from "./fingerprint"; -import { parseReviewerReport, type ReviewerFinding } from "./report"; -import type { ReviewerDefinition } from "./reviewers"; - -/** One reviewer's turn, as it came back. */ -export type ReviewerPass = - | { - readonly reviewer: ReviewerDefinition; - readonly ok: true; - readonly reply: string; - } - | { - readonly reviewer: ReviewerDefinition; - readonly ok: false; - readonly reason: string; - }; - -interface AggregatedFinding { - readonly finding: ReviewerFinding; - readonly fingerprint: string; - readonly reviewers: readonly string[]; -} - -const SEVERITY_ORDER = ["blocking", "should-fix", "later"] as const; - -const SEVERITY_HEADING: Record = { - blocking: "Blocking", - "should-fix": "Worth fixing", - later: "For later", -}; - -// A finding's `summary` and `suggestion` are model output shaped by -// arktype, but arktype only constrains the type, not the markdown it -// gets embedded into — and the model itself is reading attacker-supplied -// PR content (title, description, diff). A summary with an embedded -// newline can forge a fresh list item or heading; a suggestion with an -// embedded ``` can break out of its code fence into free-form review -// body. Neither is hypothetical: both are one crafted diff line away. -function singleLine(text: string): string { - return text.replace(/\s+/g, " ").trim(); -} - -function fenceSafe(text: string): string { - return text.replace(/```/g, "`​``"); -} - -function inlineCodeSafe(text: string): string { - return text.replace(/`/g, "'"); -} - -function severityRank(severity: ReviewerFinding["severity"]): number { - return SEVERITY_ORDER.indexOf(severity); -} - -interface CollectedPasses { - readonly findings: readonly AggregatedFinding[]; - readonly summaries: readonly { name: string; summary: string }[]; - readonly silent: readonly { name: string; reason: string }[]; -} - -function collect( - passes: readonly ReviewerPass[], - alreadyPosted: ReadonlySet, -): CollectedPasses { - const byFingerprint = new Map(); - const summaries: { name: string; summary: string }[] = []; - const silent: { name: string; reason: string }[] = []; - - for (const pass of passes) { - const name = pass.reviewer.displayName; - if (!pass.ok) { - silent.push({ name, reason: pass.reason }); - continue; - } - const parsed = parseReviewerReport(pass.reply); - if (!parsed.ok) { - silent.push({ name, reason: parsed.reason }); - continue; - } - if (parsed.report.summary.trim().length > 0) { - summaries.push({ name, summary: parsed.report.summary.trim() }); - } - for (const finding of parsed.report.findings) { - const fingerprint = fingerprintOf(finding); - if (alreadyPosted.has(fingerprint)) continue; - const existing = byFingerprint.get(fingerprint); - if (existing === undefined) { - byFingerprint.set(fingerprint, { finding, who: [name] }); - continue; - } - existing.who.push(name); - if (severityRank(finding.severity) < severityRank(existing.finding.severity)) { - byFingerprint.set(fingerprint, { finding, who: existing.who }); - } - } - } - - const findings = [...byFingerprint.entries()] - .map(([fingerprint, entry]) => ({ - finding: entry.finding, - fingerprint, - reviewers: entry.who, - })) - .sort( - (left, right) => severityRank(left.finding.severity) - severityRank(right.finding.severity), - ); - return { findings, summaries, silent }; -} - -function anchorable(finding: ReviewerFinding, diff: PullRequestDiff): number | undefined { - if (finding.line === undefined) return undefined; - const file = diff.files.find((entry) => entry.path === finding.file); - if (file === undefined) return undefined; - return file.changedLines.includes(finding.line) ? finding.line : undefined; -} - -function attribution(reviewers: readonly string[]): string { - return reviewers.join(", "); -} - -// The `suggestion` fence is a GitHub commit-suggestion: whatever text it -// wraps replaces the anchored line outright. Rendering `suggestedFix` -// there only makes sense once `existingCode` is verified against the -// diff — otherwise a model that wrote prose instead of code would ship -// as a broken, unreviewable "fix". A finding that fails the check keeps -// its text; it only drops the fence. -function rightHandLines(patch: string): string { - return patch - .split("\n") - .filter((line) => !line.startsWith("-") && !line.startsWith("@@")) - .map((line) => (line.startsWith("+") || line.startsWith(" ") ? line.slice(1) : line)) - .join("\n"); -} - -function existingCodeAnchors(finding: ReviewerFinding, diff: PullRequestDiff): boolean { - if (finding.existingCode === undefined) return false; - const file = diff.files.find((entry) => entry.path === finding.file); - if (file?.patch === undefined) return false; - return rightHandLines(file.patch).includes(finding.existingCode.trim()); -} - -function bodyLine(entry: AggregatedFinding): string { - const path = inlineCodeSafe(entry.finding.file); - const where = entry.finding.line === undefined ? path : `${path}:${String(entry.finding.line)}`; - return ( - `- \`${where}\` — ${singleLine(entry.finding.summary)} ` + - `_(${attribution(entry.reviewers)})_ ` + - fingerprintMarker(entry.fingerprint) - ); -} - -function commentBody(entry: AggregatedFinding, diff: PullRequestDiff): string { - const head = - `**${SEVERITY_HEADING[entry.finding.severity]}** — ` + - `${singleLine(entry.finding.summary)}\n\n_${attribution(entry.reviewers)}_`; - const marker = fingerprintMarker(entry.fingerprint); - if (entry.finding.suggestedFix === undefined || !existingCodeAnchors(entry.finding, diff)) { - return `${head}\n\n${marker}`; - } - return ( - `${head}\n\n\`\`\`suggestion\n${fenceSafe(entry.finding.suggestedFix)}\n\`\`\`` + - `\n\n${marker}` - ); -} - -// GitHub caps a paginated fetch at `fetchAllPages`' page bound; a pull -// request past it reads back partial. Silence would let this review -// read as complete when it saw only part of the change, or re-flag a -// finding whose earlier post fell outside the read page — both worse -// than saying so plainly. -const TRUNCATED_NOTE = - "_This review may be incomplete: the pull request has more changed " + - "files or already-posted comments than one review pass reads, so " + - "some results may be partial or repeated._"; - -function countLine(findings: readonly AggregatedFinding[]): string { - if (findings.length === 0) { - return "No findings — the reviewers read the change and had nothing to raise."; - } - const counts = SEVERITY_ORDER.map((severity) => { - const total = findings.filter((entry) => entry.finding.severity === severity).length; - return total === 0 ? undefined : `${String(total)} ${SEVERITY_HEADING[severity].toLowerCase()}`; - }).filter((part): part is string => part !== undefined); - return `${counts.join(", ")}.`; -} - -/** - * Combines the reviewer passes into the one review that gets posted: - * a markdown body covering every finding, plus inline comments for the - * findings anchored to a line in the diff. - */ -export function aggregateReview( - passes: readonly ReviewerPass[], - diff: PullRequestDiff, - alreadyPosted: ReadonlySet = new Set(), - commentsTruncated = false, -): PullRequestReviewDraft { - const collected = collect(passes, alreadyPosted); - const sections: string[] = ["## Code review", "", countLine(collected.findings)]; - - if (diff.truncated || commentsTruncated) { - sections.push("", TRUNCATED_NOTE); - } - - for (const severity of SEVERITY_ORDER) { - const forSeverity = collected.findings.filter((entry) => entry.finding.severity === severity); - if (forSeverity.length === 0) continue; - sections.push("", `### ${SEVERITY_HEADING[severity]}`, ""); - sections.push(...forSeverity.map(bodyLine)); - } - - if (collected.summaries.length > 0) { - sections.push("", "### What each reviewer looked at", ""); - sections.push( - ...collected.summaries.map((entry) => `- **${entry.name}** — ${singleLine(entry.summary)}`), - ); - } - - if (collected.silent.length > 0) { - sections.push("", "### Reviewers that did not report", ""); - sections.push(...collected.silent.map((entry) => `- **${entry.name}** — ${entry.reason}`)); - } - - const comments: PullRequestReviewComment[] = []; - for (const entry of collected.findings) { - const line = anchorable(entry.finding, diff); - if (line === undefined) continue; - comments.push({ - path: entry.finding.file, - line, - body: commentBody(entry, diff), - }); - } - - return { body: `${sections.join("\n")}\n`, comments }; -} diff --git a/packages/code-review/src/bot-guard.test.ts b/packages/code-review/src/bot-guard.test.ts deleted file mode 100644 index f92dae75bb..0000000000 --- a/packages/code-review/src/bot-guard.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { expect, test } from "bun:test"; - -import { isBotAuthor } from "./bot-guard"; - -test("a plain human login is not a bot", () => { - expect(isBotAuthor("octocat")).toBe(false); -}); - -test.each([ - "dependabot[bot]", - "renovate[bot]", - "release-bot", - "bot-deploy", - "deploy_bot", - "bot_deploy", -])("%s reads as a bot login", (login) => { - expect(isBotAuthor(login)).toBe(true); -}); - -test("the check is case-insensitive", () => { - expect(isBotAuthor("Dependabot[BOT]")).toBe(true); -}); - -test("a login that merely contains the word robot is not flagged", () => { - expect(isBotAuthor("robotics-team")).toBe(false); -}); diff --git a/packages/code-review/src/bot-guard.ts b/packages/code-review/src/bot-guard.ts deleted file mode 100644 index 5f26c1c71f..0000000000 --- a/packages/code-review/src/bot-guard.ts +++ /dev/null @@ -1,12 +0,0 @@ -// A review that reacts to a bot's own pull request risks a loop: the -// bot pushes, the review posts, something reacts to the posted review, -// and the cycle repeats with no human in it. The guard is a pure -// predicate on the author's login so the workflow entry can skip the -// run before any inference or posting happens. -const BOT_LOGIN_INDICATORS = ["bot_", "bot-", "_bot", "-bot", "[bot]"]; - -/** Whether a pull-request author's login reads as a bot, not a person. */ -export function isBotAuthor(login: string): boolean { - const normalized = login.toLowerCase(); - return BOT_LOGIN_INDICATORS.some((indicator) => normalized.includes(indicator)); -} diff --git a/packages/code-review/src/fingerprint.test.ts b/packages/code-review/src/fingerprint.test.ts deleted file mode 100644 index da5419ebd0..0000000000 --- a/packages/code-review/src/fingerprint.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { expect, test } from "bun:test"; - -import { fingerprintMarker, fingerprintOf, fingerprintsIn } from "./fingerprint"; -import type { ReviewerFinding } from "./report"; - -function finding(overrides: Partial = {}): ReviewerFinding { - return { - severity: "blocking", - file: "src/loop.ts", - line: 2, - summary: "Drops the second pass", - ...overrides, - }; -} - -test("the same finding fingerprints the same way every time", () => { - expect(fingerprintOf(finding())).toBe(fingerprintOf(finding())); -}); - -test("whitespace and case in the summary do not change the fingerprint", () => { - expect(fingerprintOf(finding({ summary: "drops the second pass " }))).toBe( - fingerprintOf(finding({ summary: "Drops the second pass" })), - ); -}); - -test("a different line changes the fingerprint", () => { - expect(fingerprintOf(finding({ line: 3 }))).not.toBe(fingerprintOf(finding({ line: 2 }))); -}); - -test("a different file changes the fingerprint", () => { - expect(fingerprintOf(finding({ file: "other.ts" }))).not.toBe(fingerprintOf(finding())); -}); - -test("fingerprintsIn reads every marker out of posted comment bodies", () => { - const a = fingerprintOf(finding()); - const b = fingerprintOf(finding({ file: "other.ts" })); - const found = fingerprintsIn([ - `Some text\n\n${fingerprintMarker(a)}`, - `Other text ${fingerprintMarker(b)} trailing`, - "No marker here.", - ]); - expect(found).toEqual(new Set([a, b])); -}); - -test("fingerprintsIn ignores text that only looks like a marker", () => { - expect(fingerprintsIn([""])).toEqual(new Set()); -}); diff --git a/packages/code-review/src/fingerprint.ts b/packages/code-review/src/fingerprint.ts deleted file mode 100644 index 621cb39c55..0000000000 --- a/packages/code-review/src/fingerprint.ts +++ /dev/null @@ -1,48 +0,0 @@ -// A finding's stable identity: a content-hash fingerprint over the file, -// line, and normalized summary that identifies it. Two reviewers who -// raise the same problem in different words still land on the same -// fingerprint if their summary normalizes the same way, which is what -// lets aggregation credit both under one entry; a fingerprint embedded -// in the posted comment is also the marker a re-run reads back to know -// this finding was already posted, so it is never raised twice. -import { createHash } from "node:crypto"; - -import type { ReviewerFinding } from "./report"; - -/** The HTML comment marker a fingerprint is embedded under in posted text. */ -const MARKER_PREFIX = ""; -const MARKER_PATTERN = //g; - -function normalizedSummary(summary: string): string { - return summary.trim().toLowerCase().replace(/\s+/g, " "); -} - -/** - * The stable id for one finding: SHA-256 over its file, line, and - * normalized summary. Two findings that would render as the same body - * line share a fingerprint, by design — that is the same identity - * aggregation already dedupes on. - */ -export function fingerprintOf(finding: ReviewerFinding): string { - const line = finding.line === undefined ? "" : String(finding.line); - const key = `${finding.file}\n${line}\n${normalizedSummary(finding.summary)}`; - return createHash("sha256").update(key).digest("hex"); -} - -/** Embeds a fingerprint as an invisible marker in posted markdown. */ -export function fingerprintMarker(fingerprint: string): string { - return `${MARKER_PREFIX}${fingerprint}${MARKER_SUFFIX}`; -} - -/** Reads every fingerprint marker out of previously posted comment bodies. */ -export function fingerprintsIn(bodies: readonly string[]): ReadonlySet { - const found = new Set(); - for (const body of bodies) { - for (const match of body.matchAll(MARKER_PATTERN)) { - const fingerprint = match[1]; - if (fingerprint !== undefined) found.add(fingerprint); - } - } - return found; -} diff --git a/packages/code-review/src/github.ts b/packages/code-review/src/github.ts deleted file mode 100644 index 70511fc206..0000000000 --- a/packages/code-review/src/github.ts +++ /dev/null @@ -1,20 +0,0 @@ -// Binds the review run's GitHub seam to `@corbits/github-tools`' REST -// client. The credential arrives as config from whoever resolved the -// connection — this module never reads an environment variable itself. -import { - fetchPullRequestDiff, - fetchPullRequestReviewComments, - postPullRequestReview, - type GitHubClientConfig, -} from "@corbits/github-tools"; - -import type { CodeReviewGitHub } from "./review-run"; - -/** The GitHub reach a review run needs, under a resolved credential. */ -export function createGitHubReviewClient(config: GitHubClientConfig): CodeReviewGitHub { - return { - fetchDiff: (ref) => fetchPullRequestDiff(config, ref), - postReview: (ref, headSha, review) => postPullRequestReview(config, ref, headSha, review), - listPostedComments: (ref) => fetchPullRequestReviewComments(config, ref), - }; -} diff --git a/packages/code-review/src/index.ts b/packages/code-review/src/index.ts deleted file mode 100644 index 3bbf47b383..0000000000 --- a/packages/code-review/src/index.ts +++ /dev/null @@ -1,29 +0,0 @@ -export { codeReviewAgentRequests, type CodeReviewAgentRequest } from "./agent-requests"; -export { aggregateReview, type ReviewerPass } from "./aggregate"; -export { isBotAuthor } from "./bot-guard"; -export { fingerprintMarker, fingerprintOf, fingerprintsIn } from "./fingerprint"; -export { createGitHubReviewClient } from "./github"; -export { renderReviewPrompt } from "./prompt"; -export { - parseReviewerReport, - type ParsedReviewerReport, - type ReviewerFinding, - type ReviewerReport, -} from "./report"; -export { - CODE_REVIEW_REVIEWERS, - REVIEWER_REPORT_CONTRACT, - reviewerReportPrompt, - reviewerById, - type ReviewerDefinition, -} from "./reviewers"; -export { reviewerIntroductions, type ReviewerIntroduction } from "./introductions"; -export { - runPullRequestReview, - type CodeReviewGitHub, - type PullRequestReviewResult, - type PullRequestReviewRun, - type PullRequestReviewSkipped, - type ReviewerTurn, - type RunPullRequestReviewDeps, -} from "./review-run"; diff --git a/packages/code-review/src/introductions.test.ts b/packages/code-review/src/introductions.test.ts deleted file mode 100644 index 2130d94f47..0000000000 --- a/packages/code-review/src/introductions.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { CODE_REVIEW_REVIEWERS } from "./reviewers"; -import { reviewerIntroductions } from "./introductions"; - -const REPO_NAMES = ["acme/widgets", "acme/gadgets"]; - -describe("reviewerIntroductions", () => { - test("returns one introduction per reviewer, in roster order", () => { - const introductions = reviewerIntroductions(REPO_NAMES); - expect(introductions.map((introduction) => introduction.handle)).toEqual( - CODE_REVIEW_REVIEWERS.map((reviewer) => reviewer.handle), - ); - }); - - test("every introduction names a picked repo and carries no internal detail", () => { - const introductions = reviewerIntroductions(REPO_NAMES); - for (const { handle, text } of introductions) { - expect(REPO_NAMES.some((name) => text.includes(name))).toBe(true); - expect(text).not.toContain("@"); - expect(text.toLowerCase()).not.toContain("http"); - expect(text).not.toContain(handle); - } - }); - - test("names the single picked repo when only one is selected", () => { - const introductions = reviewerIntroductions(["acme/widgets"]); - for (const { text } of introductions) { - expect(text).toContain("acme/widgets"); - } - }); -}); diff --git a/packages/code-review/src/introductions.ts b/packages/code-review/src/introductions.ts deleted file mode 100644 index 134ead986d..0000000000 --- a/packages/code-review/src/introductions.ts +++ /dev/null @@ -1,21 +0,0 @@ -// The canned copy each reviewer posts once repos are picked and it -// starts reviewing — the identity beat of the first minute. Pure -// formatting over `./reviewers.ts`'s own `introduction` templates; no -// inference runs to produce these, exactly like `@corbits/chat`'s -// `postCannedGreeting`. -import { CODE_REVIEW_REVIEWERS } from "./reviewers"; - -export interface ReviewerIntroduction { - readonly handle: string; - readonly text: string; -} - -/** One introduction per reviewer, in `CODE_REVIEW_REVIEWERS` order. */ -export function reviewerIntroductions( - repoNames: readonly string[], -): readonly ReviewerIntroduction[] { - return CODE_REVIEW_REVIEWERS.map((reviewer) => ({ - handle: reviewer.handle, - text: reviewer.introduction(repoNames), - })); -} diff --git a/packages/code-review/src/prompt.test.ts b/packages/code-review/src/prompt.test.ts deleted file mode 100644 index 27799b75e0..0000000000 --- a/packages/code-review/src/prompt.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { expect, test } from "bun:test"; -import type { PullRequestDiff, PullRequestFileDiff } from "@corbits/github-tools"; - -import { renderReviewPrompt } from "./prompt"; - -function file(path: string, patchChars: number): PullRequestFileDiff { - return { - path, - status: "modified", - additions: 1, - deletions: 0, - patch: `@@ -1,1 +1,2 @@\n${"+x".repeat(patchChars / 2)}`, - changedLines: [1], - }; -} - -function diffOf(files: readonly PullRequestFileDiff[]): PullRequestDiff { - return { - ref: { owner: "acme", repo: "widgets", number: 7 }, - title: "Big change", - description: "", - url: "https://github.com/acme/widgets/pull/7", - author: "octocat", - headSha: "headsha", - baseSha: "basesha", - files, - truncated: false, - }; -} - -test("a pull request with no description says so instead of leaving a gap", () => { - const prompt = renderReviewPrompt(diffOf([file("a.ts", 10)])); - expect(prompt).toContain("(none given)"); - expect(prompt).toContain("a.ts"); -}); - -test("an oversized patch is truncated and says it was", () => { - const prompt = renderReviewPrompt(diffOf([file("a.ts", 40000)])); - expect(prompt).toContain("patch truncated"); -}); - -test("a change too large for one turn names the files it left out", () => { - const many = Array.from({ length: 40 }, (_unused, index) => - file(`file-${String(index)}.ts`, 8000), - ); - const prompt = renderReviewPrompt(diffOf(many)); - expect(prompt.length).toBeLessThan(200000); - expect(prompt).toContain("Not shown to you"); - expect(prompt).toContain("file-39.ts"); -}); diff --git a/packages/code-review/src/prompt.ts b/packages/code-review/src/prompt.ts deleted file mode 100644 index e1679b0192..0000000000 --- a/packages/code-review/src/prompt.ts +++ /dev/null @@ -1,61 +0,0 @@ -// The one turn input every reviewer sees: the pull request's title, -// description, and per-file patches. -// -// Two budgets, because a real pull request blows past a turn otherwise — -// a 100-file change renders half a million characters of patch. Each -// patch is capped on its own, and the prompt stops adding files once the -// total budget is spent. Both cuts are stated in the prompt: a reviewer -// that was shown part of a change must know that, or it will report a -// gap it never looked at as if the change had none. -import type { PullRequestDiff } from "@corbits/github-tools"; - -const MAX_PATCH_CHARS = 12000; -const MAX_TOTAL_PATCH_CHARS = 120000; - -function renderPatch(patch: string): string { - if (patch.length <= MAX_PATCH_CHARS) return patch; - return ( - `${patch.slice(0, MAX_PATCH_CHARS)}\n` + - "... patch truncated; review what is shown and say that the rest " + - "was not shown to you." - ); -} - -/** Renders the review turn's input for one pull request. */ -export function renderReviewPrompt(diff: PullRequestDiff): string { - const header = [ - `Pull request: ${diff.title}`, - `URL: ${diff.url}`, - `Files changed: ${String(diff.files.length)}`, - "", - "Description:", - diff.description.trim().length === 0 ? "(none given)" : diff.description.trim(), - "", - ].join("\n"); - - const rendered: string[] = []; - const omitted: string[] = []; - let spent = 0; - for (const file of diff.files) { - const heading = - `--- ${file.path} (${file.status}, +${String(file.additions)} ` + - `-${String(file.deletions)})`; - if (spent >= MAX_TOTAL_PATCH_CHARS) { - omitted.push(file.path); - continue; - } - const body = - file.patch === undefined ? "(no patch available for this file)" : renderPatch(file.patch); - spent += body.length; - rendered.push(`${heading}\n${body}`); - } - - const tail = - omitted.length === 0 - ? "" - : `\nNot shown to you (the change is too large for one turn): ` + - `${omitted.join(", ")}. Say that these files were not shown ` + - "rather than reviewing them unseen.\n"; - - return `${header}Diff:\n${rendered.join("\n\n")}\n${tail}`; -} diff --git a/packages/code-review/src/report.test.ts b/packages/code-review/src/report.test.ts deleted file mode 100644 index 25092b8f0b..0000000000 --- a/packages/code-review/src/report.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { expect, test } from "bun:test"; - -import { parseReviewerReport } from "./report"; - -test("parses a bare JSON report", () => { - const parsed = parseReviewerReport( - '{"summary":"read the diff","findings":[{"severity":"blocking",' + - '"file":"src/loop.ts","line":2,"summary":"drops the second pass"}]}', - ); - if (!parsed.ok) throw new Error(parsed.reason); - expect(parsed.report.findings[0]?.severity).toBe("blocking"); - expect(parsed.report.findings[0]?.line).toBe(2); -}); - -test("parses a report inside a JSON code fence", () => { - const parsed = parseReviewerReport('```json\n{"summary":"fine","findings":[]}\n```'); - if (!parsed.ok) throw new Error(parsed.reason); - expect(parsed.report.findings).toEqual([]); -}); - -test("an empty findings list is a real report, not a failure", () => { - const parsed = parseReviewerReport('{"summary":"genuinely fine","findings":[]}'); - expect(parsed.ok).toBe(true); -}); - -test("prose instead of JSON comes back as a named failure", () => { - const parsed = parseReviewerReport("Looks good to me!"); - expect(parsed).toEqual({ ok: false, reason: "the reply was not JSON" }); -}); - -test("an empty reply comes back as a named failure", () => { - expect(parseReviewerReport(" ")).toEqual({ - ok: false, - reason: "the reply was empty", - }); -}); - -test("a wrong severity is rejected rather than coerced", () => { - const parsed = parseReviewerReport( - '{"summary":"x","findings":[{"severity":"nit","file":"a.ts",' + '"summary":"style"}]}', - ); - expect(parsed.ok).toBe(false); -}); - -test("existingCode and suggestedFix parse as a before/after code pair", () => { - const parsed = parseReviewerReport( - '{"summary":"x","findings":[{"severity":"blocking","file":"a.ts",' + - '"line":2,"summary":"off by one","existingCode":"for (let i = 0; ' + - 'i <= n; i++) {","suggestedFix":"for (let i = 0; i < n; i++) {"}]}', - ); - if (!parsed.ok) throw new Error(parsed.reason); - expect(parsed.report.findings[0]?.existingCode).toBe("for (let i = 0; i <= n; i++) {"); - expect(parsed.report.findings[0]?.suggestedFix).toBe("for (let i = 0; i < n; i++) {"); -}); diff --git a/packages/code-review/src/report.ts b/packages/code-review/src/report.ts deleted file mode 100644 index d25c1701ec..0000000000 --- a/packages/code-review/src/report.ts +++ /dev/null @@ -1,61 +0,0 @@ -// A reviewer's reply is model output: untrusted text at a trust -// boundary, parsed with arktype and never asserted into shape. A reply -// that does not parse is not coerced into an empty report — it comes -// back as a named failure so the posted review can say honestly that -// this reviewer did not report, instead of quietly claiming it found -// nothing. -import { type } from "arktype"; - -const ReviewerFinding = type({ - severity: '"blocking" | "should-fix" | "later"', - file: "string > 0", - "line?": "number > 0", - summary: "string > 0", - /** The exact lines from the diff a suggestedFix would replace. */ - "existingCode?": "string > 0", - /** The literal replacement lines — never instructions or prose. */ - "suggestedFix?": "string > 0", -}); - -const ReviewerReport = type({ - summary: "string", - findings: ReviewerFinding.array(), -}); - -export type ReviewerFinding = typeof ReviewerFinding.infer; -export type ReviewerReport = typeof ReviewerReport.infer; - -export type ParsedReviewerReport = - | { readonly ok: true; readonly report: ReviewerReport } - | { readonly ok: false; readonly reason: string }; - -const FENCED_JSON = /^```(?:json)?\s*([\s\S]*?)```$/; - -function unwrapFence(raw: string): string { - const trimmed = raw.trim(); - const fenced = FENCED_JSON.exec(trimmed); - return fenced?.[1]?.trim() ?? trimmed; -} - -/** - * Parses one reviewer's reply into its report. Tolerates a JSON code - * fence around the object — models add one often enough that rejecting - * it would throw away real findings — but nothing beyond that. - */ -export function parseReviewerReport(raw: string): ParsedReviewerReport { - const body = unwrapFence(raw); - if (body.length === 0) { - return { ok: false, reason: "the reply was empty" }; - } - let decoded: unknown; - try { - decoded = JSON.parse(body); - } catch { - return { ok: false, reason: "the reply was not JSON" }; - } - const parsed = ReviewerReport(decoded); - if (parsed instanceof type.errors) { - return { ok: false, reason: parsed.summary }; - } - return { ok: true, report: parsed }; -} diff --git a/packages/code-review/src/review-run.test.ts b/packages/code-review/src/review-run.test.ts deleted file mode 100644 index a7c09ec064..0000000000 --- a/packages/code-review/src/review-run.test.ts +++ /dev/null @@ -1,255 +0,0 @@ -// The loop, with GitHub faked: one diff read, one turn per reviewer, -// one review posted at the head sha. -import { expect, test } from "bun:test"; -import type { - PullRequestDiff, - PullRequestRef, - PullRequestReviewDraft, -} from "@corbits/github-tools"; - -import { fingerprintMarker, fingerprintOf } from "./fingerprint"; -import { CODE_REVIEW_REVIEWERS } from "./reviewers"; -import { runPullRequestReview, type CodeReviewGitHub } from "./review-run"; - -const REF: PullRequestRef = { owner: "acme", repo: "widgets", number: 7 }; - -const DIFF: PullRequestDiff = { - ref: REF, - title: "Add the review loop", - description: "Closes the loop.", - url: "https://github.com/acme/widgets/pull/7", - author: "octocat", - headSha: "headsha", - baseSha: "basesha", - files: [ - { - path: "src/loop.ts", - status: "modified", - additions: 1, - deletions: 0, - patch: "@@ -1,1 +1,2 @@\n context\n+added", - changedLines: [1, 2], - }, - ], - truncated: false, -}; - -interface FakeGitHub { - readonly client: CodeReviewGitHub; - readonly posted: { - ref: PullRequestRef; - headSha: string; - review: PullRequestReviewDraft; - }[]; - readonly diffReads: PullRequestRef[]; - readonly listedComments: PullRequestRef[]; -} - -function fakeGitHub( - diff: PullRequestDiff = DIFF, - postedComments: readonly string[] = [], - commentsTruncated = false, -): FakeGitHub { - const posted: FakeGitHub["posted"][number][] = []; - const diffReads: PullRequestRef[] = []; - const listedComments: PullRequestRef[] = []; - return { - posted, - diffReads, - listedComments, - client: { - fetchDiff: (ref) => { - diffReads.push(ref); - return Promise.resolve(diff); - }, - postReview: (ref, headSha, review) => { - posted.push({ ref, headSha, review }); - return Promise.resolve({ - id: 1, - url: "https://github.com/acme/widgets/pull/7#review", - }); - }, - listPostedComments: (ref) => { - listedComments.push(ref); - return Promise.resolve({ - comments: postedComments, - truncated: commentsTruncated, - }); - }, - }, - }; -} - -function reportFor(reviewerId: string): string { - return JSON.stringify({ - summary: `${reviewerId} read the diff`, - findings: [ - { - severity: "should-fix", - file: "src/loop.ts", - line: 2, - summary: `${reviewerId} finding`, - }, - ], - }); -} - -test("every reviewer sees the same diff and the review is posted once", async () => { - const github = fakeGitHub(); - const prompts: string[] = []; - const seen: string[] = []; - - const result = await runPullRequestReview( - { - github: github.client, - runReviewerTurn: ({ reviewer, prompt }) => { - seen.push(reviewer.id); - prompts.push(prompt); - return Promise.resolve(reportFor(reviewer.id)); - }, - }, - REF, - ); - - expect(github.diffReads).toEqual([REF]); - expect(seen.sort()).toEqual(CODE_REVIEW_REVIEWERS.map((reviewer) => reviewer.id).sort()); - expect(new Set(prompts).size).toBe(1); - expect(prompts[0]).toContain("src/loop.ts"); - expect(github.posted.length).toBe(1); - expect(github.posted[0]?.headSha).toBe("headsha"); - for (const reviewer of CODE_REVIEW_REVIEWERS) { - expect(github.posted[0]?.review.body).toContain(`${reviewer.id} finding`); - } - if (result.skipped) throw new Error("expected the review to run"); - expect(result.posted.id).toBe(1); -}); - -test("one reviewer failing still posts a review that names the gap", async () => { - const github = fakeGitHub(); - const result = await runPullRequestReview( - { - github: github.client, - runReviewerTurn: ({ reviewer }) => - reviewer.id === "release-risk" - ? Promise.reject(new Error("inference timed out")) - : Promise.resolve(reportFor(reviewer.id)), - }, - REF, - ); - - expect(github.posted.length).toBe(1); - if (result.skipped) throw new Error("expected the review to run"); - expect(result.review.body).toContain("Reviewers that did not report"); - expect(result.review.body).toContain("inference timed out"); - expect(result.review.body).toContain("correctness finding"); -}); - -test("a diff that cannot be read means no review is posted", async () => { - const github = fakeGitHub(); - await expect( - runPullRequestReview( - { - github: { - fetchDiff: () => Promise.reject(new Error("404 not found")), - postReview: github.client.postReview, - listPostedComments: github.client.listPostedComments, - }, - runReviewerTurn: () => Promise.resolve(reportFor("correctness")), - }, - REF, - ), - ).rejects.toThrow(/404 not found/); - expect(github.posted.length).toBe(0); -}); - -test("a review needs at least one reviewer", async () => { - const github = fakeGitHub(); - await expect( - runPullRequestReview( - { - github: github.client, - runReviewerTurn: () => Promise.resolve("{}"), - reviewers: [], - }, - REF, - ), - ).rejects.toThrow(/at least one reviewer/); -}); - -test("a bot author is skipped before any inference or posting", async () => { - const github = fakeGitHub({ ...DIFF, author: "dependabot[bot]" }); - const turns: string[] = []; - - const result = await runPullRequestReview( - { - github: github.client, - runReviewerTurn: ({ reviewer }) => { - turns.push(reviewer.id); - return Promise.resolve(reportFor(reviewer.id)); - }, - }, - REF, - ); - - expect(result).toEqual({ - skipped: true, - reason: expect.stringContaining("dependabot[bot]") as unknown as string, - }); - expect(turns).toEqual([]); - expect(github.posted.length).toBe(0); - expect(github.listedComments).toEqual([]); -}); - -test("a finding whose fingerprint was already posted is not raised again", async () => { - const alreadyPosted = fingerprintOf({ - severity: "should-fix", - file: "src/loop.ts", - line: 2, - summary: "architecture finding", - }); - const github = fakeGitHub(DIFF, [fingerprintMarker(alreadyPosted)]); - - const result = await runPullRequestReview( - { - github: github.client, - runReviewerTurn: ({ reviewer }) => Promise.resolve(reportFor(reviewer.id)), - }, - REF, - ); - - expect(github.listedComments).toEqual([REF]); - if (result.skipped) throw new Error("expected the review to run"); - expect(result.review.body).toContain("correctness finding"); - expect(result.review.body).toContain("release-risk finding"); - expect(result.review.body).not.toContain("architecture finding"); -}); - -test("a truncated diff surfaces an incompleteness note in the posted review", async () => { - const github = fakeGitHub({ ...DIFF, truncated: true }); - - const result = await runPullRequestReview( - { - github: github.client, - runReviewerTurn: ({ reviewer }) => Promise.resolve(reportFor(reviewer.id)), - }, - REF, - ); - - if (result.skipped) throw new Error("expected the review to run"); - expect(result.review.body).toContain("This review may be incomplete"); -}); - -test("a truncated already-posted-comments page surfaces the same note", async () => { - const github = fakeGitHub(DIFF, [], true); - - const result = await runPullRequestReview( - { - github: github.client, - runReviewerTurn: ({ reviewer }) => Promise.resolve(reportFor(reviewer.id)), - }, - REF, - ); - - if (result.skipped) throw new Error("expected the review to run"); - expect(result.review.body).toContain("This review may be incomplete"); -}); diff --git a/packages/code-review/src/review-run.ts b/packages/code-review/src/review-run.ts deleted file mode 100644 index fe9ff05baf..0000000000 --- a/packages/code-review/src/review-run.ts +++ /dev/null @@ -1,129 +0,0 @@ -// The loop: fetch the pull request's diff once, run every reviewer over -// it, combine the passes into one review, post it. GitHub reach and the -// reviewer turn itself are seams — the host that owns inference and the -// connection's credential supplies them — so this module is the whole -// mechanism and nothing in it needs a network to be tested. -// -// Posting is not gated: a posted review is a comment, and a comment is -// what the standing grant on the connection already covers. Nothing here -// approves, requests changes, or merges. -// -// One reviewer failing does not fail the review. Its pass is recorded as -// "did not report" and the posted review names it, which is honest about -// coverage; dropping it silently would let a review read as complete -// when it was not. -// -// A run on a bot's own pull request is skipped outright, before any -// inference or posting happens — the bot-loop guard the workflow entry -// needs so a bot pushing a change never sets off a review that something -// downstream reacts to, which pushes again. -import type { - PostedPullRequestReview, - PullRequestDiff, - PullRequestRef, - PullRequestReviewCommentsPage, - PullRequestReviewDraft, -} from "@corbits/github-tools"; - -import { aggregateReview, type ReviewerPass } from "./aggregate"; -import { isBotAuthor } from "./bot-guard"; -import { fingerprintsIn } from "./fingerprint"; -import { renderReviewPrompt } from "./prompt"; -import { CODE_REVIEW_REVIEWERS, reviewerReportPrompt, type ReviewerDefinition } from "./reviewers"; - -/** The GitHub reach a review run needs, under the connection's credential. */ -export interface CodeReviewGitHub { - fetchDiff(ref: PullRequestRef): Promise; - postReview( - ref: PullRequestRef, - headSha: string, - review: PullRequestReviewDraft, - ): Promise; - /** Bodies of every review comment already posted, for the fingerprint scan. */ - listPostedComments(ref: PullRequestRef): Promise; -} - -/** Runs one reviewer's turn and returns its raw reply. `systemPrompt` - * is the reviewer's lens *plus* the JSON report contract this run parses - * back — a host must use it rather than `reviewer.systemPrompt`, which - * carries the lens alone so the same reviewer can answer a person in - * prose. */ -export type ReviewerTurn = (input: { - readonly reviewer: ReviewerDefinition; - readonly systemPrompt: string; - readonly prompt: string; -}) => Promise; - -export interface RunPullRequestReviewDeps { - readonly github: CodeReviewGitHub; - readonly runReviewerTurn: ReviewerTurn; - /** Defaults to the full roster; narrowed in tests and in a smoke run. */ - readonly reviewers?: readonly ReviewerDefinition[]; -} - -export interface PullRequestReviewRun { - readonly skipped: false; - readonly diff: PullRequestDiff; - readonly passes: readonly ReviewerPass[]; - readonly review: PullRequestReviewDraft; - readonly posted: PostedPullRequestReview; -} - -/** A run skipped before any inference or posting happened, and why. */ -export interface PullRequestReviewSkipped { - readonly skipped: true; - readonly reason: string; -} - -export type PullRequestReviewResult = PullRequestReviewRun | PullRequestReviewSkipped; - -function reasonOf(err: unknown): string { - return err instanceof Error ? err.message : String(err); -} - -async function runOne( - deps: RunPullRequestReviewDeps, - reviewer: ReviewerDefinition, - prompt: string, -): Promise { - try { - const reply = await deps.runReviewerTurn({ - reviewer, - systemPrompt: reviewerReportPrompt(reviewer), - prompt, - }); - return { reviewer, ok: true, reply }; - } catch (err) { - return { reviewer, ok: false, reason: reasonOf(err) }; - } -} - -/** - * Reviews one pull request end to end and posts the result. Throws only - * when the diff cannot be read or the review cannot be posted — the two - * failures that mean no review happened at all. Returns `skipped: true`, - * with no inference and no post, when the author reads as a bot. - */ -export async function runPullRequestReview( - deps: RunPullRequestReviewDeps, - ref: PullRequestRef, -): Promise { - const reviewers = deps.reviewers ?? CODE_REVIEW_REVIEWERS; - if (reviewers.length === 0) { - throw new Error("a pull-request review needs at least one reviewer"); - } - const diff = await deps.github.fetchDiff(ref); - if (isBotAuthor(diff.author)) { - return { - skipped: true, - reason: `"${diff.author}" reads as a bot author; skipping to avoid a review loop`, - }; - } - const prompt = renderReviewPrompt(diff); - const postedComments = await deps.github.listPostedComments(ref); - const alreadyPosted = fingerprintsIn(postedComments.comments); - const passes = await Promise.all(reviewers.map((reviewer) => runOne(deps, reviewer, prompt))); - const review = aggregateReview(passes, diff, alreadyPosted, postedComments.truncated); - const posted = await deps.github.postReview(ref, diff.headSha, review); - return { skipped: false, diff, passes, review, posted }; -} diff --git a/packages/code-review/src/reviewers.ts b/packages/code-review/src/reviewers.ts deleted file mode 100644 index 84dd165509..0000000000 --- a/packages/code-review/src/reviewers.ts +++ /dev/null @@ -1,184 +0,0 @@ -// The three reviewer definitions a pull-request review fans out to. -// Each one is a lens, not a whole opinion: architecture judges whether -// the shape is sound, correctness hunts defects with receipts, and -// release risk says what actually blocks shipping. Their prompts are -// adapted from the reviewer personas the team already runs by hand, so -// a review posted here reads the way the team's own reviews read. -// -// A definition here is plain data: an id, a handle, a display name, and -// a system prompt. That is exactly what the agent-directory create path -// takes, so the same three definitions can be installed as agents in a -// workbench and driven by this package's own review run. - -/** - * The JSON contract a reviewer replies under **when a machine parses the - * reply back** — `./review-run.ts`'s pass runner, and nothing else. - * - * It is deliberately not part of `ReviewerDefinition.systemPrompt`. The - * same prompts install these reviewers as ordinary chat agents - * (`./agent-requests.ts`), and a reviewer carrying this contract answers - * a person in the room with `{"summary": ..., "findings": []}` instead of - * a sentence. Append it with `reviewerReportPrompt` on the one - * path that reads JSON back. - */ -export const REVIEWER_REPORT_CONTRACT = - "Reply with JSON and nothing else — no prose before or after, no code " + - 'fence. Shape: {"summary": string, "findings": [{"severity": ' + - '"blocking" | "should-fix" | "later", "file": string, "line": number, ' + - '"summary": string, "existingCode": string, "suggestedFix": string}]}. ' + - "`summary` is one or two sentences on what you looked at and what you " + - "concluded. Each finding names the file it is about; include `line` " + - "only when you can point at a specific right-hand line in the diff.\n\n" + - "Report at most 5 findings — the ones that most matter. If you have " + - "more, keep the 5 most important and leave the rest unsaid rather than " + - "padding the report.\n\n" + - 'Triage every finding honestly. `"blocking"` means the change should ' + - "not ship as-is: a defect, a broken invariant, a security or data-loss " + - 'risk, or a signature drift a caller depends on. `"should-fix"` is a ' + - 'real problem that does not have to block this merge. "later" is a ' + - "good idea with no urgency. Do not mark something blocking to make it " + - "more likely to get read — an honest severity is worth more than a " + - "louder one.\n\n" + - "Skip typos, docstring wording, import ordering, and formatting or " + - "style nits entirely — do not report them even as `later`. They are " + - "noise a person filters out on sight, so leaving them out is the " + - "correct answer.\n\n" + - "`existingCode` and `suggestedFix` are optional and only make sense " + - "together: `existingCode` is the exact lines quoted verbatim from the " + - "diff that `suggestedFix` replaces, so it can be checked against the " + - "diff before anything is posted. `suggestedFix` is the literal " + - "replacement lines, never instructions or prose — if you cannot write " + - "the actual replacement code, leave both out and say what is wrong in " + - "`summary` instead. An empty `findings` list is a real answer when the " + - "change is genuinely fine — say so in `summary` rather than inventing " + - "something to report."; - -export interface ReviewerDefinition { - /** Stable id used in aggregation, attribution, and step naming. */ - readonly id: string; - /** Handle an installed agent is created under. */ - readonly handle: string; - /** What a person sees next to this reviewer's findings. */ - readonly displayName: string; - readonly description: string; - readonly systemPrompt: string; - /** The canned message this reviewer posts, in its own voice, once - * repos are picked and it starts reviewing — who it is and what it - * will do for these repos. Never run through inference; see - * `./introductions.ts`'s `reviewerIntroductions`. */ - readonly introduction: (repoNames: readonly string[]) => string; -} - -/** Renders a list of repo names the way a sentence names them: - * "widgets", "widgets and gadgets", or "widgets, gadgets, and sprockets". */ -function namedRepos(repoNames: readonly string[]): string { - if (repoNames.length === 0) return "these repos"; - const [first, ...rest] = repoNames; - if (first === undefined) return "these repos"; - if (rest.length === 0) return first; - const last = rest[rest.length - 1]; - if (rest.length === 1) return `${first} and ${last}`; - const middle = [first, ...rest.slice(0, -1)].join(", "); - return `${middle}, and ${last}`; -} - -const ARCHITECTURE_REVIEWER: ReviewerDefinition = { - id: "architecture", - handle: "architecture-reviewer", - displayName: "Architecture reviewer", - description: - "Judges whether a change's shape is sound: invariants, where a " + - "constraint is owned, and what it costs to live with later", - systemPrompt: - "You review a pull request for architectural soundness.\n\n" + - "Judge soundness, constraint ownership, and what this change costs " + - "to live with. You never write product code and you never rewrite " + - "the change — your value is the judgment, not the legwork.\n\n" + - "Look for: architectural holes and missing invariants; a constraint " + - "fixed at the wrong layer (symptom-chasing instead of ownership); " + - "backward-compatibility and maintainability consequences; drift " + - "between what the change says it does and what it does; and " + - "duplication that should have been a refactor or an extension of an " + - "existing interface.\n\n" + - "Out of lane: style-only nitpicking, and speculative redesigns of " + - "code this change did not touch.", - introduction: (repoNames) => - `I'm the architecture reviewer. I'll read every pull request on ` + - `${namedRepos(repoNames)} for whether the shape holds up: the ` + - `invariants, where a constraint should live, and what it costs to ` + - `maintain later.`, -}; - -const CORRECTNESS_REVIEWER: ReviewerDefinition = { - id: "correctness", - handle: "correctness-reviewer", - displayName: "Correctness reviewer", - description: - "Finds defects with receipts: the file, the line, and the input " + "that makes it go wrong", - systemPrompt: - "You review a pull request for defects, with evidence.\n\n" + - "Find defects; never rewrite the code. Every claim names the file " + - "and the line or symbol, what breaks, and the concrete input or " + - "sequence that triggers it. A claim you cannot anchor that way is " + - "not a finding yet — leave it out.\n\n" + - 'Rank what you find: blocking, should-fix, later. "This is ' + - 'genuinely fine" is a real answer when it is true. Say plainly what ' + - "you did not cover, so nobody reads your report as the whole " + - "story.\n\n" + - "Correctness only. Flag gaps that affect correctness or the stated " + - "requirements. Style preferences and speculative abstractions are " + - "`later` at most. Never push extra layers, defensive code for cases " + - "that cannot happen, or tests for inputs that cannot occur. A " + - "signature that drifted from what callers expect — a value that " + - "became a promise, a changed parameter order, a return type that " + - "narrowed — is blocking.", - introduction: (repoNames) => - `I'm the correctness reviewer. I'll read every pull request opened ` + - `on ${namedRepos(repoNames)} for defects, with the file, the line, ` + - `and the input that trips them.`, -}; - -const RELEASE_RISK_REVIEWER: ReviewerDefinition = { - id: "release-risk", - handle: "release-risk-reviewer", - displayName: "Release-risk reviewer", - description: - "Says what actually blocks shipping, what ships with a note, and " + "what is filed for later", - systemPrompt: - "You advise on the risk of shipping this pull request.\n\n" + - "You are counsel, not a gate. You do not write product code, and " + - "you do not repeat the architecture or correctness reviews. Given " + - "the change in front of you: what actually blocks a release, what " + - "ships with a note, and what is filed for later.\n\n" + - "Say what the team is most likely getting wrong that nobody else " + - 'would raise. Say "do not ship" plainly when you mean it — an early ' + - "no is worth more than a late surprise. Sequencing, rollout order, " + - "and what has to be true before this lands are yours to raise.", - introduction: (repoNames) => - `I'm the release-risk reviewer. I'll weigh in on pull requests to ` + - `${namedRepos(repoNames)}, saying plainly what actually blocks ` + - `shipping, what ships with a note, and what can wait.`, -}; - -/** The roster a review fans out to, in the order findings are reported. */ -export const CODE_REVIEW_REVIEWERS: readonly ReviewerDefinition[] = [ - CORRECTNESS_REVIEWER, - ARCHITECTURE_REVIEWER, - RELEASE_RISK_REVIEWER, -]; - -/** This reviewer's lens plus the JSON contract — the system prompt for - * a turn whose reply is parsed, never the one an installed chat agent - * answers a person under. */ -export function reviewerReportPrompt(reviewer: ReviewerDefinition): string { - return `${reviewer.systemPrompt}\n\n${REVIEWER_REPORT_CONTRACT}`; -} - -/** Looks a reviewer up by id; an unknown id is a named error. */ -export function reviewerById(id: string): ReviewerDefinition { - const found = CODE_REVIEW_REVIEWERS.find((reviewer) => reviewer.id === id); - if (found === undefined) { - throw new Error(`no code reviewer with id "${id}"`); - } - return found; -} diff --git a/packages/code-review/tsconfig.json b/packages/code-review/tsconfig.json deleted file mode 100644 index e125e90614..0000000000 --- a/packages/code-review/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "./tsconfig.src.json", - "compilerOptions": { - "composite": false, - "noEmit": true, - "disableSourceOfProjectReferenceRedirect": true, - "declaration": false, - "declarationMap": false, - "emitDeclarationOnly": false, - "tsBuildInfoFile": "tsconfig.tsbuildinfo", - "rootDir": "../.." - }, - "include": ["src"], - "exclude": [] -} diff --git a/packages/code-review/tsconfig.src.json b/packages/code-review/tsconfig.src.json deleted file mode 100644 index fc8724305f..0000000000 --- a/packages/code-review/tsconfig.src.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "include": ["src", "package.json", "src/**/*.json"], - "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"], - "compilerOptions": { - "types": ["bun"], - "composite": true, - "emitDeclarationOnly": true, - "outDir": "dist", - "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" - } -} diff --git a/packages/tool-registry-publish/src/registry.ts b/packages/tool-registry-publish/src/registry.ts index d1cc5c93a3..7e1d01d4c9 100644 --- a/packages/tool-registry-publish/src/registry.ts +++ b/packages/tool-registry-publish/src/registry.ts @@ -62,9 +62,5 @@ export const CORBITS_TOOL_PACKAGE_DIRS: readonly string[] = [ new URL("../../../tools/access", import.meta.url).pathname, new URL("../../../tools/interaction", import.meta.url).pathname, new URL("../../../tools/skills", import.meta.url).pathname, - new URL("../../../tools/github", import.meta.url).pathname, - new URL("../../../tools/web-search", import.meta.url).pathname, - new URL("../../../tools/granola", import.meta.url).pathname, - new URL("../../../tools/linear", import.meta.url).pathname, new URL("../../../tools/workflow-authoring", import.meta.url).pathname, ]; diff --git a/tools/capability/README.md b/tools/capability/README.md index 2bc607e101..7456900d12 100644 --- a/tools/capability/README.md +++ b/tools/capability/README.md @@ -18,7 +18,7 @@ the addition happens. On approval, the execution calls the workflow-run capabilities surface for the calling agent's own definition. Success comes back as a plain -result message ("Added @corbits/github-tools — I can use it from my +result message ("Added @corbits/example-tools — I can use it from my next reply."); an out-of-inventory request comes back naming what's actually available, never a fabricated success. Any other transport or HTTP failure comes back as an honest `isError: true` result. diff --git a/tools/capability/src/client.test.ts b/tools/capability/src/client.test.ts index 2cfa4e3da2..3819c8e122 100644 --- a/tools/capability/src/client.test.ts +++ b/tools/capability/src/client.test.ts @@ -27,7 +27,7 @@ test("addCapability posts to the definition's workflow-run capabilities endpoint seenBody = JSON.parse(String(init?.body)); return new Response( JSON.stringify({ - toolPackagePins: [{ name: "@corbits/github-tools" }], + toolPackagePins: [{ name: "@corbits/example-tools" }], skills: [], }), ); @@ -35,7 +35,7 @@ test("addCapability posts to the definition's workflow-run capabilities endpoint const result = await addCapability(testConfig(fetchImpl), { kind: "toolPackage", - name: "@corbits/github-tools", + name: "@corbits/example-tools", }); expect(seenUrl).toBe("https://hub.example.com/api/workflow-capabilities/def_1/capabilities"); @@ -43,9 +43,9 @@ test("addCapability posts to the definition's workflow-run capabilities endpoint expect(seenHeaders?.["x-workflow-run-address"]).toBe("run_1@workflow"); expect(seenBody).toEqual({ kind: "toolPackage", - name: "@corbits/github-tools", + name: "@corbits/example-tools", }); - expect(result.toolPackagePins).toEqual([{ name: "@corbits/github-tools" }]); + expect(result.toolPackagePins).toEqual([{ name: "@corbits/example-tools" }]); }); test("addCapability throws CapabilityOutOfInventoryError on the route's fail-closed 400", async () => { diff --git a/tools/capability/src/tool.test.ts b/tools/capability/src/tool.test.ts index 25a8d65ba2..1a7f0f2723 100644 --- a/tools/capability/src/tool.test.ts +++ b/tools/capability/src/tool.test.ts @@ -54,7 +54,7 @@ test("the tool's description reads as a humane approval-card headline", () => { test("rejects a call missing a required field without calling out", async () => { const bundle = capabilityTools(testEnv()); const result = await bundle.run( - callFor({ kind: "tool-package", name: "@corbits/github-tools" }), + callFor({ kind: "tool-package", name: "@corbits/example-tools" }), new AbortController().signal, ); expect(result.isError).toBe(true); @@ -77,7 +77,7 @@ test("on approval, calls the capabilities route with the calling agent's own def seenUrl = String(url); return new Response( JSON.stringify({ - toolPackagePins: [{ name: "@corbits/github-tools" }], + toolPackagePins: [{ name: "@corbits/example-tools" }], skills: [], }), ); @@ -87,15 +87,15 @@ test("on approval, calls the capabilities route with the calling agent's own def const result = await bundle.run( callFor({ kind: "tool-package", - name: "@corbits/github-tools", - why: "I need to open a pull request", - title: "GitHub tools", + name: "@corbits/example-tools", + why: "I need it", + title: "Example tools", }), new AbortController().signal, ); expect(seenUrl).toBe("https://hub.example.com/api/workflow-capabilities/def_1/capabilities"); expect(result.isError).toBeFalsy(); - expect(result.content).toBe("Added @corbits/github-tools — I can use it from my next reply."); + expect(result.content).toBe("Added @corbits/example-tools — I can use it from my next reply."); } finally { globalThis.fetch = originalFetch; } diff --git a/tools/github/LICENSE b/tools/github/LICENSE deleted file mode 100644 index 173c3a21d0..0000000000 --- a/tools/github/LICENSE +++ /dev/null @@ -1,176 +0,0 @@ -GNU LESSER GENERAL PUBLIC LICENSE - -Version 2.1, February 1999 - -Copyright (C) 1991, 1999 Free Software Foundation, Inc. -51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - -Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. - -[This is the first released version of the Lesser GPL. It also counts as the successor of the GNU Library Public License, version 2, hence the version number 2.1.] - -Preamble - -The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public Licenses are intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. - -This license, the Lesser General Public License, applies to some specially designated software packages--typically libraries--of the Free Software Foundation and other authors who decide to use it. You can use it too, but we suggest you first think carefully about whether this license or the ordinary General Public License is the better strategy to use in any particular case, based on the explanations below. - -When we speak of free software, we are referring to freedom of use, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish); that you receive source code or can get it if you want it; that you can change the software and use pieces of it in new free programs; and that you are informed that you can do these things. - -To protect your rights, we need to make restrictions that forbid distributors to deny you these rights or to ask you to surrender these rights. These restrictions translate to certain responsibilities for you if you distribute copies of the library or if you modify it. - -For example, if you distribute copies of the library, whether gratis or for a fee, you must give the recipients all the rights that we gave you. You must make sure that they, too, receive or can get the source code. If you link other code with the library, you must provide complete object files to the recipients, so that they can relink them with the library after making changes to the library and recompiling it. And you must show them these terms so they know their rights. - -We protect your rights with a two-step method: (1) we copyright the library, and (2) we offer you this license, which gives you legal permission to copy, distribute and/or modify the library. - -To protect each distributor, we want to make it very clear that there is no warranty for the free library. Also, if the library is modified by someone else and passed on, the recipients should know that what they have is not the original version, so that the original author's reputation will not be affected by problems that might be introduced by others. - -Finally, software patents pose a constant threat to the existence of any free program. We wish to make sure that a company cannot effectively restrict the users of a free program by obtaining a restrictive license from a patent holder. Therefore, we insist that any patent license obtained for a version of the library must be consistent with the full freedom of use specified in this license. - -Most GNU software, including some libraries, is covered by the ordinary GNU General Public License. This license, the GNU Lesser General Public License, applies to certain designated libraries, and is quite different from the ordinary General Public License. We use this license for certain libraries in order to permit linking those libraries into non-free programs. - -When a program is linked with a library, whether statically or using a shared library, the combination of the two is legally speaking a combined work, a derivative of the original library. The ordinary General Public License therefore permits such linking only if the entire combination fits its criteria of freedom. The Lesser General Public License permits more lax criteria for linking other code with the library. - -We call this license the "Lesser" General Public License because it does Less to protect the user's freedom than the ordinary General Public License. It also provides other free software developers Less of an advantage over competing non-free programs. These disadvantages are the reason we use the ordinary General Public License for many libraries. However, the Lesser license provides advantages in certain special circumstances. - -For example, on rare occasions, there may be a special need to encourage the widest possible use of a certain library, so that it becomes a de-facto standard. To achieve this, non-free programs must be allowed to use the library. A more frequent case is that a free library does the same job as widely used non-free libraries. In this case, there is little to gain by limiting the free library to free software only, so we use the Lesser General Public License. - -In other cases, permission to use a particular library in non-free programs enables a greater number of people to use a large body of free software. For example, permission to use the GNU C Library in non-free programs enables many more people to use the whole GNU operating system, as well as its variant, the GNU/Linux operating system. - -Although the Lesser General Public License is Less protective of the users' freedom, it does ensure that the user of a program that is linked with the Library has the freedom and the wherewithal to run that program using a modified version of the Library. - -The precise terms and conditions for copying, distribution and modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, whereas the latter must be combined with the library in order to run. - -GNU LESSER GENERAL PUBLIC LICENSE -TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - -0. This License Agreement applies to any software library or other program which contains a notice placed by the copyright holder or other authorized party saying it may be distributed under the terms of this Lesser General Public License (also called "this License"). Each licensee is addressed as "you". - -A "library" means a collection of software functions and/or data prepared so as to be conveniently linked with application programs (which use some of those functions and data) to form executables. - -The "Library", below, refers to any such software library or work which has been distributed under these terms. A "work based on the Library" means either the Library or any derivative work under copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with modifications and/or translated straightforwardly into another language. (Hereinafter, translation is included without limitation in the term "modification".) - -"Source code" for a work means the preferred form of the work for making modifications to it. For a library, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library. - -Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running a program using the Library is not restricted, and output from such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does. - -1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and distribute a copy of this License along with the Library. - -You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. - -2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: - - a) The modified work must itself be a software library. - - b) You must cause the files modified to carry prominent notices stating that you changed the files and the date of any change. - - c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms of this License. - - d) If a facility in the modified Library refers to a function or a table of data to be supplied by an application program that uses the facility, other than as an argument passed when the facility is invoked, then you must make a good faith effort to ensure that, in the event an application does not supply such function or table, the facility still operates, and performs whatever part of its purpose remains meaningful. - -(For example, a function in a library to compute square roots has a purpose that is entirely well-defined independent of the application. Therefore, Subsection 2d requires that any application-supplied function or table used by this function must be optional: if the application does not supply it, the square root function must still compute square roots.) - -These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Library, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Library, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. - -Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Library. - -In addition, mere aggregation of another work not based on the Library with the Library (or with a work based on the Library) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. - -3. You may opt to apply the terms of the ordinary GNU General Public License instead of this License to a given copy of the Library. To do this, you must alter all the notices that refer to this License, so that they refer to the ordinary GNU General Public License, version 2, instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. - -Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. - -This option is useful when you wish to copy part of the code of the Library into a program that is not a library. - -4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange. - -If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code, even though third parties are not compelled to copy the source along with the object code. - -5. A program that contains no derivative of any portion of the Library, but is designed to work with the Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in isolation, is not a derivative work of the Library, and therefore falls outside the scope of this License. - -However, linking a "work that uses the Library" with the Library creates an executable that is a derivative of the Library (because it contains portions of the Library), rather than a "work that uses the library". The executable is therefore covered by this License. Section 6 states terms for distribution of such executables. - -When a "work that uses the Library" uses material from a header file that is part of the Library, the object code for the work may be a derivative work of the Library even though the source code is not. Whether this is true is especially significant if the work can be linked without the Library, or if the work is itself a library. The threshold for this to be true is not precisely defined by law. - -If such an object file uses only numerical parameters, data structure layouts and accessors, and small macros and small inline functions (ten lines or less in length), then the use of the object file is unrestricted, regardless of whether it is legally a derivative work. (Executables containing this object code plus portions of the Library will still fall under Section 6.) - -Otherwise, if the work is a derivative of the Library, you may distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. - -6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work under terms of your choice, provided that the terms permit modification of the work for the customer's own use and reverse engineering for debugging such modifications. - -You must give prominent notice with each copy of the work that the Library is used in it and that the Library and its use are covered by this License. You must supply a copy of this License. If the work during execution displays copyright notices, you must include the copyright notice for the Library among them, as well as a reference directing the user to the copy of this License. Also, you must do one of these things: - - a) Accompany the work with the complete corresponding machine-readable source code for the Library including whatever changes were used in the work (which must be distributed under Sections 1 and 2 above); and, if the work is an executable linked with the Library, with the complete machine-readable "work that uses the Library", as object code and/or source code, so that the user can modify the Library and then relink to produce a modified executable containing the modified Library. (It is understood that the user who changes the contents of definitions files in the Library will not necessarily be able to recompile the application to use the modified definitions.) - - b) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (1) uses at run time a copy of the library already present on the user's computer system, rather than copying library functions into the executable, and (2) will operate properly with a modified version of the library, if the user installs one, as long as the modified version is interface-compatible with the version that the work was made with. - - c) Accompany the work with a written offer, valid for at least three years, to give the same user the materials specified in Subsection 6a, above, for a charge no more than the cost of performing this distribution. - - d) If distribution of the work is made by offering access to copy from a designated place, offer equivalent access to copy the above specified materials from the same place. - - e) Verify that the user has already received a copy of these materials or that you have already sent this user a copy. - -For an executable, the required form of the "work that uses the Library" must include any data and utility programs needed for reproducing the executable from it. However, as a special exception, the materials to be distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. - -It may happen that this requirement contradicts the license restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. - -7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined library, provided that the separate distribution of the work based on the Library and of the other library facilities is otherwise permitted, and provided that you do these two things: - - a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities. This must be distributed under the terms of the Sections above. - - b) Give prominent notice with the combined library of the fact that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. - -8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, link with, or distribute the Library is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. - -9. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Library or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Library (or any work based on the Library), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Library or works based on it. - -10. Each time you redistribute the Library (or any work based on the Library), the recipient automatically receives a license from the original licensor to copy, distribute, link with or modify the Library subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties with this License. - -11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Library at all. For example, if a patent license would not permit royalty-free redistribution of the Library by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Library. - -If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply, and the section as a whole is intended to apply in other circumstances. - -It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. - -This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. - -12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Library under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. - -13. The Free Software Foundation may publish revised and/or new versions of the Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. - -Each version is given a distinguishing version number. If the Library specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. - -14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. - -NO WARRANTY - -15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - -16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. - -END OF TERMS AND CONDITIONS - -How to Apply These Terms to Your New Libraries - -If you develop a new library, and you want it to be of the greatest possible use to the public, we recommend making it free software that everyone can redistribute and change. You can do so by permitting redistribution under these terms (or, alternatively, under the terms of the ordinary General Public License). - -To apply these terms, attach the following notices to the library. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. - - one line to give the library's name and an idea of what it does. - Copyright (C) year name of author - - This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; version 2.1 of the License. - - This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Also add information on how to contact you by electronic and paper mail. - -You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the library, if necessary. Here is a sample; alter the names: - -Yoyodyne, Inc., hereby disclaims all copyright interest in -the library `Frob' (a library for tweaking knobs) written -by James Random Hacker. - -signature of Ty Coon, 1 April 1990 -Ty Coon, President of Vice -That's all there is to it! diff --git a/tools/github/README.md b/tools/github/README.md deleted file mode 100644 index 978e9a3fc9..0000000000 --- a/tools/github/README.md +++ /dev/null @@ -1,60 +0,0 @@ -# @corbits/github-tools - -A minimal GitHub integration: search, plus the pull-request reads and -one write a code review needs — two `@intx/agent` tool bundles. Built for the last-30-days-research workflow port, -mirroring the OG gtm-workbench's `packages/tools-github` client against -GitHub's real public REST search API. - -## Tools - -- `github_activity` — searches GitHub for recently active repositories, - issues, and pull requests matching a topic within a days window, - returning a normalized list: `{ url, title, publishedAt (ISO 8601), -source: "github", engagement, entityTag? }`. Repos carry `engagement: -{ stars }` and an `entityTag` (the full repo name); issues/PRs carry - `engagement: { upvotes, comments }` (reactions mapped to upvotes). - -- `github_pull_request_diff` — reads one pull request: title, - description, head commit sha, and each changed file's patch, with - `changedLines` naming the right-hand lines a review comment can be - anchored to. -- `github_post_pull_request_review` — posts one comment-only review: a - markdown body plus inline comments. Never approves, requests changes, - or merges, which is why it is not approval-gated. GitHub rejects a - comment anchored outside the diff, so anchor only to a line the diff - reported. - -## Credential - -Unlike this port's other source (`@corbits/web-search-tools`), GitHub's -search endpoints work with **no credential at all** — an absent -`githubApiKey` just means the request goes out unauthenticated, at -GitHub's lower rate limit (60 requests/hour vs 5000/hour with a token). -The search tool never returns a "not connected" result for a missing -key; only a failed HTTP call (rate-limited, network error) degrades to -`isError: true`, so a calling agent can read that as "GitHub is -unavailable right now" and say so honestly. - -The pull-request bundle is different: a private diff is not readable -keylessly and posting a review is a write, so an unbound `github` handle -comes back as a plain "not connected" result rather than a lower rate -limit. - -## Usage - -```ts -import { githubPullRequestTools, githubTools } from "@corbits/github-tools"; - -const agent = defineAgent({ - // ... - tools: [githubTools, githubPullRequestTools], -}); -``` - -## Running tests - -``` -cd tools/github && bun test -``` - -No live database or real credentials required. diff --git a/tools/github/package.json b/tools/github/package.json deleted file mode 100644 index 13831ba263..0000000000 --- a/tools/github/package.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "name": "@corbits/github-tools", - "version": "0.0.11", - "description": "GitHub search integration: a minimal REST client and an @intx/agent tool bundle exposing github_activity", - "license": "LGPL-2.1-only", - "repository": { - "type": "git", - "url": "https://github.com/corbitsdev/workbench.git", - "directory": "tools/github" - }, - "files": [ - "src", - "LICENSE", - "README.md" - ], - "type": "module", - "exports": { - ".": "./src/index.ts" - }, - "publishConfig": { - "access": "public" - }, - "scripts": { - "typecheck": "tsc --noEmit", - "test": "bun test" - }, - "dependencies": { - "arktype": "catalog:" - }, - "devDependencies": { - "@types/bun": "catalog:", - "typescript": "catalog:" - }, - "peerDependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0" - }, - "interchange": { - "credentials": [ - { - "handle": "github" - } - ] - } -} diff --git a/tools/github/src/client.test.ts b/tools/github/src/client.test.ts deleted file mode 100644 index e6f691cf1c..0000000000 --- a/tools/github/src/client.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { expect, test } from "bun:test"; - -import { searchGitHubActivity } from "./client"; - -const REPOS = { - items: [ - { - full_name: "acme/agentkit", - html_url: "https://github.com/acme/agentkit", - description: "An agent toolkit", - stargazers_count: 42, - pushed_at: "2026-08-01T00:00:00Z", - }, - ], -}; - -const ISSUES = { - items: [ - { - html_url: "https://github.com/acme/agentkit/issues/1", - title: "Add streaming support", - comments: 3, - reactions: { total_count: 5 }, - updated_at: "2026-08-02T00:00:00Z", - }, - ], -}; - -const PRS = { - items: [ - { - html_url: "https://github.com/acme/agentkit/pull/2", - title: "Fix flaky test", - comments: 1, - reactions: { total_count: 0 }, - updated_at: "2026-08-03T00:00:00Z", - }, - ], -}; - -function threeCallFetch(bodies: readonly unknown[]) { - let call = 0; - const captured: string[] = []; - const fetchImpl = (async (input: URL | string) => { - captured.push(String(input)); - const body = bodies[call]; - call += 1; - return new Response(JSON.stringify(body), { status: 200 }); - }) as unknown as typeof fetch; - return { fetchImpl, captured }; -} - -test("merges repos, issues, and PRs into one normalized list", async () => { - const { fetchImpl } = threeCallFetch([REPOS, ISSUES, PRS]); - const items = await searchGitHubActivity({ fetchImpl }, { query: "agent workflows" }); - expect(items).toEqual([ - { - url: "https://github.com/acme/agentkit", - title: "acme/agentkit: An agent toolkit", - publishedAt: "2026-08-01T00:00:00Z", - source: "github", - engagement: { stars: 42 }, - entityTag: "acme/agentkit", - }, - { - url: "https://github.com/acme/agentkit/issues/1", - title: "Add streaming support", - publishedAt: "2026-08-02T00:00:00Z", - source: "github", - engagement: { upvotes: 5, comments: 3 }, - }, - { - url: "https://github.com/acme/agentkit/pull/2", - title: "Fix flaky test", - publishedAt: "2026-08-03T00:00:00Z", - source: "github", - engagement: { upvotes: 0, comments: 1 }, - }, - ]); -}); - -test("omits the authorization header when no apiKey is given (keyless call)", async () => { - const captured: (Record | undefined)[] = []; - const fetchImpl = (async (_input: URL | string, init?: RequestInit) => { - captured.push(init?.headers as Record | undefined); - return new Response(JSON.stringify({ items: [] }), { status: 200 }); - }) as unknown as typeof fetch; - - await searchGitHubActivity({ fetchImpl }, { query: "topic" }); - for (const headers of captured) { - expect(headers?.authorization).toBeUndefined(); - } -}); - -test("sends a bearer authorization header when an apiKey is given", async () => { - const captured: (Record | undefined)[] = []; - const fetchImpl = (async (_input: URL | string, init?: RequestInit) => { - captured.push(init?.headers as Record | undefined); - return new Response(JSON.stringify({ items: [] }), { status: 200 }); - }) as unknown as typeof fetch; - - await searchGitHubActivity({ apiKey: "ghp_test", fetchImpl }, { query: "topic" }); - for (const headers of captured) { - expect(headers?.authorization).toBe("Bearer ghp_test"); - } -}); - -test("scopes each query with the days cutoff and per_page limit", async () => { - const { fetchImpl, captured } = threeCallFetch([{ items: [] }, { items: [] }, { items: [] }]); - await searchGitHubActivity({ fetchImpl }, { query: "vector db", days: 7, limit: 3 }); - expect(captured[0]).toContain("search/repositories"); - expect(captured[0]).toContain("per_page=3"); - expect(captured[1]).toContain("is%3Aissue"); - expect(captured[2]).toContain("is%3Apr"); -}); - -test("throws on a non-ok HTTP response from any of the three calls", async () => { - const fetchImpl = (async () => new Response("nope", { status: 403 })) as unknown as typeof fetch; - await expect(searchGitHubActivity({ fetchImpl }, { query: "topic" })).rejects.toThrow(/403/); -}); - -test("throws when a response body does not match the expected shape", async () => { - const { fetchImpl } = threeCallFetch([{ items: [{ bad: true }] }, ISSUES, PRS]); - await expect(searchGitHubActivity({ fetchImpl }, { query: "topic" })).rejects.toThrow( - /did not match the expected shape/, - ); -}); diff --git a/tools/github/src/client.ts b/tools/github/src/client.ts deleted file mode 100644 index 1ae5bbe3bd..0000000000 --- a/tools/github/src/client.ts +++ /dev/null @@ -1,169 +0,0 @@ -// A minimal GitHub REST search client. The public search endpoints -// (`/search/repositories`, `/search/issues`) work keylessly — GitHub just -// rate-limits unauthenticated callers harder (60/hr vs 5000/hr with a -// token) — so, unlike Granola/Exa/Linear, "no credential" here means -// "lower rate limit," not "not connected." A caller-supplied token is -// used when present; every call still goes out either way. -import { type } from "arktype"; - -const GitHubRepo = type({ - full_name: "string", - html_url: "string", - "description?": "string | null", - stargazers_count: "number", - pushed_at: "string", -}); - -const GitHubIssueOrPR = type({ - html_url: "string", - title: "string", - comments: "number", - reactions: { total_count: "number" }, - updated_at: "string", -}); - -const GitHubSearchReposResponse = type({ items: GitHubRepo.array() }); -const GitHubSearchIssuesResponse = type({ items: GitHubIssueOrPR.array() }); - -export interface GitHubActivityItem { - readonly url: string; - readonly title: string; - /** ISO 8601 — a repo's last push, or an issue/PR's last update. */ - readonly publishedAt: string; - readonly source: "github"; - readonly engagement: - | { readonly stars: number } - | { readonly upvotes: number; readonly comments: number }; - /** Repos only: the full "owner/name" this item is about. */ - readonly entityTag?: string; -} - -export interface GitHubClientConfig { - /** Absent means unauthenticated (60 req/hr instead of 5000). */ - readonly apiKey?: string; - /** Override for tests; defaults to the real GitHub API host. */ - readonly baseUrl?: string; - readonly fetchImpl?: typeof fetch; -} - -const DEFAULT_BASE_URL = "https://api.github.com"; -const DEFAULT_DAYS = 30; -const DEFAULT_PER_LIST = 5; -const MAX_PER_LIST = 25; - -function buildHeaders(apiKey: string | undefined): Record { - const headers: Record = { - accept: "application/vnd.github+json", - "x-github-api-version": "2022-11-28", - }; - if (apiKey !== undefined && apiKey.length > 0) { - headers.authorization = `Bearer ${apiKey}`; - } - return headers; -} - -async function fetchGitHubJSON(url: URL, config: GitHubClientConfig): Promise { - const doFetch = config.fetchImpl ?? fetch; - const response = await doFetch(url, { headers: buildHeaders(config.apiKey) }); - if (!response.ok) { - throw new Error(`GitHub search request failed: ${response.status} ${response.statusText}`); - } - return response.json(); -} - -function normalizeRepo(repo: typeof GitHubRepo.infer): GitHubActivityItem { - return { - url: repo.html_url, - title: `${repo.full_name}: ${repo.description ?? ""}`, - publishedAt: repo.pushed_at, - source: "github", - engagement: { stars: repo.stargazers_count }, - entityTag: repo.full_name, - }; -} - -function normalizeIssueOrPR(item: typeof GitHubIssueOrPR.infer): GitHubActivityItem { - return { - url: item.html_url, - title: item.title, - publishedAt: item.updated_at, - source: "github", - engagement: { - upvotes: item.reactions.total_count, - comments: item.comments, - }, - }; -} - -function clampPerList(requested: number | undefined): number { - if (requested === undefined || !Number.isInteger(requested) || requested <= 0) { - return DEFAULT_PER_LIST; - } - return Math.min(requested, MAX_PER_LIST); -} - -/** - * Searches GitHub for recently active repositories, issues, and pull - * requests matching a topic. Throws on any transport, HTTP, or shape - * failure — callers that need graceful degradation (e.g. this package's - * `github_activity` tool) catch at their own boundary. - */ -export async function searchGitHubActivity( - config: GitHubClientConfig, - params: { - readonly query: string; - readonly days?: number; - readonly limit?: number; - }, -): Promise { - const days = - typeof params.days === "number" && params.days > 0 ? Math.floor(params.days) : DEFAULT_DAYS; - const cutoff = new Date(Date.now() - days * 86400 * 1000); - const cutoffDate = cutoff.toISOString().slice(0, 10); - const perPage = String(clampPerList(params.limit)); - const base = config.baseUrl ?? DEFAULT_BASE_URL; - - const reposUrl = new URL(`${base}/search/repositories`); - reposUrl.searchParams.set("q", `${params.query} pushed:>=${cutoffDate}`); - reposUrl.searchParams.set("sort", "stars"); - reposUrl.searchParams.set("per_page", perPage); - - const issuesUrl = new URL(`${base}/search/issues`); - issuesUrl.searchParams.set("q", `${params.query} is:issue updated:>=${cutoffDate}`); - issuesUrl.searchParams.set("sort", "reactions"); - issuesUrl.searchParams.set("per_page", perPage); - - const prsUrl = new URL(`${base}/search/issues`); - prsUrl.searchParams.set("q", `${params.query} is:pr updated:>=${cutoffDate}`); - prsUrl.searchParams.set("sort", "reactions"); - prsUrl.searchParams.set("per_page", perPage); - - const [reposRaw, issuesRaw, prsRaw] = await Promise.all([ - fetchGitHubJSON(reposUrl, config), - fetchGitHubJSON(issuesUrl, config), - fetchGitHubJSON(prsUrl, config), - ]); - - const repos = GitHubSearchReposResponse(reposRaw); - const issues = GitHubSearchIssuesResponse(issuesRaw); - const prs = GitHubSearchIssuesResponse(prsRaw); - if (repos instanceof type.errors) { - throw new Error( - `GitHub repo search response did not match the expected shape: ${repos.summary}`, - ); - } - if (issues instanceof type.errors) { - throw new Error( - `GitHub issue search response did not match the expected shape: ${issues.summary}`, - ); - } - if (prs instanceof type.errors) { - throw new Error(`GitHub PR search response did not match the expected shape: ${prs.summary}`); - } - - return [ - ...repos.items.map(normalizeRepo), - ...issues.items.map(normalizeIssueOrPR), - ...prs.items.map(normalizeIssueOrPR), - ]; -} diff --git a/tools/github/src/index.ts b/tools/github/src/index.ts deleted file mode 100644 index 609d5e10c6..0000000000 --- a/tools/github/src/index.ts +++ /dev/null @@ -1,28 +0,0 @@ -export { searchGitHubActivity } from "./client"; -export type { GitHubActivityItem, GitHubClientConfig } from "./client"; -export { fetchAuthenticatedLogin, listRepos } from "./repos"; -export type { GitHubRepoSummary } from "./repos"; -export { - changedLinesOf, - fetchPullRequestDiff, - fetchPullRequestReviewComments, - parsePullRequestUrl, - postPullRequestReview, -} from "./pull-requests"; -export type { - PostedPullRequestReview, - PullRequestDiff, - PullRequestFileDiff, - PullRequestRef, - PullRequestReviewComment, - PullRequestReviewCommentsPage, - PullRequestReviewDraft, -} from "./pull-requests"; -export { - GITHUB_POST_PULL_REQUEST_REVIEW_TOOL, - GITHUB_PULL_REQUEST_DIFF_TOOL, - githubPullRequestTools, -} from "./pull-request-tools"; -export type { GitHubPullRequestEnv } from "./pull-request-tools"; -export { GITHUB_ACTIVITY_TOOL, githubTools } from "./tool"; -export type { GitHubEnv } from "./tool"; diff --git a/tools/github/src/manifest.test.ts b/tools/github/src/manifest.test.ts deleted file mode 100644 index d2e73db3d5..0000000000 --- a/tools/github/src/manifest.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -// Proves this package's own `package.json` satisfies the vendored -// `PackageJSON` manifest schema (`@intx/types/src/package-json.ts`) -// with an `interchange.credentials` declaration the deploy-time -// `ToolPackageManifest` harvests into `topLevel[].credentials` — the -// static half of the credential-binding seam adopts. GitHub's -// credential is optional at runtime (an unresolved "github" handle -// degrades to unauthenticated calls, never "not connected" — see -// `tool.ts`), but the manifest still declares the handle that *could* -// be bound, same as the registry's `github` connector entry existing at -// all. -import { expect, test } from "bun:test"; -import { type } from "arktype"; -import { PackageJSON } from "@intx/types/package-json"; - -import packageJson from "../package.json"; - -test("package.json parses against the vendored PackageJSON manifest schema", () => { - const parsed = PackageJSON(packageJson); - expect(parsed instanceof type.errors).toBe(false); -}); - -test("declares exactly one credential handle: github", () => { - const parsed = PackageJSON(packageJson); - if (parsed instanceof type.errors) { - throw new Error(parsed.summary); - } - expect(parsed.interchange?.credentials).toEqual([{ handle: "github" }]); -}); diff --git a/tools/github/src/pull-request-tools.test.ts b/tools/github/src/pull-request-tools.test.ts deleted file mode 100644 index 924e51d6f9..0000000000 --- a/tools/github/src/pull-request-tools.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { expect, test } from "bun:test"; -import type { CredentialCapability, MediatedCredential } from "@intx/types"; -import type { ToolCall } from "@intx/types/runtime"; - -import { - GITHUB_POST_PULL_REQUEST_REVIEW_TOOL, - GITHUB_PULL_REQUEST_DIFF_TOOL, - githubPullRequestTools, - type GitHubPullRequestEnv, -} from "./pull-request-tools"; - -const PULL_URL = "https://github.com/acme/widgets/pull/7"; - -/** Requests the mediated fetch saw, so a test can assert the real call. */ -const requests: { url: string; method: string; body?: string }[] = []; - -function respond(url: string): Response { - if (url.includes("/pulls/7/files")) { - return new Response( - JSON.stringify([ - { - filename: "src/loop.ts", - status: "modified", - additions: 1, - deletions: 0, - patch: "@@ -1,1 +1,2 @@\n context\n+added", - }, - ]), - { status: 200 }, - ); - } - if (url.includes("/reviews")) { - return new Response(JSON.stringify({ id: 5, html_url: `${PULL_URL}#review` }), { status: 200 }); - } - return new Response( - JSON.stringify({ - title: "Add the review loop", - body: null, - user: { login: "octocat" }, - head: { sha: "headsha" }, - base: { sha: "basesha" }, - html_url: PULL_URL, - }), - { status: 200 }, - ); -} - -function fakeCredentials(bound: boolean): CredentialCapability { - return { - resolve(handle: string): Promise { - if (!bound) { - return Promise.reject(new Error(`no credential is bound to handle "${handle}"`)); - } - return Promise.resolve({ - kind: "http", - fetch: (input, init) => { - const url = String(input); - requests.push({ - url, - method: init?.method ?? "GET", - ...(typeof init?.body === "string" ? { body: init.body } : {}), - }); - return Promise.resolve(respond(url)); - }, - dispose: () => {}, - }); - }, - }; -} - -function bundle(bound: boolean) { - const env = { - credentials: fakeCredentials(bound), - } as unknown as GitHubPullRequestEnv; - return githubPullRequestTools(env); -} - -function call(name: string, args: Record): ToolCall { - return { id: "call_1", name, arguments: args }; -} - -test("the bundle exposes the diff read and the review post", () => { - expect(githubPullRequestTools.definitions.map((one) => one.name)).toEqual([ - GITHUB_PULL_REQUEST_DIFF_TOOL, - GITHUB_POST_PULL_REQUEST_REVIEW_TOOL, - ]); -}); - -test("reading a diff returns anchorable lines for each file", async () => { - requests.length = 0; - const result = await bundle(true).run( - call(GITHUB_PULL_REQUEST_DIFF_TOOL, { pullRequestUrl: PULL_URL }), - new AbortController().signal, - ); - expect(result.isError).toBeUndefined(); - const diff = JSON.parse(String(result.content)) as { - headSha: string; - files: { changedLines: number[] }[]; - }; - expect(diff.headSha).toBe("headsha"); - expect(diff.files[0]?.changedLines).toEqual([1, 2]); -}); - -test("posting a review sends one comment-only review", async () => { - requests.length = 0; - const result = await bundle(true).run( - call(GITHUB_POST_PULL_REQUEST_REVIEW_TOOL, { - pullRequestUrl: PULL_URL, - headSha: "headsha", - body: "## Code review\n\nNo findings.", - comments: [{ path: "src/loop.ts", line: 2, body: "Here." }], - }), - new AbortController().signal, - ); - expect(result.isError).toBeUndefined(); - const posted = requests.find((one) => one.url.includes("/reviews")); - expect(posted?.method).toBe("POST"); - expect(JSON.parse(String(posted?.body)) as unknown).toMatchObject({ - event: "COMMENT", - commit_id: "headsha", - }); -}); - -test("an unbound credential is reported as not connected, not as a crash", async () => { - const result = await bundle(false).run( - call(GITHUB_PULL_REQUEST_DIFF_TOOL, { pullRequestUrl: PULL_URL }), - new AbortController().signal, - ); - expect(result.isError).toBe(true); - expect(String(result.content)).toContain("not connected"); -}); - -test("an unbound credential names GitHub structurally, for the chat's connect prompt", async () => { - const result = await bundle(false).run( - call(GITHUB_PULL_REQUEST_DIFF_TOOL, { pullRequestUrl: PULL_URL }), - new AbortController().signal, - ); - expect(result.detail).toEqual({ - kind: "missing-credential", - connectorId: "github", - }); -}); - -test("a resolved credential carries no missing-credential detail", async () => { - const result = await bundle(true).run( - call(GITHUB_PULL_REQUEST_DIFF_TOOL, { pullRequestUrl: PULL_URL }), - new AbortController().signal, - ); - expect(result.detail).toBeUndefined(); -}); - -test("a URL that is not a pull request comes back as a tool error", async () => { - const result = await bundle(true).run( - call(GITHUB_PULL_REQUEST_DIFF_TOOL, { - pullRequestUrl: "https://github.com/acme/widgets", - }), - new AbortController().signal, - ); - expect(result.isError).toBe(true); - expect(String(result.content)).toContain("not a GitHub pull-request URL"); -}); - -test("a review with no body is refused before any request goes out", async () => { - requests.length = 0; - const result = await bundle(true).run( - call(GITHUB_POST_PULL_REQUEST_REVIEW_TOOL, { - pullRequestUrl: PULL_URL, - headSha: "headsha", - body: "", - }), - new AbortController().signal, - ); - expect(result.isError).toBe(true); - expect(requests.length).toBe(0); -}); diff --git a/tools/github/src/pull-request-tools.ts b/tools/github/src/pull-request-tools.ts deleted file mode 100644 index 50c54b27c3..0000000000 --- a/tools/github/src/pull-request-tools.ts +++ /dev/null @@ -1,201 +0,0 @@ -// Two agent-facing pull-request tools: read a pull request's diff, and -// post one review on it. Both need the mediated "github" credential — a -// private diff is not readable keylessly and a review is a write — so an -// unresolved credential here is a real "not connected", unlike the -// keyless `github_activity` search in `./tool.ts`. -// -// Posting is not approval-gated. A review is a comment: it says what the -// reviewers found and never approves, requests changes, or merges, so it -// flows under the standing grant on the connection rather than stopping -// for a per-call decision. -import { defineTool } from "@intx/agent"; -import type { BaseEnv } from "@intx/agent"; -import type { CredentialCapability } from "@intx/types"; -import type { ToolCall, ToolResult } from "@intx/types/runtime"; -import { type } from "arktype"; - -import { fetchPullRequestDiff, parsePullRequestUrl, postPullRequestReview } from "./pull-requests"; - -export const GITHUB_PULL_REQUEST_DIFF_TOOL = "github_pull_request_diff"; -export const GITHUB_POST_PULL_REQUEST_REVIEW_TOOL = "github_post_pr_review"; - -const GITHUB_CREDENTIAL_HANDLE = "github"; - -/** Env this bundle needs: the mediated GitHub credential. */ -export interface GitHubPullRequestEnv extends BaseEnv { - readonly credentials?: CredentialCapability; -} - -const DiffArguments = type({ pullRequestUrl: "string > 0" }); - -const ReviewArguments = type({ - pullRequestUrl: "string > 0", - headSha: "string > 0", - body: "string > 0", - "comments?": type({ - path: "string > 0", - line: "number > 0", - body: "string > 0", - }).array(), -}); - -function errorResult(callId: string, message: string, detail?: unknown): ToolResult { - return { - callId, - content: message, - isError: true, - ...(detail !== undefined ? { detail } : {}), - }; -} - -// The wire shape `@corbits/chat`'s orchestrator parses to render the -// live connect-service card. Written literally rather than imported: a -// sandboxed tool package stays free of a dependency on a hub-side -// package for one constant shape both sides already agree on by -// convention, the same way `toolDoneResult` narrows `event.type` -// without importing a shared literal. -function missingCredentialDetail(connectorId: string) { - return { kind: "missing-credential", connectorId } as const; -} - -function failureMessage(err: unknown): string { - return err instanceof Error ? err.message : String(err); -} - -/** - * The mediated credential's `fetch`, which injects the secret itself, or - * `null` when the handle is unbound or the grant was denied. - */ -async function resolveConfig( - env: GitHubPullRequestEnv, -): Promise<{ fetchImpl: typeof fetch } | null> { - if (env.credentials === undefined) return null; - try { - const mediated = await env.credentials.resolve(GITHUB_CREDENTIAL_HANDLE); - return { fetchImpl: mediated.fetch as unknown as typeof fetch }; - } catch { - return null; - } -} - -const NOT_CONNECTED = - "GitHub is not connected for this run, so the pull request cannot be " + - "read or reviewed. Say so plainly instead of guessing at the change."; - -async function runDiff(env: GitHubPullRequestEnv, call: ToolCall): Promise { - const args = DiffArguments(call.arguments); - if (args instanceof type.errors) { - return errorResult(call.id, `${GITHUB_PULL_REQUEST_DIFF_TOOL}: ${args.summary}`); - } - const config = await resolveConfig(env); - if (config === null) { - return errorResult(call.id, NOT_CONNECTED, missingCredentialDetail(GITHUB_CREDENTIAL_HANDLE)); - } - try { - const ref = parsePullRequestUrl(args.pullRequestUrl); - const diff = await fetchPullRequestDiff(config, ref); - return { callId: call.id, content: JSON.stringify(diff) }; - } catch (err) { - return errorResult(call.id, failureMessage(err)); - } -} - -async function runPostReview(env: GitHubPullRequestEnv, call: ToolCall): Promise { - const args = ReviewArguments(call.arguments); - if (args instanceof type.errors) { - return errorResult(call.id, `${GITHUB_POST_PULL_REQUEST_REVIEW_TOOL}: ${args.summary}`); - } - const config = await resolveConfig(env); - if (config === null) { - return errorResult(call.id, NOT_CONNECTED, missingCredentialDetail(GITHUB_CREDENTIAL_HANDLE)); - } - try { - const ref = parsePullRequestUrl(args.pullRequestUrl); - const posted = await postPullRequestReview(config, ref, args.headSha, { - body: args.body, - comments: args.comments ?? [], - }); - return { callId: call.id, content: JSON.stringify(posted) }; - } catch (err) { - return errorResult(call.id, failureMessage(err)); - } -} - -/** - * The `@corbits/github-tools` pull-request bundle: read a diff, post one - * review. Pin this package on any agent that reviews pull requests. - */ -export const githubPullRequestTools = defineTool({ - id: "@corbits/github-tools/prs", - requires: ["credentials"], - definitions: [ - { name: GITHUB_PULL_REQUEST_DIFF_TOOL }, - { name: GITHUB_POST_PULL_REQUEST_REVIEW_TOOL }, - ], - factory: (env) => ({ - definitions: [ - { - name: GITHUB_PULL_REQUEST_DIFF_TOOL, - description: - "Reads a GitHub pull request: its title, description, head " + - "commit sha, and the patch for every changed file. Each file " + - "also reports `changedLines`, the right-hand line numbers a " + - "review comment can be anchored to.", - inputSchema: { - type: "object", - properties: { - pullRequestUrl: { - type: "string", - description: - "The pull request's URL, e.g. " + "https://github.com/owner/repo/pull/123.", - }, - }, - required: ["pullRequestUrl"], - }, - }, - { - name: GITHUB_POST_PULL_REQUEST_REVIEW_TOOL, - description: - "Posts one comment-only review on a pull request: a markdown " + - "body plus optional inline comments. Anchor an inline comment " + - "only to a line the diff reported in `changedLines` for that " + - "file — GitHub rejects any other line. This never approves, " + - "requests changes, or merges.", - inputSchema: { - type: "object", - properties: { - pullRequestUrl: { - type: "string", - description: "The pull request's URL.", - }, - headSha: { - type: "string", - description: - "The head commit sha the diff reported, which the review " + "is anchored to.", - }, - body: { - type: "string", - description: "The review body, in markdown.", - }, - comments: { - type: "array", - description: "Inline comments on lines the diff can anchor.", - items: { - type: "object", - properties: { - path: { type: "string" }, - line: { type: "number" }, - body: { type: "string" }, - }, - required: ["path", "line", "body"], - }, - }, - }, - required: ["pullRequestUrl", "headSha", "body"], - }, - }, - ], - run: (call, _signal) => - call.name === GITHUB_PULL_REQUEST_DIFF_TOOL ? runDiff(env, call) : runPostReview(env, call), - }), -}); diff --git a/tools/github/src/pull-requests.test.ts b/tools/github/src/pull-requests.test.ts deleted file mode 100644 index 92537d1dd4..0000000000 --- a/tools/github/src/pull-requests.test.ts +++ /dev/null @@ -1,353 +0,0 @@ -import { expect, test } from "bun:test"; - -import { - changedLinesOf, - fetchPullRequestDiff, - fetchPullRequestReviewComments, - parsePullRequestUrl, - postPullRequestReview, -} from "./pull-requests"; - -const BASE = "https://github.test/api"; - -function jsonResponse(body: unknown): Response { - return new Response(JSON.stringify(body), { status: 200 }); -} - -/** A stand-in for `fetch`, narrowed to what these tests exercise. */ -function fakeFetch( - handler: (input: URL | string, init?: RequestInit) => Promise, -): typeof fetch { - return handler as unknown as typeof fetch; -} - -const PULL_BODY = { - title: "Add the review loop", - body: "Closes the loop.", - user: { login: "octocat" }, - head: { sha: "headsha" }, - base: { sha: "basesha" }, - html_url: "https://github.com/acme/widgets/pull/7", -}; - -const PATCH = ["@@ -1,3 +1,4 @@", " context", "-removed", "+added", "+also added"].join("\n"); - -test("parsePullRequestUrl reads owner, repo, and number", () => { - expect(parsePullRequestUrl("https://github.com/acme/widgets/pull/42")).toEqual({ - owner: "acme", - repo: "widgets", - number: 42, - }); -}); - -test("parsePullRequestUrl rejects a URL that is not a pull request", () => { - expect(() => parsePullRequestUrl("https://github.com/acme/widgets")).toThrow( - /not a GitHub pull-request URL/, - ); -}); - -test("changedLinesOf reports the right-hand lines a hunk touches", () => { - expect(changedLinesOf(PATCH)).toEqual([1, 2, 3]); -}); - -test("changedLinesOf ignores content outside any hunk", () => { - expect(changedLinesOf("no hunk header here\n+added")).toEqual([]); -}); - -test("fetchPullRequestDiff returns metadata plus anchorable lines", async () => { - const requested: string[] = []; - const diff = await fetchPullRequestDiff( - { - apiKey: "token", - baseUrl: BASE, - fetchImpl: fakeFetch((input) => { - const url = String(input); - requested.push(url); - if (url.includes("/files")) { - return Promise.resolve( - jsonResponse([ - { - filename: "src/loop.ts", - status: "modified", - additions: 2, - deletions: 1, - patch: PATCH, - }, - ]), - ); - } - return Promise.resolve(jsonResponse(PULL_BODY)); - }), - }, - { owner: "acme", repo: "widgets", number: 7 }, - ); - - expect(diff.title).toBe("Add the review loop"); - expect(diff.headSha).toBe("headsha"); - expect(diff.author).toBe("octocat"); - expect(diff.files[0]?.changedLines).toEqual([1, 2, 3]); - expect(requested.some((url) => url.includes("/pulls/7/files"))).toBe(true); -}); - -test("fetchPullRequestDiff sends a bearer header only when given a token", async () => { - const sent: (Record | undefined)[] = []; - await fetchPullRequestDiff( - { - baseUrl: BASE, - fetchImpl: fakeFetch((input, init) => { - sent.push(init?.headers as Record | undefined); - return Promise.resolve( - String(input).includes("/files") ? jsonResponse([]) : jsonResponse(PULL_BODY), - ); - }), - }, - { owner: "acme", repo: "widgets", number: 7 }, - ); - for (const headers of sent) { - expect(headers?.authorization).toBeUndefined(); - } -}); - -test("fetchPullRequestDiff names a response whose shape is wrong", async () => { - await expect( - fetchPullRequestDiff( - { - apiKey: "token", - baseUrl: BASE, - fetchImpl: fakeFetch((input) => - Promise.resolve( - String(input).includes("/files") - ? jsonResponse([]) - : jsonResponse({ title: "no head sha" }), - ), - ), - }, - { owner: "acme", repo: "widgets", number: 7 }, - ), - ).rejects.toThrow(/did not match the expected shape/); -}); - -test("postPullRequestReview posts one comment-only review at the head sha", async () => { - let sent: { url: string; body: unknown } | undefined; - const posted = await postPullRequestReview( - { - apiKey: "token", - baseUrl: BASE, - fetchImpl: fakeFetch((input, init) => { - sent = { - url: String(input), - body: JSON.parse(String(init?.body)) as unknown, - }; - return Promise.resolve( - jsonResponse({ - id: 99, - html_url: "https://github.com/acme/widgets/pull/7#review", - }), - ); - }), - }, - { owner: "acme", repo: "widgets", number: 7 }, - "headsha", - { - body: "One review.", - comments: [{ path: "src/loop.ts", line: 2, body: "This breaks." }], - }, - ); - - expect(posted).toEqual({ - id: 99, - url: "https://github.com/acme/widgets/pull/7#review", - }); - expect(sent?.url).toBe(`${BASE}/repos/acme/widgets/pulls/7/reviews`); - expect(sent?.body).toEqual({ - commit_id: "headsha", - body: "One review.", - event: "COMMENT", - comments: [{ path: "src/loop.ts", line: 2, side: "RIGHT", body: "This breaks." }], - }); -}); - -test("fetchPullRequestReviewComments returns each comment's body", async () => { - const page = await fetchPullRequestReviewComments( - { - apiKey: "token", - baseUrl: BASE, - fetchImpl: fakeFetch(() => - Promise.resolve(jsonResponse([{ body: "first" }, { body: "second" }])), - ), - }, - { owner: "acme", repo: "widgets", number: 7 }, - ); - expect(page).toEqual({ comments: ["first", "second"], truncated: false }); -}); - -/** A page short enough to end pagination whichever way the caller detects it. */ -function shortPage(bodies: string[], link?: string): Response { - return new Response(JSON.stringify(bodies.map((body) => ({ body }))), { - status: 200, - headers: link === undefined ? {} : { link }, - }); -} - -/** A full (100-item) page, to drive the page-number fallback when no `Link` header is sent. */ -function fullPage(prefix: string): Response { - const bodies = Array.from({ length: 100 }, (_, i) => ({ - body: `${prefix}-${String(i)}`, - })); - return new Response(JSON.stringify(bodies), { status: 200 }); -} - -test('fetchPullRequestReviewComments follows a Link: rel="next" header across pages', async () => { - const requestedUrls: string[] = []; - const page = await fetchPullRequestReviewComments( - { - apiKey: "token", - baseUrl: BASE, - fetchImpl: fakeFetch((input) => { - const url = String(input); - requestedUrls.push(url); - if (url.includes("page=2")) { - return Promise.resolve(shortPage(["third", "fourth"])); - } - return Promise.resolve(shortPage(["first", "second"], `<${BASE}/next?page=2>; rel="next"`)); - }), - }, - { owner: "acme", repo: "widgets", number: 7 }, - ); - expect(page).toEqual({ - comments: ["first", "second", "third", "fourth"], - truncated: false, - }); - expect(requestedUrls).toHaveLength(2); -}); - -test("fetchPullRequestReviewComments falls back to page= when no Link header is sent", async () => { - let calls = 0; - const page = await fetchPullRequestReviewComments( - { - apiKey: "token", - baseUrl: BASE, - fetchImpl: fakeFetch(() => { - calls += 1; - return Promise.resolve(calls === 1 ? fullPage("p1") : shortPage(["last"])); - }), - }, - { owner: "acme", repo: "widgets", number: 7 }, - ); - expect(page.comments).toHaveLength(101); - expect(page.comments[100]).toBe("last"); - expect(page.truncated).toBe(false); - expect(calls).toBe(2); -}); - -test("fetchPullRequestReviewComments reports truncated when the page bound is hit", async () => { - const page = await fetchPullRequestReviewComments( - { - apiKey: "token", - baseUrl: BASE, - fetchImpl: fakeFetch(() => Promise.resolve(fullPage("p"))), - }, - { owner: "acme", repo: "widgets", number: 7 }, - ); - expect(page.truncated).toBe(true); - expect(page.comments).toHaveLength(3000); -}); - -test("fetchPullRequestDiff follows pagination for files and reports it unset for one page", async () => { - const diff = await fetchPullRequestDiff( - { - apiKey: "token", - baseUrl: BASE, - fetchImpl: fakeFetch((input) => { - const url = String(input); - if (url.includes("/files")) { - return Promise.resolve( - jsonResponse([ - { - filename: "src/loop.ts", - status: "modified", - additions: 2, - deletions: 1, - patch: PATCH, - }, - ]), - ); - } - return Promise.resolve(jsonResponse(PULL_BODY)); - }), - }, - { owner: "acme", repo: "widgets", number: 7 }, - ); - expect(diff.truncated).toBe(false); - expect(diff.files).toHaveLength(1); -}); - -function fileAt(index: number): Record { - return { - filename: `src/file-${String(index)}.ts`, - status: "modified", - additions: 1, - deletions: 0, - }; -} - -test("fetchPullRequestDiff merges every page of changed files", async () => { - const diff = await fetchPullRequestDiff( - { - apiKey: "token", - baseUrl: BASE, - fetchImpl: fakeFetch((input) => { - const url = String(input); - if (url.includes("page=2")) { - return Promise.resolve(new Response(JSON.stringify([fileAt(100)]), { status: 200 })); - } - if (url.includes("/files")) { - const files = Array.from({ length: 100 }, (_, i) => fileAt(i)); - return Promise.resolve( - new Response(JSON.stringify(files), { - status: 200, - headers: { - link: `<${BASE}/repos/acme/widgets/pulls/7/files?page=2>; rel="next"`, - }, - }), - ); - } - return Promise.resolve(jsonResponse(PULL_BODY)); - }), - }, - { owner: "acme", repo: "widgets", number: 7 }, - ); - expect(diff.files).toHaveLength(101); - expect(diff.truncated).toBe(false); -}); - -test("fetchPullRequestDiff reports truncated when the file-page bound is hit", async () => { - const diff = await fetchPullRequestDiff( - { - apiKey: "token", - baseUrl: BASE, - fetchImpl: fakeFetch((input) => { - const url = String(input); - if (url.includes("/files")) { - const files = Array.from({ length: 100 }, (_, i) => fileAt(i)); - return Promise.resolve(jsonResponse(files)); - } - return Promise.resolve(jsonResponse(PULL_BODY)); - }), - }, - { owner: "acme", repo: "widgets", number: 7 }, - ); - expect(diff.truncated).toBe(true); - expect(diff.files).toHaveLength(3000); -}); - -test("postPullRequestReview refuses an empty body", async () => { - await expect( - postPullRequestReview( - { apiKey: "token", baseUrl: BASE }, - { owner: "acme", repo: "widgets", number: 7 }, - "headsha", - { body: " ", comments: [] }, - ), - ).rejects.toThrow(/non-empty body/); -}); diff --git a/tools/github/src/pull-requests.ts b/tools/github/src/pull-requests.ts deleted file mode 100644 index 4e0fe9f501..0000000000 --- a/tools/github/src/pull-requests.ts +++ /dev/null @@ -1,386 +0,0 @@ -// Pull-request reads and one write, on top of the same minimal REST -// client `./client.ts` uses for search. Both calls here need a real -// credential in practice — a private diff is not readable keylessly and -// posting a review is a write — but which shape that credential takes is -// the caller's business: a mediated `fetch` that injects the secret -// itself, or a token passed as `apiKey`. This module only sends what it -// was given, so "not connected" is decided where the credential is -// resolved, never guessed at here. -// -// The right-hand line numbers a patch actually touches are parsed out of -// each file's hunk headers. GitHub rejects a review comment anchored to -// a line outside the diff, so a caller that wants inline comments has to -// know which lines are anchorable before it posts — that is what -// `changedLines` is for. -import { type } from "arktype"; - -import type { GitHubClientConfig } from "./client"; - -const DEFAULT_BASE_URL = "https://api.github.com"; -const MAX_ITEMS_PER_PAGE = 100; -/** - * Upper bound on how many pages a paginated fetch follows. A pull - * request with more than 3,000 changed files or review comments is - * pathological; past that we stop and say so rather than loop forever. - */ -const MAX_PAGES = 30; - -const PullRequestResponse = type({ - title: "string", - "body?": "string | null", - user: { login: "string" }, - head: { sha: "string" }, - base: { sha: "string" }, - html_url: "string", -}); - -const PullRequestFileResponse = type({ - filename: "string", - status: "string", - additions: "number", - deletions: "number", - "patch?": "string", -}); - -const PullRequestFilesResponse = PullRequestFileResponse.array(); - -const PostedReviewResponse = type({ - id: "number", - html_url: "string", -}); - -/** Which pull request a call is about. */ -export interface PullRequestRef { - readonly owner: string; - readonly repo: string; - readonly number: number; -} - -export interface PullRequestFileDiff { - readonly path: string; - readonly status: string; - readonly additions: number; - readonly deletions: number; - /** Absent for files GitHub reports without a patch (binary, too large). */ - readonly patch?: string; - /** Right-hand line numbers this patch touches; the anchorable set. */ - readonly changedLines: readonly number[]; -} - -export interface PullRequestDiff { - readonly ref: PullRequestRef; - readonly title: string; - readonly description: string; - readonly url: string; - /** The pull request's author login; a bot login skips review entirely. */ - readonly author: string; - /** The head commit a posted review is anchored to. */ - readonly headSha: string; - readonly baseSha: string; - readonly files: readonly PullRequestFileDiff[]; - /** True when the file list hit the page bound and may be incomplete. */ - readonly truncated: boolean; -} - -/** One inline comment on a posted review. */ -export interface PullRequestReviewComment { - readonly path: string; - readonly line: number; - readonly body: string; -} - -/** The review a reviewer run posts: one body plus inline comments. */ -export interface PullRequestReviewDraft { - readonly body: string; - readonly comments: readonly PullRequestReviewComment[]; -} - -export interface PostedPullRequestReview { - readonly id: number; - readonly url: string; -} - -export interface PullRequestReviewCommentsPage { - readonly comments: readonly string[]; - /** True when the comment list hit the page bound and may be incomplete. */ - readonly truncated: boolean; -} - -const PULL_REQUEST_URL = /^https?:\/\/github\.com\/([^/\s]+)\/([^/\s]+)\/pull\/(\d+)(?:[/?#].*)?$/; - -/** - * Reads a `PullRequestRef` out of a pull-request URL. The URL arrives - * from a webhook payload or a person's message, so a shape that is not - * a pull-request URL is a named error rather than a guess. - */ -export function parsePullRequestUrl(url: string): PullRequestRef { - const match = PULL_REQUEST_URL.exec(url.trim()); - if (match === null) { - throw new Error( - `"${url}" is not a GitHub pull-request URL ` + - "(expected https://github.com///pull/)", - ); - } - const [, owner, repo, number] = match; - if (owner === undefined || repo === undefined || number === undefined) { - throw new Error(`"${url}" is not a GitHub pull-request URL`); - } - return { owner, repo, number: Number(number) }; -} - -const HUNK_HEADER = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/; - -/** - * Right-hand line numbers a unified patch touches: every added line, and - * every context line in between, since GitHub accepts a comment anchored - * to any line inside a hunk it rendered. - */ -export function changedLinesOf(patch: string): readonly number[] { - const lines: number[] = []; - let cursor = 0; - for (const patchLine of patch.split("\n")) { - const header = HUNK_HEADER.exec(patchLine); - if (header !== null) { - cursor = Number(header[1]); - continue; - } - if (cursor === 0) continue; - if (patchLine.startsWith("-")) continue; - if (patchLine.startsWith("+") || patchLine.startsWith(" ")) { - lines.push(cursor); - cursor += 1; - } - } - return lines; -} - -function headers(apiKey: string | undefined): Record { - const base: Record = { - accept: "application/vnd.github+json", - "x-github-api-version": "2022-11-28", - "content-type": "application/json", - }; - if (apiKey !== undefined && apiKey.length > 0) { - base.authorization = `Bearer ${apiKey}`; - } - return base; -} - -/** - * Fetches a URL and parses its body as JSON, throwing one consistently - * worded error for a non-2xx response. The one seam every GitHub call in - * this module goes through, so `requestJSON` and `fetchAllPages` report - * a transport failure identically instead of each spelling it out. - */ -async function fetchJSON( - config: GitHubClientConfig, - url: URL, - init: { readonly method: string; readonly body?: string }, -): Promise<{ readonly response: Response; readonly body: unknown }> { - const doFetch = config.fetchImpl ?? fetch; - const response: Response = await doFetch(url, { - method: init.method, - headers: headers(config.apiKey), - ...(init.body === undefined ? {} : { body: init.body }), - }); - if (!response.ok) { - throw new Error( - `GitHub ${init.method} ${url.pathname} failed: ` + - `${String(response.status)} ${response.statusText}`, - ); - } - return { response, body: await response.json() }; -} - -async function requestJSON( - config: GitHubClientConfig, - url: URL, - init: { readonly method: string; readonly body?: string }, -): Promise { - return (await fetchJSON(config, url, init)).body; -} - -const NEXT_LINK = /<([^>]+)>\s*;\s*rel="next"/; - -/** Reads the `rel="next"` URL out of a GitHub `Link` response header. */ -function nextPageUrl(linkHeader: string | null): URL | null { - if (linkHeader === null) return null; - const match = NEXT_LINK.exec(linkHeader); - return match?.[1] === undefined ? null : new URL(match[1]); -} - -/** The next page to request when a paginated endpoint sent no `Link`. */ -function fallbackNextPage(url: URL, pageItemCount: number): URL | null { - if (pageItemCount < MAX_ITEMS_PER_PAGE) return null; - const next = new URL(url); - const currentPage = Number(next.searchParams.get("page") ?? "1"); - next.searchParams.set("page", String(currentPage + 1)); - return next; -} - -/** - * Follows a paginated GitHub list endpoint to completion: the `Link` - * header's `rel="next"` when GitHub sends one, otherwise successive - * `page=` requests until a page comes back short. Stops at `MAX_PAGES` - * and reports `truncated: true` rather than looping forever against a - * pathological pull request. - */ -async function fetchAllPages( - config: GitHubClientConfig, - initialUrl: URL, -): Promise<{ - readonly items: readonly unknown[]; - readonly truncated: boolean; -}> { - const items: unknown[] = []; - let url: URL | null = initialUrl; - let pageCount = 0; - let truncated = false; - - while (url !== null) { - if (pageCount >= MAX_PAGES) { - truncated = true; - break; - } - pageCount += 1; - const { response, body: page } = await fetchJSON(config, url, { - method: "GET", - }); - if (!Array.isArray(page)) { - throw new Error(`GitHub GET ${url.pathname} returned a non-array page`); - } - items.push(...page); - url = nextPageUrl(response.headers.get("link")) ?? fallbackNextPage(url, page.length); - } - - return { items, truncated }; -} - -function baseOf(config: GitHubClientConfig): string { - return config.baseUrl ?? DEFAULT_BASE_URL; -} - -function pullPath(ref: PullRequestRef): string { - return `/repos/${ref.owner}/${ref.repo}/pulls/${String(ref.number)}`; -} - -function toFileDiff(file: typeof PullRequestFileResponse.infer): PullRequestFileDiff { - const patch = file.patch; - return { - path: file.filename, - status: file.status, - additions: file.additions, - deletions: file.deletions, - ...(patch === undefined ? {} : { patch }), - changedLines: patch === undefined ? [] : changedLinesOf(patch), - }; -} - -/** - * Fetches a pull request's metadata and per-file patches. Throws on any - * transport, HTTP, or shape failure; a caller that needs graceful - * degradation catches at its own boundary. - */ -export async function fetchPullRequestDiff( - config: GitHubClientConfig, - ref: PullRequestRef, -): Promise { - const base = baseOf(config); - - const pullUrl = new URL(`${base}${pullPath(ref)}`); - const filesUrl = new URL(`${base}${pullPath(ref)}/files`); - filesUrl.searchParams.set("per_page", String(MAX_ITEMS_PER_PAGE)); - - const [pullRaw, filesPage] = await Promise.all([ - requestJSON(config, pullUrl, { method: "GET" }), - fetchAllPages(config, filesUrl), - ]); - - const pull = PullRequestResponse(pullRaw); - if (pull instanceof type.errors) { - throw new Error( - `GitHub pull-request response did not match the expected shape: ${pull.summary}`, - ); - } - const files = PullRequestFilesResponse(filesPage.items); - if (files instanceof type.errors) { - throw new Error( - `GitHub pull-request files response did not match the expected shape: ${files.summary}`, - ); - } - - return { - ref, - title: pull.title, - description: pull.body ?? "", - url: pull.html_url, - author: pull.user.login, - headSha: pull.head.sha, - baseSha: pull.base.sha, - files: files.map(toFileDiff), - truncated: filesPage.truncated, - }; -} - -const ReviewCommentResponse = type({ body: "string" }); -const ReviewCommentsResponse = ReviewCommentResponse.array(); - -/** - * Reads every review comment already posted on a pull request. A re-run - * uses this to find which findings it already posted, by the fingerprint - * marker embedded in each comment's body — never by re-reading the model. - */ -export async function fetchPullRequestReviewComments( - config: GitHubClientConfig, - ref: PullRequestRef, -): Promise { - const url = new URL(`${baseOf(config)}${pullPath(ref)}/comments`); - url.searchParams.set("per_page", String(MAX_ITEMS_PER_PAGE)); - const page = await fetchAllPages(config, url); - const comments = ReviewCommentsResponse(page.items); - if (comments instanceof type.errors) { - throw new Error( - `GitHub pull-request comments response did not match the expected shape: ${comments.summary}`, - ); - } - return { - comments: comments.map((comment) => comment.body), - truncated: page.truncated, - }; -} - -/** - * Posts one review on a pull request, as a comment-only review (never an - * approval or a change request — a reviewer run says what it found; a - * person decides what that means for the merge). - */ -export async function postPullRequestReview( - config: GitHubClientConfig, - ref: PullRequestRef, - headSha: string, - review: PullRequestReviewDraft, -): Promise { - if (review.body.trim().length === 0) { - throw new Error("a posted pull-request review needs a non-empty body"); - } - const url = new URL(`${baseOf(config)}${pullPath(ref)}/reviews`); - const raw = await requestJSON(config, url, { - method: "POST", - body: JSON.stringify({ - commit_id: headSha, - body: review.body, - event: "COMMENT", - comments: review.comments.map((comment) => ({ - path: comment.path, - line: comment.line, - side: "RIGHT", - body: comment.body, - })), - }), - }); - const posted = PostedReviewResponse(raw); - if (posted instanceof type.errors) { - throw new Error(`GitHub review response did not match the expected shape: ${posted.summary}`); - } - return { id: posted.id, url: posted.html_url }; -} diff --git a/tools/github/src/repos.test.ts b/tools/github/src/repos.test.ts deleted file mode 100644 index 25ce14e3ad..0000000000 --- a/tools/github/src/repos.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { expect, test } from "bun:test"; - -import { fetchAuthenticatedLogin, listRepos } from "./repos"; - -const BASE = "https://github.test/api"; - -function jsonResponse(body: unknown): Response { - return new Response(JSON.stringify(body), { status: 200 }); -} - -function fakeFetch(handler: (url: string) => Promise): typeof fetch { - return ((input: URL | string) => handler(String(input))) as unknown as typeof fetch; -} - -test("listRepos reads the whole picker from one list call, never a per-repo search", async () => { - const requested: string[] = []; - const fetchImpl = fakeFetch((url) => { - requested.push(url); - if (url.includes("/user/repos")) { - return Promise.resolve( - jsonResponse([ - { - id: 1, - full_name: "acme/widgets", - pushed_at: "2026-08-29T12:00:00Z", - }, - { id: 2, full_name: "acme/gadgets", pushed_at: null }, - ]), - ); - } - throw new Error(`unstubbed request: ${url}`); - }); - - const repos = await listRepos({ apiKey: "tok", baseUrl: BASE, fetchImpl }); - - expect(repos).toEqual([ - { - id: "1", - name: "acme/widgets", - lastPushedAt: "2026-08-29T12:00:00Z", - }, - { id: "2", name: "acme/gadgets" }, - ]); - expect(requested).toHaveLength(1); - expect(requested.some((url) => url.includes("/search/"))).toBe(false); -}); - -test("listRepos throws when the repos response doesn't match the expected shape", async () => { - const fetchImpl = fakeFetch(() => Promise.resolve(jsonResponse([{ id: "not-a-number" }]))); - await expect(listRepos({ baseUrl: BASE, fetchImpl })).rejects.toThrow( - /did not match the expected shape/, - ); -}); - -test("listRepos names the status when GitHub rejects the list call", async () => { - const fetchImpl = fakeFetch(() => - Promise.resolve(new Response("nope", { status: 403, statusText: "Forbidden" })), - ); - await expect(listRepos({ baseUrl: BASE, fetchImpl })).rejects.toThrow(/403 Forbidden/); -}); - -test("fetchAuthenticatedLogin reads the PAT's own login", async () => { - const fetchImpl = fakeFetch((url) => { - expect(url).toContain("/user"); - return Promise.resolve(jsonResponse({ login: "octocat" })); - }); - expect(await fetchAuthenticatedLogin({ baseUrl: BASE, fetchImpl })).toBe("octocat"); -}); diff --git a/tools/github/src/repos.ts b/tools/github/src/repos.ts deleted file mode 100644 index 5af2954290..0000000000 --- a/tools/github/src/repos.ts +++ /dev/null @@ -1,99 +0,0 @@ -// The connect-github card's repo picker: the repositories the -// connected token can reach, most recently pushed first. -// -// One call, on purpose. This used to decorate every row with an exact -// open-PR count from the search API, which cost one `/search/issues` call -// per repo fired concurrently — and GitHub's search API allows 30 requests -// a minute and secondary-rate-limits bursts, so any account past a handful -// of repos got a 403 and the whole picker failed to load. The -// count was decoration on a checkbox row; the push timestamp that explains -// the ordering rides along on the list response for free. -import { type } from "arktype"; - -import type { GitHubClientConfig } from "./client"; - -const GitHubUserRepo = type({ - id: "number", - full_name: "string", - "pushed_at?": "string | null", -}); -const GitHubUserReposResponse = GitHubUserRepo.array(); - -const DEFAULT_BASE_URL = "https://api.github.com"; -const PER_PAGE = 100; - -export interface GitHubRepoSummary { - readonly id: string; - readonly name: string; - /** When GitHub last saw a push, ISO-8601 — absent on a repo that has - * never been pushed to. */ - readonly lastPushedAt?: string; -} - -function headers(apiKey: string | undefined): Record { - const base: Record = { - accept: "application/vnd.github+json", - "x-github-api-version": "2022-11-28", - }; - if (apiKey !== undefined && apiKey.length > 0) { - base.authorization = `Bearer ${apiKey}`; - } - return base; -} - -async function fetchJSON(config: GitHubClientConfig, url: URL): Promise { - const doFetch = config.fetchImpl ?? fetch; - const response = await doFetch(url, { headers: headers(config.apiKey) }); - if (!response.ok) { - throw new Error( - `GitHub request to ${url.pathname} failed: ` + - `${String(response.status)} ${response.statusText}`, - ); - } - return response.json(); -} - -/** - * Lists the repositories the connected credential can see, most - * recently pushed first. Throws on any transport, HTTP, or shape - * failure; the connect-github card's own host catches at its render - * boundary. - */ -export async function listRepos(config: GitHubClientConfig): Promise { - const base = config.baseUrl ?? DEFAULT_BASE_URL; - const url = new URL(`${base}/user/repos`); - url.searchParams.set("per_page", String(PER_PAGE)); - url.searchParams.set("sort", "pushed"); - - const raw = await fetchJSON(config, url); - const repos = GitHubUserReposResponse(raw); - if (repos instanceof type.errors) { - throw new Error(`GitHub repos response did not match the expected shape: ${repos.summary}`); - } - - return repos.map((repo) => ({ - id: String(repo.id), - name: repo.full_name, - ...(typeof repo.pushed_at === "string" ? { lastPushedAt: repo.pushed_at } : {}), - })); -} - -const GitHubAuthenticatedUser = type({ login: "string" }); - -/** - * The authenticated PAT's own login — this connector authenticates a - * person, not an organization (GitHub App/OAuth org-level connect is - *, not built here), so a caller showing "connected as" shows - * this login, not an org name. - */ -export async function fetchAuthenticatedLogin(config: GitHubClientConfig): Promise { - const base = config.baseUrl ?? DEFAULT_BASE_URL; - const raw = await fetchJSON(config, new URL(`${base}/user`)); - const parsed = GitHubAuthenticatedUser(raw); - if (parsed instanceof type.errors) { - throw new Error( - `GitHub authenticated-user response did not match the expected shape: ${parsed.summary}`, - ); - } - return parsed.login; -} diff --git a/tools/github/src/tool.test.ts b/tools/github/src/tool.test.ts deleted file mode 100644 index 57d05ec133..0000000000 --- a/tools/github/src/tool.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { expect, test } from "bun:test"; -import type { ToolCall } from "@intx/types/runtime"; -import type { CredentialCapability, MediatedCredential } from "@intx/types"; - -import { GITHUB_ACTIVITY_TOOL, githubTools } from "./tool"; -import type { GitHubEnv } from "./tool"; - -const CALL: ToolCall = { - id: "call_1", - name: GITHUB_ACTIVITY_TOOL, - arguments: { query: "agent workflows" }, -}; - -/** - * A fake `credentials` capability mirroring the platform's own - * `createCredentialCapability`/`createHttpCredentialProvider` shape: a - * bound `secret` resolves to a mediated `fetch` that injects a bearer - * header and delegates to `globalThis.fetch`; an unbound handle throws, - * matching the real gate's "no credential is bound to handle" failure. - */ -function fakeCredentials(secret: string | undefined): CredentialCapability { - return { - resolve(handle: string): Promise { - if (secret === undefined) { - return Promise.reject(new Error(`no credential is bound to handle "${handle}"`)); - } - return Promise.resolve({ - kind: "http", - fetch: (input, init) => { - const headers = new Headers(init?.headers); - headers.set("authorization", `Bearer ${secret}`); - return fetch(input as string | URL, { ...init, headers }); - }, - dispose: () => {}, - }); - }, - }; -} - -function fakeEnv(credentials: CredentialCapability | undefined): GitHubEnv { - return { credentials } as unknown as GitHubEnv; -} - -function emptyResponsesFetch(): typeof fetch { - return (async () => - new Response(JSON.stringify({ items: [] }), { - status: 200, - })) as unknown as typeof fetch; -} - -test("declares the github_activity tool", () => { - const bundle = githubTools(fakeEnv(undefined)); - expect(bundle.definitions.map((d) => d.name)).toEqual([GITHUB_ACTIVITY_TOOL]); -}); - -test("rejects a missing query without calling the network", async () => { - const originalFetch = globalThis.fetch; - let called = false; - globalThis.fetch = (async () => { - called = true; - return new Response("{}", { status: 200 }); - }) as unknown as typeof fetch; - try { - const bundle = githubTools(fakeEnv(undefined)); - const result = await bundle.run( - { id: "call_2", name: GITHUB_ACTIVITY_TOOL, arguments: {} }, - new AbortController().signal, - ); - expect(called).toBe(false); - expect(result.isError).toBe(true); - expect(result.content).toContain("query"); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("works with no credentials capability at all (keyless call succeeds, not an error)", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = emptyResponsesFetch(); - try { - const bundle = githubTools(fakeEnv(undefined)); - const result = await bundle.run(CALL, new AbortController().signal); - expect(result.isError).toBeUndefined(); - const parsed = JSON.parse(result.content as string) as { - items: unknown[]; - }; - expect(parsed.items).toEqual([]); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("degrades to an unauthenticated call (not an error) when resolve() throws", async () => { - const originalFetch = globalThis.fetch; - const capturedHeaders: (Headers | undefined)[] = []; - globalThis.fetch = (async (_input: unknown, init?: RequestInit) => { - capturedHeaders.push(init?.headers as Headers | undefined); - return new Response(JSON.stringify({ items: [] }), { status: 200 }); - }) as unknown as typeof fetch; - try { - const bundle = githubTools(fakeEnv(fakeCredentials(undefined))); - const result = await bundle.run(CALL, new AbortController().signal); - expect(result.isError).toBeUndefined(); - for (const headers of capturedHeaders) { - expect(headers?.get?.("authorization")).toBeFalsy(); - } - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("uses the mediated fetch (authenticated) when a credential resolves", async () => { - const originalFetch = globalThis.fetch; - const capturedHeaders: (Headers | undefined)[] = []; - globalThis.fetch = (async (_input: unknown, init?: RequestInit) => { - capturedHeaders.push(init?.headers as Headers | undefined); - return new Response(JSON.stringify({ items: [] }), { status: 200 }); - }) as unknown as typeof fetch; - try { - const bundle = githubTools(fakeEnv(fakeCredentials("ghp_test"))); - const result = await bundle.run(CALL, new AbortController().signal); - expect(result.isError).toBeUndefined(); - expect(capturedHeaders.length).toBeGreaterThan(0); - for (const headers of capturedHeaders) { - expect(headers?.get?.("authorization")).toBe("Bearer ghp_test"); - } - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("degrades to an error result (never throws) when the underlying call fails", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = (async () => new Response("nope", { status: 403 })) as unknown as typeof fetch; - try { - const bundle = githubTools(fakeEnv(undefined)); - const result = await bundle.run(CALL, new AbortController().signal); - expect(result.isError).toBe(true); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("degrades to an error result the same way with a credential resolving", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = (async () => new Response("nope", { status: 500 })) as unknown as typeof fetch; - try { - const bundle = githubTools(fakeEnv(fakeCredentials("ghp_test"))); - const result = await bundle.run(CALL, new AbortController().signal); - expect(result.isError).toBe(true); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("a present-but-invalid token still authenticates the call and honestly reports the rejection", async () => { - // Distinct from the two "underlying call fails" cases above: this is - // GitHub actively rejecting a bound token (bad/expired PAT), not a - // transport error or an unauthenticated-by-design call. The header - // capture proves the token was actually sent -- a 401 here must never - // be silently reinterpreted as "keyless," only as an honest error. - const originalFetch = globalThis.fetch; - const capturedHeaders: (Headers | undefined)[] = []; - globalThis.fetch = (async (_input: unknown, init?: RequestInit) => { - capturedHeaders.push(init?.headers as Headers | undefined); - return new Response(JSON.stringify({ message: "Bad credentials" }), { - status: 401, - }); - }) as unknown as typeof fetch; - try { - const bundle = githubTools(fakeEnv(fakeCredentials("ghp_invalid"))); - const result = await bundle.run(CALL, new AbortController().signal); - expect(result.isError).toBe(true); - expect(capturedHeaders.length).toBeGreaterThan(0); - for (const headers of capturedHeaders) { - expect(headers?.get?.("authorization")).toBe("Bearer ghp_invalid"); - } - } finally { - globalThis.fetch = originalFetch; - } -}); diff --git a/tools/github/src/tool.ts b/tools/github/src/tool.ts deleted file mode 100644 index b90e68bff8..0000000000 --- a/tools/github/src/tool.ts +++ /dev/null @@ -1,124 +0,0 @@ -// The `github_activity` tool: an agent-facing wrapper around `./client.ts` -// that never throws. Unlike `@corbits/granola-tools`, -// `@corbits/web-search-tools`, or `@corbits/reddit-tools`, GitHub's -// search API works keylessly — an unresolved "github" credential just -// means a lower rate limit (60/hr vs 5000/hr with a token), not "not -// connected" — so this tool always attempts the call, mediated or not. A -// failed call (rate-limited, network error) comes back as a completed -// `ToolResult` with `isError: true`; the calling agent's job is to read -// that as "GitHub is unavailable right now" and say so honestly, never to -// have the run itself fail because one source is unreachable. -import { defineTool } from "@intx/agent"; -import type { BaseEnv } from "@intx/agent"; -import type { CredentialCapability } from "@intx/types"; -import type { ToolCall, ToolResult } from "@intx/types/runtime"; - -import { searchGitHubActivity } from "./client"; - -export const GITHUB_ACTIVITY_TOOL = "github_activity"; - -/** The handle this package declares in `interchange.credentials`. */ -const GITHUB_CREDENTIAL_HANDLE = "github"; - -/** - * Env this bundle can use beyond `BaseEnv`: the mediated-credential - * capability, optionally bound to a GitHub token. Unlike every other - * package in this wave, an unresolved credential here is never "not - * connected" — it degrades to an unauthenticated call at GitHub's lower - * rate limit (60/hr vs 5000/hr with a token). - */ -export interface GitHubEnv extends BaseEnv { - readonly credentials?: CredentialCapability; -} - -/** - * Resolve this bundle's mediated GitHub credential, or `null` when none - * is available -- an absent `env.credentials`, an unbound handle, or a - * denied grant all collapse to the same signal. Callers must treat - * `null` as "run unauthenticated," never as an error: GitHub's search - * API works keylessly, just at a lower rate limit. - */ -async function resolveGitHubCredential( - env: GitHubEnv, -): Promise<{ fetchImpl: typeof fetch } | null> { - if (env.credentials === undefined) return null; - try { - const mediated = await env.credentials.resolve(GITHUB_CREDENTIAL_HANDLE); - return { fetchImpl: mediated.fetch as unknown as typeof fetch }; - } catch { - return null; - } -} - -async function runGitHubActivity(env: GitHubEnv, call: ToolCall): Promise { - const query = call.arguments["query"]; - if (typeof query !== "string" || query.trim().length === 0) { - return { - callId: call.id, - content: `${GITHUB_ACTIVITY_TOOL} requires a non-empty query argument`, - isError: true, - }; - } - const credential = await resolveGitHubCredential(env); - const days = call.arguments["days"]; - const limit = call.arguments["limit"]; - try { - const clientConfig = credential !== null ? { fetchImpl: credential.fetchImpl } : {}; - const withDays = typeof days === "number" ? { query, days } : { query }; - const params = typeof limit === "number" ? { ...withDays, limit } : withDays; - const items = await searchGitHubActivity(clientConfig, params); - return { callId: call.id, content: JSON.stringify({ items }) }; - } catch (err) { - return { - callId: call.id, - content: err instanceof Error ? err.message : String(err), - isError: true, - }; - } -} - -/** - * The `@corbits/github-tools` bundle factory: one tool, one optional - * mediated credential (handle "github"). Pin this package on any agent - * that needs recently active GitHub repos, issues, and PRs for a topic - * — works with no credential bound at all, at GitHub's unauthenticated - * rate limit. - */ -export const githubTools = defineTool({ - id: "@corbits/github-tools/gh", - requires: [], - definitions: [{ name: GITHUB_ACTIVITY_TOOL }], - factory: (env) => ({ - definitions: [ - { - name: GITHUB_ACTIVITY_TOOL, - description: - "Searches GitHub for recently active repositories, issues, and " + - "pull requests matching a topic. Scope the search tightly: " + - "pass specific keyword terms and a narrow days window. Do not " + - "include GitHub search qualifiers like is:issue or repo: in " + - "the query; those are added automatically.", - inputSchema: { - type: "object", - properties: { - query: { - type: "string", - description: "Topic keywords to search for.", - }, - days: { - type: "number", - description: "Number of days to look back (default 30).", - }, - limit: { - type: "number", - description: - "Maximum results per category — repos, issues, PRs each (1-25, default 5).", - }, - }, - required: ["query"], - }, - }, - ], - run: (call, _signal) => runGitHubActivity(env, call), - }), -}); diff --git a/tools/github/tsconfig.json b/tools/github/tsconfig.json deleted file mode 100644 index e125e90614..0000000000 --- a/tools/github/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "./tsconfig.src.json", - "compilerOptions": { - "composite": false, - "noEmit": true, - "disableSourceOfProjectReferenceRedirect": true, - "declaration": false, - "declarationMap": false, - "emitDeclarationOnly": false, - "tsBuildInfoFile": "tsconfig.tsbuildinfo", - "rootDir": "../.." - }, - "include": ["src"], - "exclude": [] -} diff --git a/tools/github/tsconfig.src.json b/tools/github/tsconfig.src.json deleted file mode 100644 index fc8724305f..0000000000 --- a/tools/github/tsconfig.src.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "include": ["src", "package.json", "src/**/*.json"], - "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"], - "compilerOptions": { - "types": ["bun"], - "composite": true, - "emitDeclarationOnly": true, - "outDir": "dist", - "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" - } -} diff --git a/tools/gotenberg/LICENSE b/tools/gotenberg/LICENSE deleted file mode 100644 index 173c3a21d0..0000000000 --- a/tools/gotenberg/LICENSE +++ /dev/null @@ -1,176 +0,0 @@ -GNU LESSER GENERAL PUBLIC LICENSE - -Version 2.1, February 1999 - -Copyright (C) 1991, 1999 Free Software Foundation, Inc. -51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - -Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. - -[This is the first released version of the Lesser GPL. It also counts as the successor of the GNU Library Public License, version 2, hence the version number 2.1.] - -Preamble - -The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public Licenses are intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. - -This license, the Lesser General Public License, applies to some specially designated software packages--typically libraries--of the Free Software Foundation and other authors who decide to use it. You can use it too, but we suggest you first think carefully about whether this license or the ordinary General Public License is the better strategy to use in any particular case, based on the explanations below. - -When we speak of free software, we are referring to freedom of use, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish); that you receive source code or can get it if you want it; that you can change the software and use pieces of it in new free programs; and that you are informed that you can do these things. - -To protect your rights, we need to make restrictions that forbid distributors to deny you these rights or to ask you to surrender these rights. These restrictions translate to certain responsibilities for you if you distribute copies of the library or if you modify it. - -For example, if you distribute copies of the library, whether gratis or for a fee, you must give the recipients all the rights that we gave you. You must make sure that they, too, receive or can get the source code. If you link other code with the library, you must provide complete object files to the recipients, so that they can relink them with the library after making changes to the library and recompiling it. And you must show them these terms so they know their rights. - -We protect your rights with a two-step method: (1) we copyright the library, and (2) we offer you this license, which gives you legal permission to copy, distribute and/or modify the library. - -To protect each distributor, we want to make it very clear that there is no warranty for the free library. Also, if the library is modified by someone else and passed on, the recipients should know that what they have is not the original version, so that the original author's reputation will not be affected by problems that might be introduced by others. - -Finally, software patents pose a constant threat to the existence of any free program. We wish to make sure that a company cannot effectively restrict the users of a free program by obtaining a restrictive license from a patent holder. Therefore, we insist that any patent license obtained for a version of the library must be consistent with the full freedom of use specified in this license. - -Most GNU software, including some libraries, is covered by the ordinary GNU General Public License. This license, the GNU Lesser General Public License, applies to certain designated libraries, and is quite different from the ordinary General Public License. We use this license for certain libraries in order to permit linking those libraries into non-free programs. - -When a program is linked with a library, whether statically or using a shared library, the combination of the two is legally speaking a combined work, a derivative of the original library. The ordinary General Public License therefore permits such linking only if the entire combination fits its criteria of freedom. The Lesser General Public License permits more lax criteria for linking other code with the library. - -We call this license the "Lesser" General Public License because it does Less to protect the user's freedom than the ordinary General Public License. It also provides other free software developers Less of an advantage over competing non-free programs. These disadvantages are the reason we use the ordinary General Public License for many libraries. However, the Lesser license provides advantages in certain special circumstances. - -For example, on rare occasions, there may be a special need to encourage the widest possible use of a certain library, so that it becomes a de-facto standard. To achieve this, non-free programs must be allowed to use the library. A more frequent case is that a free library does the same job as widely used non-free libraries. In this case, there is little to gain by limiting the free library to free software only, so we use the Lesser General Public License. - -In other cases, permission to use a particular library in non-free programs enables a greater number of people to use a large body of free software. For example, permission to use the GNU C Library in non-free programs enables many more people to use the whole GNU operating system, as well as its variant, the GNU/Linux operating system. - -Although the Lesser General Public License is Less protective of the users' freedom, it does ensure that the user of a program that is linked with the Library has the freedom and the wherewithal to run that program using a modified version of the Library. - -The precise terms and conditions for copying, distribution and modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, whereas the latter must be combined with the library in order to run. - -GNU LESSER GENERAL PUBLIC LICENSE -TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - -0. This License Agreement applies to any software library or other program which contains a notice placed by the copyright holder or other authorized party saying it may be distributed under the terms of this Lesser General Public License (also called "this License"). Each licensee is addressed as "you". - -A "library" means a collection of software functions and/or data prepared so as to be conveniently linked with application programs (which use some of those functions and data) to form executables. - -The "Library", below, refers to any such software library or work which has been distributed under these terms. A "work based on the Library" means either the Library or any derivative work under copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with modifications and/or translated straightforwardly into another language. (Hereinafter, translation is included without limitation in the term "modification".) - -"Source code" for a work means the preferred form of the work for making modifications to it. For a library, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library. - -Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running a program using the Library is not restricted, and output from such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does. - -1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and distribute a copy of this License along with the Library. - -You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. - -2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: - - a) The modified work must itself be a software library. - - b) You must cause the files modified to carry prominent notices stating that you changed the files and the date of any change. - - c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms of this License. - - d) If a facility in the modified Library refers to a function or a table of data to be supplied by an application program that uses the facility, other than as an argument passed when the facility is invoked, then you must make a good faith effort to ensure that, in the event an application does not supply such function or table, the facility still operates, and performs whatever part of its purpose remains meaningful. - -(For example, a function in a library to compute square roots has a purpose that is entirely well-defined independent of the application. Therefore, Subsection 2d requires that any application-supplied function or table used by this function must be optional: if the application does not supply it, the square root function must still compute square roots.) - -These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Library, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Library, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. - -Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Library. - -In addition, mere aggregation of another work not based on the Library with the Library (or with a work based on the Library) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. - -3. You may opt to apply the terms of the ordinary GNU General Public License instead of this License to a given copy of the Library. To do this, you must alter all the notices that refer to this License, so that they refer to the ordinary GNU General Public License, version 2, instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. - -Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. - -This option is useful when you wish to copy part of the code of the Library into a program that is not a library. - -4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange. - -If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code, even though third parties are not compelled to copy the source along with the object code. - -5. A program that contains no derivative of any portion of the Library, but is designed to work with the Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in isolation, is not a derivative work of the Library, and therefore falls outside the scope of this License. - -However, linking a "work that uses the Library" with the Library creates an executable that is a derivative of the Library (because it contains portions of the Library), rather than a "work that uses the library". The executable is therefore covered by this License. Section 6 states terms for distribution of such executables. - -When a "work that uses the Library" uses material from a header file that is part of the Library, the object code for the work may be a derivative work of the Library even though the source code is not. Whether this is true is especially significant if the work can be linked without the Library, or if the work is itself a library. The threshold for this to be true is not precisely defined by law. - -If such an object file uses only numerical parameters, data structure layouts and accessors, and small macros and small inline functions (ten lines or less in length), then the use of the object file is unrestricted, regardless of whether it is legally a derivative work. (Executables containing this object code plus portions of the Library will still fall under Section 6.) - -Otherwise, if the work is a derivative of the Library, you may distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. - -6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work under terms of your choice, provided that the terms permit modification of the work for the customer's own use and reverse engineering for debugging such modifications. - -You must give prominent notice with each copy of the work that the Library is used in it and that the Library and its use are covered by this License. You must supply a copy of this License. If the work during execution displays copyright notices, you must include the copyright notice for the Library among them, as well as a reference directing the user to the copy of this License. Also, you must do one of these things: - - a) Accompany the work with the complete corresponding machine-readable source code for the Library including whatever changes were used in the work (which must be distributed under Sections 1 and 2 above); and, if the work is an executable linked with the Library, with the complete machine-readable "work that uses the Library", as object code and/or source code, so that the user can modify the Library and then relink to produce a modified executable containing the modified Library. (It is understood that the user who changes the contents of definitions files in the Library will not necessarily be able to recompile the application to use the modified definitions.) - - b) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (1) uses at run time a copy of the library already present on the user's computer system, rather than copying library functions into the executable, and (2) will operate properly with a modified version of the library, if the user installs one, as long as the modified version is interface-compatible with the version that the work was made with. - - c) Accompany the work with a written offer, valid for at least three years, to give the same user the materials specified in Subsection 6a, above, for a charge no more than the cost of performing this distribution. - - d) If distribution of the work is made by offering access to copy from a designated place, offer equivalent access to copy the above specified materials from the same place. - - e) Verify that the user has already received a copy of these materials or that you have already sent this user a copy. - -For an executable, the required form of the "work that uses the Library" must include any data and utility programs needed for reproducing the executable from it. However, as a special exception, the materials to be distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. - -It may happen that this requirement contradicts the license restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. - -7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined library, provided that the separate distribution of the work based on the Library and of the other library facilities is otherwise permitted, and provided that you do these two things: - - a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities. This must be distributed under the terms of the Sections above. - - b) Give prominent notice with the combined library of the fact that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. - -8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, link with, or distribute the Library is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. - -9. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Library or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Library (or any work based on the Library), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Library or works based on it. - -10. Each time you redistribute the Library (or any work based on the Library), the recipient automatically receives a license from the original licensor to copy, distribute, link with or modify the Library subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties with this License. - -11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Library at all. For example, if a patent license would not permit royalty-free redistribution of the Library by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Library. - -If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply, and the section as a whole is intended to apply in other circumstances. - -It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. - -This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. - -12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Library under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. - -13. The Free Software Foundation may publish revised and/or new versions of the Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. - -Each version is given a distinguishing version number. If the Library specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. - -14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. - -NO WARRANTY - -15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - -16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. - -END OF TERMS AND CONDITIONS - -How to Apply These Terms to Your New Libraries - -If you develop a new library, and you want it to be of the greatest possible use to the public, we recommend making it free software that everyone can redistribute and change. You can do so by permitting redistribution under these terms (or, alternatively, under the terms of the ordinary General Public License). - -To apply these terms, attach the following notices to the library. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. - - one line to give the library's name and an idea of what it does. - Copyright (C) year name of author - - This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; version 2.1 of the License. - - This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Also add information on how to contact you by electronic and paper mail. - -You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the library, if necessary. Here is a sample; alter the names: - -Yoyodyne, Inc., hereby disclaims all copyright interest in -the library `Frob' (a library for tweaking knobs) written -by James Random Hacker. - -signature of Ty Coon, 1 April 1990 -Ty Coon, President of Vice -That's all there is to it! diff --git a/tools/gotenberg/README.md b/tools/gotenberg/README.md deleted file mode 100644 index a72626e714..0000000000 --- a/tools/gotenberg/README.md +++ /dev/null @@ -1,58 +0,0 @@ -# @corbits/gotenberg - -Renders a Markdown document to PDF through an operator-configured -[Gotenberg](https://gotenberg.dev) server: a thin HTTP client and one -`@intx/agent` tool bundle. - -## Tools - -- `gotenberg_render_pdf` — renders a Markdown document (`title`, - `markdown`) to PDF and returns it as base64-encoded bytes plus a - filename and MIME type. - -## Credential - -The bundle's env carries `credentials`, the harness's consumer-gated -`CredentialCapability`. When it is absent, or -`credentials.resolve("gotenberg")` throws — no bound handle, or a grant -that doesn't authorize this consumer — the tool never throws itself: it -returns a completed result with `isError: true` and content naming the -source "not connected". - -This package declares one credential handle, `gotenberg`, in its -`package.json`'s `interchange.credentials` field. The bound credential's -origin is the operator's Gotenberg server address — connect it once in -Settings · Connections, and every workflow that pins `@corbits/gotenberg` -and binds this handle resolves the same bench-owned credential. - -## Running Gotenberg locally - -```sh -docker compose up -d gotenberg -``` - -or directly: - -```sh -docker run --rm -p 3010:3000 gotenberg/gotenberg:8 -``` - -Gotenberg is stateless: no migrations, no shared state, scales -horizontally. - -## Usage - -```ts -import { gotenbergTools } from "@corbits/gotenberg"; - -const agent = defineAgent({ - // ... - tools: [gotenbergTools], -}); -``` - -## Running tests - -``` -cd tools/gotenberg && bun test -``` diff --git a/tools/gotenberg/package.json b/tools/gotenberg/package.json deleted file mode 100644 index 422fca5d84..0000000000 --- a/tools/gotenberg/package.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "name": "@corbits/gotenberg", - "version": "0.0.1", - "description": "Renders Markdown to PDF through an operator-configured Gotenberg server: a minimal client and an @intx/agent tool bundle exposing gotenberg_render_pdf", - "license": "LGPL-2.1-or-later", - "repository": { - "type": "git", - "url": "https://github.com/corbitsdev/workbench.git", - "directory": "tools/gotenberg" - }, - "files": [ - "src", - "LICENSE", - "README.md" - ], - "type": "module", - "exports": { - ".": "./src/index.ts" - }, - "publishConfig": { - "access": "public" - }, - "scripts": { - "typecheck": "tsc --noEmit", - "test": "bun test" - }, - "devDependencies": { - "@types/bun": "catalog:", - "arktype": "catalog:", - "typescript": "catalog:" - }, - "peerDependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0" - }, - "interchange": { - "credentials": [ - { - "handle": "gotenberg" - } - ] - } -} diff --git a/tools/gotenberg/src/client.test.ts b/tools/gotenberg/src/client.test.ts deleted file mode 100644 index 695f04237d..0000000000 --- a/tools/gotenberg/src/client.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { GotenbergRenderError, renderMarkdownToPdf } from "./client"; - -describe("renderMarkdownToPdf", () => { - test("posts the markdown and index template as a relative request", async () => { - const pdfBytes = new Uint8Array([0x25, 0x50, 0x44, 0x46]); - let capturedUrl: string | undefined; - let capturedForm: FormData | undefined; - const fetchStub = async (url: string | URL | Request, init?: RequestInit) => { - capturedUrl = String(url); - capturedForm = init?.body as FormData; - return new Response(pdfBytes, { status: 200 }); - }; - - const result = await renderMarkdownToPdf( - { title: "Acme Diligence Brief", markdown: "# Hello" }, - fetchStub, - ); - - expect(result).toEqual(pdfBytes); - expect(capturedUrl).toBe("/forms/chromium/convert/markdown"); - const files = capturedForm?.getAll("files") ?? []; - expect(files).toHaveLength(2); - }); - - test("wraps a network failure in GotenbergRenderError", async () => { - const fetchStub = async () => { - throw new Error("ECONNREFUSED"); - }; - - await expect( - renderMarkdownToPdf({ title: "t", markdown: "m" }, fetchStub), - ).rejects.toBeInstanceOf(GotenbergRenderError); - }); - - test("wraps a non-2xx response in GotenbergRenderError", async () => { - const fetchStub = async () => new Response("boom", { status: 500 }); - - await expect( - renderMarkdownToPdf({ title: "t", markdown: "m" }, fetchStub), - ).rejects.toBeInstanceOf(GotenbergRenderError); - }); -}); diff --git a/tools/gotenberg/src/client.ts b/tools/gotenberg/src/client.ts deleted file mode 100644 index 7ac5769a10..0000000000 --- a/tools/gotenberg/src/client.ts +++ /dev/null @@ -1,64 +0,0 @@ -// Talks to one Gotenberg server (https://gotenberg.dev) over its Chromium -// module's Markdown route: POST an `index.html` that inlines a Markdown -// file via Gotenberg's `toHTML` template helper, get a rendered PDF back. -// Gotenberg is stateless and holds no workbench data of its own — every -// call is a one-shot conversion, nothing to provision or migrate. The -// caller's `fetchImpl` is expected to already be pinned to the operator's -// Gotenberg origin (the mediated credential's `fetch`), so this module -// only ever requests a relative path. -export type GotenbergFetch = ( - input: string | URL | Request, - init?: RequestInit, -) => Promise; - -const MARKDOWN_ROUTE = "/forms/chromium/convert/markdown"; - -function escapeHtml(value: string): string { - return value - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """); -} - -function indexTemplateFor(title: string): string { - return ( - `` + - `${escapeHtml(title)}` + - `{{ toHTML "body.md" }}` - ); -} - -export class GotenbergRenderError extends Error {} - -/** - * Converts one Markdown document to PDF bytes via a Gotenberg server. - * Throws `GotenbergRenderError` on any transport or non-2xx failure. - */ -export async function renderMarkdownToPdf( - input: { readonly title: string; readonly markdown: string }, - fetchImpl: GotenbergFetch, -): Promise { - const form = new FormData(); - form.append( - "files", - new Blob([indexTemplateFor(input.title)], { type: "text/html" }), - "index.html", - ); - form.append("files", new Blob([input.markdown], { type: "text/markdown" }), "body.md"); - - let response: Response; - try { - response = await fetchImpl(MARKDOWN_ROUTE, { method: "POST", body: form }); - } catch (err) { - throw new GotenbergRenderError( - `Could not reach Gotenberg: ${err instanceof Error ? err.message : String(err)}`, - ); - } - if (!response.ok) { - throw new GotenbergRenderError( - `Gotenberg rejected the render: ${response.status} ${response.statusText}`, - ); - } - return new Uint8Array(await response.arrayBuffer()); -} diff --git a/tools/gotenberg/src/index.ts b/tools/gotenberg/src/index.ts deleted file mode 100644 index f2411891ea..0000000000 --- a/tools/gotenberg/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { renderMarkdownToPdf, GotenbergRenderError } from "./client"; -export type { GotenbergFetch } from "./client"; -export { GOTENBERG_RENDER_PDF_TOOL, gotenbergTools } from "./tool"; -export type { GotenbergEnv } from "./tool"; diff --git a/tools/gotenberg/src/manifest.test.ts b/tools/gotenberg/src/manifest.test.ts deleted file mode 100644 index bdeb728f8f..0000000000 --- a/tools/gotenberg/src/manifest.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -// Proves this package's own `package.json` satisfies the vendored -// `PackageJSON` manifest schema (`@intx/types/src/package-json.ts`) with -// an `interchange.credentials` declaration the deploy-time -// `ToolPackageManifest` harvests into `topLevel[].credentials`. -import { expect, test } from "bun:test"; -import { type } from "arktype"; -import { PackageJSON } from "@intx/types/package-json"; - -import packageJson from "../package.json"; - -test("package.json parses against the vendored PackageJSON manifest schema", () => { - const parsed = PackageJSON(packageJson); - expect(parsed instanceof type.errors).toBe(false); -}); - -test("declares exactly one credential handle: gotenberg", () => { - const parsed = PackageJSON(packageJson); - if (parsed instanceof type.errors) { - throw new Error(parsed.summary); - } - expect(parsed.interchange?.credentials).toEqual([{ handle: "gotenberg" }]); -}); diff --git a/tools/gotenberg/src/tool.test.ts b/tools/gotenberg/src/tool.test.ts deleted file mode 100644 index f0ab9fc808..0000000000 --- a/tools/gotenberg/src/tool.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { expect, test } from "bun:test"; -import type { ToolCall } from "@intx/types/runtime"; -import type { CredentialCapability, MediatedCredential } from "@intx/types"; - -import { GOTENBERG_RENDER_PDF_TOOL, gotenbergTools } from "./tool"; -import type { GotenbergEnv } from "./tool"; - -const CALL: ToolCall = { - id: "call_1", - name: GOTENBERG_RENDER_PDF_TOOL, - arguments: { title: "Acme Brief", markdown: "# Hello" }, -}; - -/** - * A fake `credentials` capability mirroring the platform's own - * `createCredentialCapability`/`createHttpCredentialProvider` shape: a - * bound handle resolves to a mediated `fetch` pinned to the operator's - * Gotenberg origin; an unbound handle throws. - */ -type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise; - -function fakeCredentials(fetchImpl: FetchLike | undefined): CredentialCapability { - return { - resolve(handle: string): Promise { - if (fetchImpl === undefined) { - return Promise.reject(new Error(`no credential is bound to handle "${handle}"`)); - } - return Promise.resolve({ - kind: "http", - fetch: fetchImpl, - dispose: () => {}, - }); - }, - }; -} - -function fakeEnv(credentials: CredentialCapability | undefined): GotenbergEnv { - return { credentials } as unknown as GotenbergEnv; -} - -test("declares the gotenberg_render_pdf tool", () => { - const bundle = gotenbergTools(fakeEnv(fakeCredentials(async () => new Response()))); - expect(bundle.definitions.map((d) => d.name)).toEqual([GOTENBERG_RENDER_PDF_TOOL]); -}); - -test("degrades to a non-throwing 'not connected' error when no credential is bound", async () => { - const bundle = gotenbergTools(fakeEnv(fakeCredentials(undefined))); - const result = await bundle.run(CALL, new AbortController().signal); - expect(result.isError).toBe(true); - expect(result.content).toMatch(/not connected/i); -}); - -test("degrades the same way when the step carries no credentials capability at all", async () => { - const bundle = gotenbergTools(fakeEnv(undefined)); - const result = await bundle.run(CALL, new AbortController().signal); - expect(result.isError).toBe(true); - expect(result.content).toMatch(/not connected/i); -}); - -test("rejects a missing markdown argument without calling the network", async () => { - let called = false; - const bundle = gotenbergTools( - fakeEnv( - fakeCredentials(async () => { - called = true; - return new Response(); - }), - ), - ); - const result = await bundle.run( - { id: "call_2", name: GOTENBERG_RENDER_PDF_TOOL, arguments: { title: "t" } }, - new AbortController().signal, - ); - expect(called).toBe(false); - expect(result.isError).toBe(true); - expect(result.content).toContain("markdown"); -}); - -test("returns the rendered PDF as base64 content on a successful call", async () => { - const pdfBytes = new Uint8Array([0x25, 0x50, 0x44, 0x46]); - const bundle = gotenbergTools( - fakeEnv(fakeCredentials(async () => new Response(pdfBytes, { status: 200 }))), - ); - const result = await bundle.run(CALL, new AbortController().signal); - expect(result.isError).toBeUndefined(); - const parsed = JSON.parse(result.content as string) as { - filename: string; - mimeType: string; - pdfBase64: string; - }; - expect(parsed.filename).toBe("Acme Brief.pdf"); - expect(parsed.mimeType).toBe("application/pdf"); - expect(Buffer.from(parsed.pdfBase64, "base64")).toEqual(Buffer.from(pdfBytes)); -}); - -test("degrades to an error result (never throws) when the underlying call fails", async () => { - const bundle = gotenbergTools( - fakeEnv(fakeCredentials(async () => new Response("nope", { status: 500 }))), - ); - const result = await bundle.run(CALL, new AbortController().signal); - expect(result.isError).toBe(true); -}); diff --git a/tools/gotenberg/src/tool.ts b/tools/gotenberg/src/tool.ts deleted file mode 100644 index 161eefed6f..0000000000 --- a/tools/gotenberg/src/tool.ts +++ /dev/null @@ -1,135 +0,0 @@ -// The `gotenberg_render_pdf` tool: an agent-facing wrapper around -// `./client.ts` that never throws. A missing credential or a failed -// render both come back as a completed `ToolResult` with `isError: -// true` — the calling agent's job is to read that as "PDF rendering is -// not available right now" and say so honestly, never to have the run -// itself fail because Gotenberg is unreachable. -// -// This package declares its "gotenberg" credential handle in -// `package.json` (`interchange.credentials`); wires the runtime half. -// The bound credential's origin is the operator-configured Gotenberg -// server address — no `GOTENBERG_URL` env var, no hub-level wiring. -import { defineTool } from "@intx/agent"; -import type { BaseEnv } from "@intx/agent"; -import type { CredentialCapability } from "@intx/types"; -import type { ToolCall, ToolResult } from "@intx/types/runtime"; - -import { renderMarkdownToPdf, type GotenbergFetch } from "./client"; - -export const GOTENBERG_RENDER_PDF_TOOL = "gotenberg_render_pdf"; - -/** The handle this package declares in `interchange.credentials`. */ -const GOTENBERG_CREDENTIAL_HANDLE = "gotenberg"; - -/** Env this bundle needs beyond `BaseEnv`: the mediated-credential capability. */ -export interface GotenbergEnv extends BaseEnv { - readonly credentials?: CredentialCapability; -} - -function notConnectedResult(callId: string): ToolResult { - return { - callId, - content: "Gotenberg is not connected for this bench.", - isError: true, - }; -} - -/** - * Resolve this bundle's mediated Gotenberg credential, or `null` when it - * is not connected -- an absent `env.credentials`, an unbound handle, or - * a denied grant all collapse to the same "not connected" signal, never - * a thrown error out of the tool. - */ -async function resolveGotenbergFetch(env: GotenbergEnv): Promise { - if (env.credentials === undefined) return null; - try { - const mediated = await env.credentials.resolve(GOTENBERG_CREDENTIAL_HANDLE); - return mediated.fetch as unknown as GotenbergFetch; - } catch { - return null; - } -} - -function base64Encode(bytes: Uint8Array): string { - return Buffer.from(bytes).toString("base64"); -} - -async function runGotenbergRenderPdf(env: GotenbergEnv, call: ToolCall): Promise { - const fetchImpl = await resolveGotenbergFetch(env); - if (fetchImpl === null) { - return notConnectedResult(call.id); - } - const title = call.arguments["title"]; - const markdown = call.arguments["markdown"]; - if (typeof title !== "string" || title === "") { - return { - callId: call.id, - content: `${GOTENBERG_RENDER_PDF_TOOL} requires a non-empty title argument`, - isError: true, - }; - } - if (typeof markdown !== "string" || markdown === "") { - return { - callId: call.id, - content: `${GOTENBERG_RENDER_PDF_TOOL} requires a non-empty markdown argument`, - isError: true, - }; - } - try { - const bytes = await renderMarkdownToPdf({ title, markdown }, fetchImpl); - return { - callId: call.id, - content: JSON.stringify({ - filename: `${title}.pdf`, - mimeType: "application/pdf", - pdfBase64: base64Encode(bytes), - }), - }; - } catch (err) { - return { - callId: call.id, - content: err instanceof Error ? err.message : String(err), - isError: true, - }; - } -} - -/** - * The `@corbits/gotenberg` bundle factory: one tool that renders a - * Markdown document to PDF through the caller's mediated Gotenberg - * credential. Pin this package on any agent that needs to turn Markdown - * (a brief, a report) into a downloadable PDF. - */ -export const gotenbergTools = defineTool({ - id: "@corbits/gotenberg/pdf", - requires: ["credentials"], - definitions: [{ name: GOTENBERG_RENDER_PDF_TOOL }], - factory: (env) => ({ - definitions: [ - { - name: GOTENBERG_RENDER_PDF_TOOL, - description: - "Renders a Markdown document to PDF through the bench's configured " + - "Gotenberg server. Returns the PDF as base64-encoded bytes plus a " + - 'filename. Returns an error result naming "not connected" when no ' + - "Gotenberg credential is configured — never fabricate a PDF when " + - "this happens.", - inputSchema: { - type: "object", - properties: { - title: { - type: "string", - description: "Document title, used for the PDF's and filename.", - }, - markdown: { - type: "string", - description: "The Markdown source to render.", - }, - }, - required: ["title", "markdown"], - }, - }, - ], - run: (call, _signal) => runGotenbergRenderPdf(env, call), - }), -}); diff --git a/tools/gotenberg/tsconfig.json b/tools/gotenberg/tsconfig.json deleted file mode 100644 index e125e90614..0000000000 --- a/tools/gotenberg/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "./tsconfig.src.json", - "compilerOptions": { - "composite": false, - "noEmit": true, - "disableSourceOfProjectReferenceRedirect": true, - "declaration": false, - "declarationMap": false, - "emitDeclarationOnly": false, - "tsBuildInfoFile": "tsconfig.tsbuildinfo", - "rootDir": "../.." - }, - "include": ["src"], - "exclude": [] -} diff --git a/tools/gotenberg/tsconfig.src.json b/tools/gotenberg/tsconfig.src.json deleted file mode 100644 index fc8724305f..0000000000 --- a/tools/gotenberg/tsconfig.src.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "include": ["src", "package.json", "src/**/*.json"], - "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"], - "compilerOptions": { - "types": ["bun"], - "composite": true, - "emitDeclarationOnly": true, - "outDir": "dist", - "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" - } -} diff --git a/tools/granola/LICENSE b/tools/granola/LICENSE deleted file mode 100644 index 173c3a21d0..0000000000 --- a/tools/granola/LICENSE +++ /dev/null @@ -1,176 +0,0 @@ -GNU LESSER GENERAL PUBLIC LICENSE - -Version 2.1, February 1999 - -Copyright (C) 1991, 1999 Free Software Foundation, Inc. -51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - -Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. - -[This is the first released version of the Lesser GPL. It also counts as the successor of the GNU Library Public License, version 2, hence the version number 2.1.] - -Preamble - -The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public Licenses are intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. - -This license, the Lesser General Public License, applies to some specially designated software packages--typically libraries--of the Free Software Foundation and other authors who decide to use it. You can use it too, but we suggest you first think carefully about whether this license or the ordinary General Public License is the better strategy to use in any particular case, based on the explanations below. - -When we speak of free software, we are referring to freedom of use, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish); that you receive source code or can get it if you want it; that you can change the software and use pieces of it in new free programs; and that you are informed that you can do these things. - -To protect your rights, we need to make restrictions that forbid distributors to deny you these rights or to ask you to surrender these rights. These restrictions translate to certain responsibilities for you if you distribute copies of the library or if you modify it. - -For example, if you distribute copies of the library, whether gratis or for a fee, you must give the recipients all the rights that we gave you. You must make sure that they, too, receive or can get the source code. If you link other code with the library, you must provide complete object files to the recipients, so that they can relink them with the library after making changes to the library and recompiling it. And you must show them these terms so they know their rights. - -We protect your rights with a two-step method: (1) we copyright the library, and (2) we offer you this license, which gives you legal permission to copy, distribute and/or modify the library. - -To protect each distributor, we want to make it very clear that there is no warranty for the free library. Also, if the library is modified by someone else and passed on, the recipients should know that what they have is not the original version, so that the original author's reputation will not be affected by problems that might be introduced by others. - -Finally, software patents pose a constant threat to the existence of any free program. We wish to make sure that a company cannot effectively restrict the users of a free program by obtaining a restrictive license from a patent holder. Therefore, we insist that any patent license obtained for a version of the library must be consistent with the full freedom of use specified in this license. - -Most GNU software, including some libraries, is covered by the ordinary GNU General Public License. This license, the GNU Lesser General Public License, applies to certain designated libraries, and is quite different from the ordinary General Public License. We use this license for certain libraries in order to permit linking those libraries into non-free programs. - -When a program is linked with a library, whether statically or using a shared library, the combination of the two is legally speaking a combined work, a derivative of the original library. The ordinary General Public License therefore permits such linking only if the entire combination fits its criteria of freedom. The Lesser General Public License permits more lax criteria for linking other code with the library. - -We call this license the "Lesser" General Public License because it does Less to protect the user's freedom than the ordinary General Public License. It also provides other free software developers Less of an advantage over competing non-free programs. These disadvantages are the reason we use the ordinary General Public License for many libraries. However, the Lesser license provides advantages in certain special circumstances. - -For example, on rare occasions, there may be a special need to encourage the widest possible use of a certain library, so that it becomes a de-facto standard. To achieve this, non-free programs must be allowed to use the library. A more frequent case is that a free library does the same job as widely used non-free libraries. In this case, there is little to gain by limiting the free library to free software only, so we use the Lesser General Public License. - -In other cases, permission to use a particular library in non-free programs enables a greater number of people to use a large body of free software. For example, permission to use the GNU C Library in non-free programs enables many more people to use the whole GNU operating system, as well as its variant, the GNU/Linux operating system. - -Although the Lesser General Public License is Less protective of the users' freedom, it does ensure that the user of a program that is linked with the Library has the freedom and the wherewithal to run that program using a modified version of the Library. - -The precise terms and conditions for copying, distribution and modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, whereas the latter must be combined with the library in order to run. - -GNU LESSER GENERAL PUBLIC LICENSE -TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - -0. This License Agreement applies to any software library or other program which contains a notice placed by the copyright holder or other authorized party saying it may be distributed under the terms of this Lesser General Public License (also called "this License"). Each licensee is addressed as "you". - -A "library" means a collection of software functions and/or data prepared so as to be conveniently linked with application programs (which use some of those functions and data) to form executables. - -The "Library", below, refers to any such software library or work which has been distributed under these terms. A "work based on the Library" means either the Library or any derivative work under copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with modifications and/or translated straightforwardly into another language. (Hereinafter, translation is included without limitation in the term "modification".) - -"Source code" for a work means the preferred form of the work for making modifications to it. For a library, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library. - -Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running a program using the Library is not restricted, and output from such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does. - -1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and distribute a copy of this License along with the Library. - -You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. - -2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: - - a) The modified work must itself be a software library. - - b) You must cause the files modified to carry prominent notices stating that you changed the files and the date of any change. - - c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms of this License. - - d) If a facility in the modified Library refers to a function or a table of data to be supplied by an application program that uses the facility, other than as an argument passed when the facility is invoked, then you must make a good faith effort to ensure that, in the event an application does not supply such function or table, the facility still operates, and performs whatever part of its purpose remains meaningful. - -(For example, a function in a library to compute square roots has a purpose that is entirely well-defined independent of the application. Therefore, Subsection 2d requires that any application-supplied function or table used by this function must be optional: if the application does not supply it, the square root function must still compute square roots.) - -These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Library, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Library, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. - -Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Library. - -In addition, mere aggregation of another work not based on the Library with the Library (or with a work based on the Library) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. - -3. You may opt to apply the terms of the ordinary GNU General Public License instead of this License to a given copy of the Library. To do this, you must alter all the notices that refer to this License, so that they refer to the ordinary GNU General Public License, version 2, instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. - -Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. - -This option is useful when you wish to copy part of the code of the Library into a program that is not a library. - -4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange. - -If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code, even though third parties are not compelled to copy the source along with the object code. - -5. A program that contains no derivative of any portion of the Library, but is designed to work with the Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in isolation, is not a derivative work of the Library, and therefore falls outside the scope of this License. - -However, linking a "work that uses the Library" with the Library creates an executable that is a derivative of the Library (because it contains portions of the Library), rather than a "work that uses the library". The executable is therefore covered by this License. Section 6 states terms for distribution of such executables. - -When a "work that uses the Library" uses material from a header file that is part of the Library, the object code for the work may be a derivative work of the Library even though the source code is not. Whether this is true is especially significant if the work can be linked without the Library, or if the work is itself a library. The threshold for this to be true is not precisely defined by law. - -If such an object file uses only numerical parameters, data structure layouts and accessors, and small macros and small inline functions (ten lines or less in length), then the use of the object file is unrestricted, regardless of whether it is legally a derivative work. (Executables containing this object code plus portions of the Library will still fall under Section 6.) - -Otherwise, if the work is a derivative of the Library, you may distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. - -6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work under terms of your choice, provided that the terms permit modification of the work for the customer's own use and reverse engineering for debugging such modifications. - -You must give prominent notice with each copy of the work that the Library is used in it and that the Library and its use are covered by this License. You must supply a copy of this License. If the work during execution displays copyright notices, you must include the copyright notice for the Library among them, as well as a reference directing the user to the copy of this License. Also, you must do one of these things: - - a) Accompany the work with the complete corresponding machine-readable source code for the Library including whatever changes were used in the work (which must be distributed under Sections 1 and 2 above); and, if the work is an executable linked with the Library, with the complete machine-readable "work that uses the Library", as object code and/or source code, so that the user can modify the Library and then relink to produce a modified executable containing the modified Library. (It is understood that the user who changes the contents of definitions files in the Library will not necessarily be able to recompile the application to use the modified definitions.) - - b) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (1) uses at run time a copy of the library already present on the user's computer system, rather than copying library functions into the executable, and (2) will operate properly with a modified version of the library, if the user installs one, as long as the modified version is interface-compatible with the version that the work was made with. - - c) Accompany the work with a written offer, valid for at least three years, to give the same user the materials specified in Subsection 6a, above, for a charge no more than the cost of performing this distribution. - - d) If distribution of the work is made by offering access to copy from a designated place, offer equivalent access to copy the above specified materials from the same place. - - e) Verify that the user has already received a copy of these materials or that you have already sent this user a copy. - -For an executable, the required form of the "work that uses the Library" must include any data and utility programs needed for reproducing the executable from it. However, as a special exception, the materials to be distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. - -It may happen that this requirement contradicts the license restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. - -7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined library, provided that the separate distribution of the work based on the Library and of the other library facilities is otherwise permitted, and provided that you do these two things: - - a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities. This must be distributed under the terms of the Sections above. - - b) Give prominent notice with the combined library of the fact that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. - -8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, link with, or distribute the Library is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. - -9. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Library or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Library (or any work based on the Library), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Library or works based on it. - -10. Each time you redistribute the Library (or any work based on the Library), the recipient automatically receives a license from the original licensor to copy, distribute, link with or modify the Library subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties with this License. - -11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Library at all. For example, if a patent license would not permit royalty-free redistribution of the Library by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Library. - -If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply, and the section as a whole is intended to apply in other circumstances. - -It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. - -This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. - -12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Library under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. - -13. The Free Software Foundation may publish revised and/or new versions of the Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. - -Each version is given a distinguishing version number. If the Library specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. - -14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. - -NO WARRANTY - -15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - -16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. - -END OF TERMS AND CONDITIONS - -How to Apply These Terms to Your New Libraries - -If you develop a new library, and you want it to be of the greatest possible use to the public, we recommend making it free software that everyone can redistribute and change. You can do so by permitting redistribution under these terms (or, alternatively, under the terms of the ordinary General Public License). - -To apply these terms, attach the following notices to the library. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. - - one line to give the library's name and an idea of what it does. - Copyright (C) year name of author - - This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; version 2.1 of the License. - - This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Also add information on how to contact you by electronic and paper mail. - -You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the library, if necessary. Here is a sample; alter the names: - -Yoyodyne, Inc., hereby disclaims all copyright interest in -the library `Frob' (a library for tweaking knobs) written -by James Random Hacker. - -signature of Ty Coon, 1 April 1990 -Ty Coon, President of Vice -That's all there is to it! diff --git a/tools/granola/README.md b/tools/granola/README.md deleted file mode 100644 index a8ae002360..0000000000 --- a/tools/granola/README.md +++ /dev/null @@ -1,64 +0,0 @@ -# @corbits/granola-tools - -A minimal Granola integration: two client calls, one `@intx/agent` tool -bundle. Built for the morning-brief workflow; `granola_get_note` -was added by the pain-point-collateral workflow — neither is -specific to its origin workflow, so both stay in this one package rather -than forking a second Granola client. - -## Tools - -- `granola_list_recent_notes` — lists the caller's recent call notes - (`id`, `title`, `summary`, `createdAt`), optionally filtered to notes - created after a given ISO 8601 timestamp. -- `granola_get_note` — fetches one call note by id, including its - transcript (`speaker`/`text` lines). - -## Credential - -The bundle's env carries `credentials`, the harness's consumer-gated -`CredentialCapability` (`@intx/harness/src/credential-capability.ts`). -When it is absent, or `credentials.resolve("granola")` throws — no bound -handle, or a grant that doesn't authorize this consumer — the tool never -throws itself: it returns a completed result with `isError: true` and -content naming the source "not connected", so a calling agent can -degrade gracefully instead of failing its turn. - -This package declares one credential handle, `granola`, in its -`package.json`'s `interchange.credentials` field. Connect -Granola once, in Settings · Connections, and every workflow that pins -`@corbits/granola-tools` and binds this handle — `granola-call`, -`process-granola-call`, `pain-point-collateral`, `collateral-generation`, -`morning-brief` — resolves the same tenant-owned credential at launch; -no per-workflow reconnection. - -Launch-time resolution (`buildCredentialDelivery`) is proven in -`test/credential-delivery.drizzle.test.ts`; the full chain — seeded -credential through the sidecar's step wiring -(`apps/sidecar/src/step-agent-tools.ts`) to this bundle's tool call — is -proven in `test/credential-wiring-e2e.drizzle.test.ts`. See -`docs/credential-wiring.md` for the end-to-end picture and the provider -plugins involved (this package's Granola credential uses the vendored -`http` / Bearer plugin; `@corbits/linear-tools`'s Linear credential does -not). - -## Usage - -```ts -import { granolaTools } from "@corbits/granola-tools"; - -const agent = defineAgent({ - // ... - tools: [granolaTools], -}); -``` - -## Running tests - -``` -cd tools/granola && bun test -``` - -`test/credential-delivery.drizzle.test.ts` and -`test/credential-wiring-e2e.drizzle.test.ts` need a live Postgres: -`DATABASE_URL=postgres://localhost:5432/workbench_e2e`. diff --git a/tools/granola/package.json b/tools/granola/package.json deleted file mode 100644 index 2ee059f323..0000000000 --- a/tools/granola/package.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "name": "@corbits/granola-tools", - "version": "0.0.5", - "description": "Granola integration: a minimal client and an @intx/agent tool bundle for listing a user's recent call notes", - "license": "LGPL-2.1-only", - "repository": { - "type": "git", - "url": "https://github.com/corbitsdev/workbench.git", - "directory": "tools/granola" - }, - "files": [ - "src", - "LICENSE", - "README.md" - ], - "type": "module", - "exports": { - ".": "./src/index.ts" - }, - "publishConfig": { - "access": "public" - }, - "scripts": { - "typecheck": "tsc --noEmit", - "test": "bun test" - }, - "dependencies": { - "arktype": "catalog:" - }, - "devDependencies": { - "@intx/authz": "0.3.0", - "@intx/crypto": "0.3.0", - "@intx/db": "workspace:*", - "@intx/harness": "workspace:*", - "@types/bun": "catalog:", - "typescript": "catalog:" - }, - "peerDependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0" - }, - "interchange": { - "credentials": [ - { - "handle": "granola" - } - ] - } -} diff --git a/tools/granola/src/client.test.ts b/tools/granola/src/client.test.ts deleted file mode 100644 index 6134786fe2..0000000000 --- a/tools/granola/src/client.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { expect, test } from "bun:test"; - -import { getGranolaNote, listRecentGranolaNotes } from "./client"; - -const NOTE = { - id: "note_1", - title: "Weekly sync", - summary: "Discussed roadmap.", - createdAt: "2026-08-12T09:00:00.000Z", -}; - -test("returns the parsed notes on a successful call", async () => { - const fetchImpl = (async (input: URL | string) => { - expect(String(input)).toBe("https://api.granola.ai/v1/notes"); - return new Response(JSON.stringify({ notes: [NOTE] }), { status: 200 }); - }) as unknown as typeof fetch; - - const notes = await listRecentGranolaNotes({ - apiKey: "test-key", - fetchImpl, - }); - expect(notes).toEqual([NOTE]); -}); - -test("carries the since parameter and bearer auth header", async () => { - const captured: { url: string; auth: string | null } = { - url: "", - auth: null, - }; - const fetchImpl = (async (input: URL | string, init?: RequestInit) => { - captured.url = String(input); - captured.auth = - (init?.headers as Record<string, string> | undefined)?.["authorization"] ?? null; - return new Response(JSON.stringify({ notes: [] }), { status: 200 }); - }) as unknown as typeof fetch; - - await listRecentGranolaNotes( - { apiKey: "secret", fetchImpl }, - { since: "2026-08-01T00:00:00.000Z" }, - ); - expect(captured.url).toBe("https://api.granola.ai/v1/notes?since=2026-08-01T00%3A00%3A00.000Z"); - expect(captured.auth).toBe("Bearer secret"); -}); - -test("throws on a non-ok HTTP response", async () => { - const fetchImpl = (async () => new Response("nope", { status: 401 })) as unknown as typeof fetch; - - await expect(listRecentGranolaNotes({ apiKey: "bad", fetchImpl })).rejects.toThrow(/401/); -}); - -test("throws when the response body does not match the expected shape", async () => { - const fetchImpl = (async () => - new Response(JSON.stringify({ notes: [{ id: "note_1" }] }), { - status: 200, - })) as unknown as typeof fetch; - - await expect(listRecentGranolaNotes({ apiKey: "test-key", fetchImpl })).rejects.toThrow( - /did not match the expected shape/, - ); -}); - -test("getGranolaNote fetches one note by id with its transcript", async () => { - const captured: { url: string; auth: string | null } = { - url: "", - auth: null, - }; - const noteWithTranscript = { - ...NOTE, - transcript: [ - { speaker: "Rep", text: "How's your current setup?" }, - { speaker: "Prospect", text: "It's slow and hard to configure." }, - ], - }; - const fetchImpl = (async (input: URL | string, init?: RequestInit) => { - captured.url = String(input); - captured.auth = - (init?.headers as Record<string, string> | undefined)?.["authorization"] ?? null; - return new Response(JSON.stringify(noteWithTranscript), { status: 200 }); - }) as unknown as typeof fetch; - - const note = await getGranolaNote({ apiKey: "test-key", fetchImpl }, { noteId: "note_1" }); - expect(captured.url).toBe("https://api.granola.ai/v1/notes/note_1?include=transcript"); - expect(captured.auth).toBe("Bearer test-key"); - expect(note).toEqual(noteWithTranscript); -}); - -test("getGranolaNote throws on a non-ok HTTP response", async () => { - const fetchImpl = (async () => - new Response("not found", { status: 404 })) as unknown as typeof fetch; - - await expect( - getGranolaNote({ apiKey: "test-key", fetchImpl }, { noteId: "missing" }), - ).rejects.toThrow(/404/); -}); - -test("getGranolaNote throws when the response body does not match the expected shape", async () => { - const fetchImpl = (async () => - new Response(JSON.stringify({ id: "note_1" }), { - status: 200, - })) as unknown as typeof fetch; - - await expect( - getGranolaNote({ apiKey: "test-key", fetchImpl }, { noteId: "note_1" }), - ).rejects.toThrow(/did not match the expected shape/); -}); diff --git a/tools/granola/src/client.ts b/tools/granola/src/client.ts deleted file mode 100644 index 58889dff22..0000000000 --- a/tools/granola/src/client.ts +++ /dev/null @@ -1,105 +0,0 @@ -// A minimal Granola API client: one call, one shape. Callers that need -// more (transcripts, folders, write access) extend this module rather -// than reach around it — every workbench consumer of Granola notes goes -// through the same parsed, validated shape. -import { type } from "arktype"; - -const GranolaTranscriptLine = type({ - speaker: "string", - text: "string", -}); -export type GranolaTranscriptLine = typeof GranolaTranscriptLine.infer; - -export const GranolaNote = type({ - id: "string", - title: "string", - "summary?": "string", - createdAt: "string", - /** Only populated when fetched with `getGranolaNote`. */ - "transcript?": GranolaTranscriptLine.array(), -}); -export type GranolaNote = typeof GranolaNote.infer; - -const GranolaListNotesResponse = type({ notes: GranolaNote.array() }); - -export interface GranolaClientConfig { - /** - * Absent when `fetchImpl` already authenticates its requests (a - * mediated credential's `fetch` injects its own bearer header per - * call — see `@intx/harness`'s `createHttpCredentialProvider`). - * Supplied directly for tests and any caller holding a raw key. - */ - readonly apiKey?: string; - /** Override for tests; defaults to the real Granola API host. */ - readonly baseUrl?: string; - readonly fetchImpl?: typeof fetch; -} - -function authHeaders(apiKey: string | undefined): Record<string, string> | undefined { - return apiKey === undefined ? undefined : { authorization: `Bearer ${apiKey}` }; -} - -const DEFAULT_BASE_URL = "https://api.granola.ai"; - -/** - * Lists the caller's recent Granola call notes, newest first. Throws on - * any transport, HTTP, or shape failure — callers that need graceful - * degradation (e.g. the morning-brief tool) catch at their own - * boundary rather than this client silently swallowing errors. - */ -export async function listRecentGranolaNotes( - config: GranolaClientConfig, - params: { readonly since?: string } = {}, -): Promise<readonly GranolaNote[]> { - const doFetch = config.fetchImpl ?? fetch; - const url = new URL("/v1/notes", config.baseUrl ?? DEFAULT_BASE_URL); - if (params.since !== undefined) { - url.searchParams.set("since", params.since); - } - const response = await doFetch(url, { - headers: authHeaders(config.apiKey), - }); - if (!response.ok) { - throw new Error(`Granola list-notes request failed: ${response.status} ${response.statusText}`); - } - const body: unknown = await response.json(); - const parsed = GranolaListNotesResponse(body); - if (parsed instanceof type.errors) { - throw new Error( - `Granola list-notes response did not match the expected shape: ${parsed.summary}`, - ); - } - return parsed.notes; -} - -/** - * Fetches one Granola call note by id, with its transcript. Throws on - * any transport, HTTP, or shape failure, same as - * {@link listRecentGranolaNotes} — callers that need graceful - * degradation catch at their own boundary. - */ -export async function getGranolaNote( - config: GranolaClientConfig, - params: { readonly noteId: string }, -): Promise<GranolaNote> { - const doFetch = config.fetchImpl ?? fetch; - const url = new URL( - `/v1/notes/${encodeURIComponent(params.noteId)}`, - config.baseUrl ?? DEFAULT_BASE_URL, - ); - url.searchParams.set("include", "transcript"); - const response = await doFetch(url, { - headers: authHeaders(config.apiKey), - }); - if (!response.ok) { - throw new Error(`Granola get-note request failed: ${response.status} ${response.statusText}`); - } - const body: unknown = await response.json(); - const parsed = GranolaNote(body); - if (parsed instanceof type.errors) { - throw new Error( - `Granola get-note response did not match the expected shape: ${parsed.summary}`, - ); - } - return parsed; -} diff --git a/tools/granola/src/index.ts b/tools/granola/src/index.ts deleted file mode 100644 index 5c4ed8a1e9..0000000000 --- a/tools/granola/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { GranolaNote, getGranolaNote, listRecentGranolaNotes } from "./client"; -export type { GranolaClientConfig, GranolaTranscriptLine } from "./client"; -export { GRANOLA_GET_NOTE_TOOL, GRANOLA_LIST_RECENT_NOTES_TOOL, granolaTools } from "./tool"; -export type { GranolaEnv } from "./tool"; diff --git a/tools/granola/src/manifest.test.ts b/tools/granola/src/manifest.test.ts deleted file mode 100644 index 5a685c798e..0000000000 --- a/tools/granola/src/manifest.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -// Proves this package's own `package.json` satisfies the vendored -// `PackageJSON` manifest schema (`@intx/types/src/package-json.ts`) -// with an `interchange.credentials` declaration the deploy-time -// `ToolPackageManifest` harvests into `topLevel[].credentials` — the -// static half of the credential-binding seam adopts. Not a test -// of the vendored schema itself, only that this package's manifest is -// shaped the way that schema requires. -import { expect, test } from "bun:test"; -import { type } from "arktype"; -import { PackageJSON } from "@intx/types/package-json"; - -import packageJson from "../package.json"; - -test("package.json parses against the vendored PackageJSON manifest schema", () => { - const parsed = PackageJSON(packageJson); - expect(parsed instanceof type.errors).toBe(false); -}); - -test("declares exactly one credential handle: granola", () => { - const parsed = PackageJSON(packageJson); - if (parsed instanceof type.errors) { - throw new Error(parsed.summary); - } - expect(parsed.interchange?.credentials).toEqual([{ handle: "granola" }]); -}); diff --git a/tools/granola/src/tool.test.ts b/tools/granola/src/tool.test.ts deleted file mode 100644 index ee4e567096..0000000000 --- a/tools/granola/src/tool.test.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { expect, test } from "bun:test"; -import type { ToolCall } from "@intx/types/runtime"; -import type { CredentialCapability, MediatedCredential } from "@intx/types"; - -import { GRANOLA_GET_NOTE_TOOL, GRANOLA_LIST_RECENT_NOTES_TOOL, granolaTools } from "./tool"; -import type { GranolaEnv } from "./tool"; - -const CALL: ToolCall = { - id: "call_1", - name: GRANOLA_LIST_RECENT_NOTES_TOOL, - arguments: {}, -}; - -const GET_NOTE_CALL: ToolCall = { - id: "call_2", - name: GRANOLA_GET_NOTE_TOOL, - arguments: { noteId: "note_1" }, -}; - -/** - * A fake `credentials` capability mirroring the platform's own - * `createCredentialCapability`/`createHttpCredentialProvider` shape: a - * bound `secret` resolves to a mediated `fetch` that injects a bearer - * header and delegates to `globalThis.fetch`; an unbound handle throws, - * matching the real gate's "no credential is bound to handle" failure. - */ -function fakeCredentials(secret: string | undefined): CredentialCapability { - return { - resolve(handle: string): Promise<MediatedCredential> { - if (secret === undefined) { - return Promise.reject(new Error(`no credential is bound to handle "${handle}"`)); - } - return Promise.resolve({ - kind: "http", - fetch: (input, init) => { - const headers = new Headers(init?.headers); - headers.set("authorization", `Bearer ${secret}`); - return fetch(input as string | URL, { ...init, headers }); - }, - dispose: () => {}, - }); - }, - }; -} - -function fakeEnv(credentials: CredentialCapability | undefined): GranolaEnv { - return { credentials } as unknown as GranolaEnv; -} - -test("declares both the granola_list_recent_notes and granola_get_note tools", () => { - const bundle = granolaTools(fakeEnv(fakeCredentials("key"))); - expect(bundle.definitions.map((d) => d.name)).toEqual([ - GRANOLA_LIST_RECENT_NOTES_TOOL, - GRANOLA_GET_NOTE_TOOL, - ]); -}); - -test("degrades to a non-throwing 'not connected' error when no credential is bound", async () => { - const bundle = granolaTools(fakeEnv(fakeCredentials(undefined))); - const result = await bundle.run(CALL, new AbortController().signal); - expect(result.isError).toBe(true); - expect(result.content).toMatch(/not connected/i); -}); - -test("degrades the same way when the step carries no credentials capability at all", async () => { - const bundle = granolaTools(fakeEnv(undefined)); - const result = await bundle.run(CALL, new AbortController().signal); - expect(result.isError).toBe(true); - expect(result.content).toMatch(/not connected/i); -}); - -test("returns the notes as JSON content on a successful call", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = (async (_input: unknown, init?: RequestInit) => { - expect((init?.headers as Headers | undefined)?.get("authorization")).toBe("Bearer key"); - return new Response( - JSON.stringify({ - notes: [ - { - id: "note_1", - title: "Weekly sync", - createdAt: "2026-08-12T09:00:00.000Z", - }, - ], - }), - { status: 200 }, - ); - }) as unknown as typeof fetch; - try { - const bundle = granolaTools(fakeEnv(fakeCredentials("key"))); - const result = await bundle.run(CALL, new AbortController().signal); - expect(result.isError).toBeUndefined(); - expect(JSON.parse(result.content as string)).toEqual({ - notes: [ - { - id: "note_1", - title: "Weekly sync", - createdAt: "2026-08-12T09:00:00.000Z", - }, - ], - }); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("degrades to an error result (never throws) when the underlying call fails", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = (async () => new Response("nope", { status: 500 })) as unknown as typeof fetch; - try { - const bundle = granolaTools(fakeEnv(fakeCredentials("key"))); - const result = await bundle.run(CALL, new AbortController().signal); - expect(result.isError).toBe(true); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("granola_get_note degrades to a non-throwing 'not connected' error when no credential is bound", async () => { - const bundle = granolaTools(fakeEnv(fakeCredentials(undefined))); - const result = await bundle.run(GET_NOTE_CALL, new AbortController().signal); - expect(result.isError).toBe(true); - expect(result.content).toMatch(/not connected/i); -}); - -test("granola_get_note rejects a missing noteId without calling the network", async () => { - const originalFetch = globalThis.fetch; - let called = false; - globalThis.fetch = (async () => { - called = true; - return new Response("{}", { status: 200 }); - }) as unknown as typeof fetch; - try { - const bundle = granolaTools(fakeEnv(fakeCredentials("key"))); - const result = await bundle.run( - { id: "call_3", name: GRANOLA_GET_NOTE_TOOL, arguments: {} }, - new AbortController().signal, - ); - expect(called).toBe(false); - expect(result.isError).toBe(true); - expect(result.content).toContain("noteId"); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("granola_get_note returns the note as JSON content on a successful call", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = (async () => - new Response( - JSON.stringify({ - id: "note_1", - title: "Discovery call", - createdAt: "2026-08-12T09:00:00.000Z", - transcript: [{ speaker: "Rep", text: "How's it going?" }], - }), - { status: 200 }, - )) as unknown as typeof fetch; - try { - const bundle = granolaTools(fakeEnv(fakeCredentials("key"))); - const result = await bundle.run(GET_NOTE_CALL, new AbortController().signal); - expect(result.isError).toBeUndefined(); - const parsed = JSON.parse(result.content as string) as { - note: { id: string; transcript: unknown[] }; - }; - expect(parsed.note.id).toBe("note_1"); - expect(parsed.note.transcript).toHaveLength(1); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("granola_get_note degrades to an error result (never throws) when the underlying call fails", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = (async () => new Response("nope", { status: 500 })) as unknown as typeof fetch; - try { - const bundle = granolaTools(fakeEnv(fakeCredentials("key"))); - const result = await bundle.run(GET_NOTE_CALL, new AbortController().signal); - expect(result.isError).toBe(true); - } finally { - globalThis.fetch = originalFetch; - } -}); diff --git a/tools/granola/src/tool.ts b/tools/granola/src/tool.ts deleted file mode 100644 index 97d523732e..0000000000 --- a/tools/granola/src/tool.ts +++ /dev/null @@ -1,174 +0,0 @@ -// The `granola_list_recent_notes` tool bundle: an agent-facing wrapper -// around `./client.ts` that never throws. A missing credential or a -// failed call both come back as a completed `ToolResult` with -// `isError: true` — the calling agent's job (e.g. the morning-brief -// workflow) is to read that as "Granola is not available right now" -// and say so honestly, never to have the run itself fail because one -// source is unreachable. -// -// declared this package's "granola" credential handle in -// `package.json` (`interchange.credentials`); wires the runtime -// half. `env.credentials` is the harness's consumer-gated capability -// (`@intx/harness/src/credential-capability.ts`, -// `createCredentialCapability`): the sidecar's step-invoker binding -// (`apps/sidecar/src/step-agent-tools.ts`) shapes one scoped to this -// package's consumer identity from the step's `CredentialWiring` -// (`ChildStepInvoker`'s 6th parameter, threaded through -// `apps/sidecar/src/workflow-substrate-factory/index.ts`'s `invokeStep`) -// and hands it to this bundle's factory. `credentials.resolve("granola")` -// throws when the handle has no bound credential (a workflow that never -// declared `credentialBindings` for this package) or the run's grants -// don't authorize this consumer to use it; both, like a network failure, -// degrade to the same honest "not connected" `ToolResult` rather than -// throwing out of the tool. -import { defineTool } from "@intx/agent"; -import type { BaseEnv } from "@intx/agent"; -import type { CredentialCapability } from "@intx/types"; -import type { ToolCall, ToolResult } from "@intx/types/runtime"; - -import { getGranolaNote, listRecentGranolaNotes } from "./client"; - -export const GRANOLA_LIST_RECENT_NOTES_TOOL = "granola_list_recent_notes"; -export const GRANOLA_GET_NOTE_TOOL = "granola_get_note"; - -/** The handle this package declares in `interchange.credentials`. */ -const GRANOLA_CREDENTIAL_HANDLE = "granola"; - -/** Env this bundle needs beyond `BaseEnv`: the mediated-credential capability. */ -export interface GranolaEnv extends BaseEnv { - readonly credentials?: CredentialCapability; -} - -function notConnectedResult(callId: string): ToolResult { - return { - callId, - content: "Granola is not connected for this user.", - isError: true, - }; -} - -/** - * Resolve this bundle's mediated Granola credential, or `null` when it - * is not connected -- an absent `env.credentials`, an unbound handle, or - * a denied grant all collapse to the same "not connected" signal, never - * a thrown error out of the tool. - */ -async function resolveGranolaCredential( - env: GranolaEnv, -): Promise<{ fetchImpl: typeof fetch } | null> { - if (env.credentials === undefined) return null; - try { - const mediated = await env.credentials.resolve(GRANOLA_CREDENTIAL_HANDLE); - return { fetchImpl: mediated.fetch as unknown as typeof fetch }; - } catch { - return null; - } -} - -async function runGranolaListRecentNotes(env: GranolaEnv, call: ToolCall): Promise<ToolResult> { - const credential = await resolveGranolaCredential(env); - if (credential === null) { - return notConnectedResult(call.id); - } - const since = call.arguments["since"]; - try { - const notes = await listRecentGranolaNotes( - { fetchImpl: credential.fetchImpl }, - typeof since === "string" ? { since } : {}, - ); - return { callId: call.id, content: JSON.stringify({ notes }) }; - } catch (err) { - return { - callId: call.id, - content: err instanceof Error ? err.message : String(err), - isError: true, - }; - } -} - -async function runGranolaGetNote(env: GranolaEnv, call: ToolCall): Promise<ToolResult> { - const credential = await resolveGranolaCredential(env); - if (credential === null) { - return notConnectedResult(call.id); - } - const noteId = call.arguments["noteId"]; - if (typeof noteId !== "string" || noteId === "") { - return { - callId: call.id, - content: `${GRANOLA_GET_NOTE_TOOL} requires a non-empty noteId argument`, - isError: true, - }; - } - try { - const note = await getGranolaNote({ fetchImpl: credential.fetchImpl }, { noteId }); - return { callId: call.id, content: JSON.stringify({ note }) }; - } catch (err) { - return { - callId: call.id, - content: err instanceof Error ? err.message : String(err), - isError: true, - }; - } -} - -/** - * The `@corbits/granola-tools` bundle factory: two tools sharing one - * mediated credential. Pin this package's `gr` bundle on any agent - * that needs a user's recent Granola call notes, or the full transcript - * of one note by id. The bundle id's local segment is kept short — the - * wire encoding of `<id>:<tool name>` must fit the 64-char OpenAI - * function-name cap (see `tool-name-limits.test.ts`), and - * `granola_list_recent_notes` already eats most of that budget. - */ -export const granolaTools = defineTool<GranolaEnv>({ - id: "@corbits/granola-tools/gr", - requires: ["credentials"], - definitions: [{ name: GRANOLA_LIST_RECENT_NOTES_TOOL }, { name: GRANOLA_GET_NOTE_TOOL }], - factory: (env) => ({ - definitions: [ - { - name: GRANOLA_LIST_RECENT_NOTES_TOOL, - description: - "Lists the caller's recent Granola call notes (title, summary, " + - "created-at), newest first. Returns an error result naming " + - '"not connected" when no Granola credential is configured — ' + - "never fabricate notes when this happens.", - inputSchema: { - type: "object", - properties: { - since: { - type: "string", - description: "ISO 8601 timestamp; only notes created after this are returned", - }, - }, - }, - }, - { - name: GRANOLA_GET_NOTE_TOOL, - description: - "Fetches one Granola call note by id, including its transcript. " + - 'Returns an error result naming "not connected" when no Granola ' + - "credential is configured — never fabricate a transcript when " + - "this happens.", - inputSchema: { - type: "object", - properties: { - noteId: { - type: "string", - description: "The Granola note id to fetch.", - }, - }, - required: ["noteId"], - }, - }, - ], - run: (call, _signal) => { - switch (call.name) { - case GRANOLA_GET_NOTE_TOOL: - return runGranolaGetNote(env, call); - default: - return runGranolaListRecentNotes(env, call); - } - }, - }), -}); diff --git a/tools/granola/test/credential-delivery.drizzle.test.ts b/tools/granola/test/credential-delivery.drizzle.test.ts deleted file mode 100644 index 7983436f3e..0000000000 --- a/tools/granola/test/credential-delivery.drizzle.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -// DB-gated: skipped when no DATABASE_URL is reachable (a fresh checkout -// still runs the unit gates), mirroring -// `packages/webhook-triggers/test/store.drizzle.test.ts`. Runs against -// its own Postgres schema, never the developer's or the walking-skeleton -// suite's. -// -// Proves the launch-time half of its credential-binding adoption -// using only the platform's own, already-built functions -// (`buildCredentialDelivery`, `createEnvKeyCredentialCipher`, -// `credentialAad`) — never a hand-rolled resolver: -// -// - a `CredentialBinding` naming this package's declared "granola" -// handle resolves to a seeded tenant-owned credential's decrypted -// secret; -// - the same binding fails closed (`unresolved`) when no credential -// exists for the provider, rather than delivering nothing silently. -import { afterAll, beforeAll, expect, test } from "bun:test"; -import { buildCredentialDelivery, createDB, runMigrations, dropSchema } from "@intx/db"; -import { schema } from "@intx/db"; -import { createEnvKeyCredentialCipher } from "@intx/crypto"; -import { credentialAad } from "@intx/types"; -import type { CredentialBinding } from "@intx/types"; - -import { dbTargetFromUrl } from "../../../scripts/db-setup"; -import { e2eDatabaseUrl } from "../../../test/database-url"; -import { dbGate } from "../../../test/db-gate"; - -const databaseUrl = e2eDatabaseUrl(); -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -const SCHEMA = "granola_tools_credential_delivery_test"; -const KEY = new Uint8Array(32).fill(9); -const CIPHER = createEnvKeyCredentialCipher(KEY); - -const GRANOLA_BINDING: CredentialBinding = { - package: "@corbits/granola-tools", - handle: "granola", - provider: "granola", - locator: "tenant", -}; - -describeIfDb("buildCredentialDelivery: the granola handle binding", () => { - const target = dbTargetFromUrl(databaseUrl ?? "postgres://localhost:5432/unused"); - - beforeAll(async () => { - await runMigrations(target, { schema: SCHEMA }); - }); - - afterAll(async () => { - await dropSchema(target, { schema: SCHEMA }); - }); - - test("resolves the bound handle to the seeded tenant credential's decrypted secret", async () => { - const { db, close } = createDB({ ...target, schema: SCHEMA }); - try { - await db.insert(schema.tenant).values({ - id: "tnt_granola_resolves", - name: "Granola Resolves Tenant", - slug: "granola-resolves-tenant", - domain: "granola-resolves.workbench.test", - }); - await db.insert(schema.provider).values({ - id: "prov_granola", - tenantId: "tnt_granola_resolves", - name: "granola", - plugin: "granola", - apiBaseUrl: "https://api.granola.ai", - }); - const credentialId = "cred_granola"; - await db.insert(schema.credential).values({ - id: credentialId, - tenantId: "tnt_granola_resolves", - providerId: "prov_granola", - name: "granola-key", - type: "api_key", - secret: await CIPHER.encrypt( - "granola-plaintext-secret", - credentialAad(credentialId, "secret"), - ), - status: "active", - }); - - const result = await buildCredentialDelivery({ - db, - tenantId: "tnt_granola_resolves", - bindings: [GRANOLA_BINDING], - creatorPrincipalId: null, - invokerPrincipalId: null, - credentialCipher: CIPHER, - }); - - expect(result.ok).toBe(true); - if (!result.ok) throw new Error("expected resolution to succeed"); - expect(result.delivery?.bindings).toEqual([ - { - handle: "granola", - credentialId, - consumer: "tool:@corbits/granola-tools", - }, - ]); - expect(result.delivery?.materials).toEqual([ - { - credentialId, - providerKey: "granola", - origin: "https://api.granola.ai", - secret: "granola-plaintext-secret", - }, - ]); - } finally { - await close(); - } - }); - - test("fails closed with 'unresolved' when no credential backs the provider", async () => { - const { db, close } = createDB({ ...target, schema: SCHEMA }); - try { - await db.insert(schema.tenant).values({ - id: "tnt_granola_unresolved", - name: "Granola Unresolved Tenant", - slug: "granola-unresolved-tenant", - domain: "granola-unresolved.workbench.test", - }); - // No provider, no credential row for this tenant: the binding's - // provider never resolves. - - const result = await buildCredentialDelivery({ - db, - tenantId: "tnt_granola_unresolved", - bindings: [GRANOLA_BINDING], - creatorPrincipalId: null, - invokerPrincipalId: null, - credentialCipher: CIPHER, - }); - - expect(result.ok).toBe(false); - if (result.ok) throw new Error("expected resolution to fail closed"); - expect(result.reason).toEqual({ - code: "unresolved", - binding: { - provider: "granola", - package: "@corbits/granola-tools", - handle: "granola", - }, - message: - "No credential resolves the binding for provider granola " + - "(package @corbits/granola-tools, handle granola)", - }); - } finally { - await close(); - } - }); -}); diff --git a/tools/granola/tsconfig.json b/tools/granola/tsconfig.json deleted file mode 100644 index a8c2b9966d..0000000000 --- a/tools/granola/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "./tsconfig.src.json", - "compilerOptions": { - "composite": false, - "noEmit": true, - "disableSourceOfProjectReferenceRedirect": true, - "declaration": false, - "declarationMap": false, - "emitDeclarationOnly": false, - "tsBuildInfoFile": "tsconfig.tsbuildinfo", - "rootDir": "../.." - }, - "include": ["src", "test"], - "exclude": [] -} diff --git a/tools/granola/tsconfig.src.json b/tools/granola/tsconfig.src.json deleted file mode 100644 index fc8724305f..0000000000 --- a/tools/granola/tsconfig.src.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "include": ["src", "package.json", "src/**/*.json"], - "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"], - "compilerOptions": { - "types": ["bun"], - "composite": true, - "emitDeclarationOnly": true, - "outDir": "dist", - "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" - } -} diff --git a/tools/linear/LICENSE b/tools/linear/LICENSE deleted file mode 100644 index 173c3a21d0..0000000000 --- a/tools/linear/LICENSE +++ /dev/null @@ -1,176 +0,0 @@ -GNU LESSER GENERAL PUBLIC LICENSE - -Version 2.1, February 1999 - -Copyright (C) 1991, 1999 Free Software Foundation, Inc. -51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - -Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. - -[This is the first released version of the Lesser GPL. It also counts as the successor of the GNU Library Public License, version 2, hence the version number 2.1.] - -Preamble - -The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public Licenses are intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. - -This license, the Lesser General Public License, applies to some specially designated software packages--typically libraries--of the Free Software Foundation and other authors who decide to use it. You can use it too, but we suggest you first think carefully about whether this license or the ordinary General Public License is the better strategy to use in any particular case, based on the explanations below. - -When we speak of free software, we are referring to freedom of use, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish); that you receive source code or can get it if you want it; that you can change the software and use pieces of it in new free programs; and that you are informed that you can do these things. - -To protect your rights, we need to make restrictions that forbid distributors to deny you these rights or to ask you to surrender these rights. These restrictions translate to certain responsibilities for you if you distribute copies of the library or if you modify it. - -For example, if you distribute copies of the library, whether gratis or for a fee, you must give the recipients all the rights that we gave you. You must make sure that they, too, receive or can get the source code. If you link other code with the library, you must provide complete object files to the recipients, so that they can relink them with the library after making changes to the library and recompiling it. And you must show them these terms so they know their rights. - -We protect your rights with a two-step method: (1) we copyright the library, and (2) we offer you this license, which gives you legal permission to copy, distribute and/or modify the library. - -To protect each distributor, we want to make it very clear that there is no warranty for the free library. Also, if the library is modified by someone else and passed on, the recipients should know that what they have is not the original version, so that the original author's reputation will not be affected by problems that might be introduced by others. - -Finally, software patents pose a constant threat to the existence of any free program. We wish to make sure that a company cannot effectively restrict the users of a free program by obtaining a restrictive license from a patent holder. Therefore, we insist that any patent license obtained for a version of the library must be consistent with the full freedom of use specified in this license. - -Most GNU software, including some libraries, is covered by the ordinary GNU General Public License. This license, the GNU Lesser General Public License, applies to certain designated libraries, and is quite different from the ordinary General Public License. We use this license for certain libraries in order to permit linking those libraries into non-free programs. - -When a program is linked with a library, whether statically or using a shared library, the combination of the two is legally speaking a combined work, a derivative of the original library. The ordinary General Public License therefore permits such linking only if the entire combination fits its criteria of freedom. The Lesser General Public License permits more lax criteria for linking other code with the library. - -We call this license the "Lesser" General Public License because it does Less to protect the user's freedom than the ordinary General Public License. It also provides other free software developers Less of an advantage over competing non-free programs. These disadvantages are the reason we use the ordinary General Public License for many libraries. However, the Lesser license provides advantages in certain special circumstances. - -For example, on rare occasions, there may be a special need to encourage the widest possible use of a certain library, so that it becomes a de-facto standard. To achieve this, non-free programs must be allowed to use the library. A more frequent case is that a free library does the same job as widely used non-free libraries. In this case, there is little to gain by limiting the free library to free software only, so we use the Lesser General Public License. - -In other cases, permission to use a particular library in non-free programs enables a greater number of people to use a large body of free software. For example, permission to use the GNU C Library in non-free programs enables many more people to use the whole GNU operating system, as well as its variant, the GNU/Linux operating system. - -Although the Lesser General Public License is Less protective of the users' freedom, it does ensure that the user of a program that is linked with the Library has the freedom and the wherewithal to run that program using a modified version of the Library. - -The precise terms and conditions for copying, distribution and modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, whereas the latter must be combined with the library in order to run. - -GNU LESSER GENERAL PUBLIC LICENSE -TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - -0. This License Agreement applies to any software library or other program which contains a notice placed by the copyright holder or other authorized party saying it may be distributed under the terms of this Lesser General Public License (also called "this License"). Each licensee is addressed as "you". - -A "library" means a collection of software functions and/or data prepared so as to be conveniently linked with application programs (which use some of those functions and data) to form executables. - -The "Library", below, refers to any such software library or work which has been distributed under these terms. A "work based on the Library" means either the Library or any derivative work under copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with modifications and/or translated straightforwardly into another language. (Hereinafter, translation is included without limitation in the term "modification".) - -"Source code" for a work means the preferred form of the work for making modifications to it. For a library, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library. - -Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running a program using the Library is not restricted, and output from such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does. - -1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and distribute a copy of this License along with the Library. - -You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. - -2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: - - a) The modified work must itself be a software library. - - b) You must cause the files modified to carry prominent notices stating that you changed the files and the date of any change. - - c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms of this License. - - d) If a facility in the modified Library refers to a function or a table of data to be supplied by an application program that uses the facility, other than as an argument passed when the facility is invoked, then you must make a good faith effort to ensure that, in the event an application does not supply such function or table, the facility still operates, and performs whatever part of its purpose remains meaningful. - -(For example, a function in a library to compute square roots has a purpose that is entirely well-defined independent of the application. Therefore, Subsection 2d requires that any application-supplied function or table used by this function must be optional: if the application does not supply it, the square root function must still compute square roots.) - -These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Library, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Library, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. - -Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Library. - -In addition, mere aggregation of another work not based on the Library with the Library (or with a work based on the Library) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. - -3. You may opt to apply the terms of the ordinary GNU General Public License instead of this License to a given copy of the Library. To do this, you must alter all the notices that refer to this License, so that they refer to the ordinary GNU General Public License, version 2, instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. - -Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. - -This option is useful when you wish to copy part of the code of the Library into a program that is not a library. - -4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange. - -If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code, even though third parties are not compelled to copy the source along with the object code. - -5. A program that contains no derivative of any portion of the Library, but is designed to work with the Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in isolation, is not a derivative work of the Library, and therefore falls outside the scope of this License. - -However, linking a "work that uses the Library" with the Library creates an executable that is a derivative of the Library (because it contains portions of the Library), rather than a "work that uses the library". The executable is therefore covered by this License. Section 6 states terms for distribution of such executables. - -When a "work that uses the Library" uses material from a header file that is part of the Library, the object code for the work may be a derivative work of the Library even though the source code is not. Whether this is true is especially significant if the work can be linked without the Library, or if the work is itself a library. The threshold for this to be true is not precisely defined by law. - -If such an object file uses only numerical parameters, data structure layouts and accessors, and small macros and small inline functions (ten lines or less in length), then the use of the object file is unrestricted, regardless of whether it is legally a derivative work. (Executables containing this object code plus portions of the Library will still fall under Section 6.) - -Otherwise, if the work is a derivative of the Library, you may distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. - -6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work under terms of your choice, provided that the terms permit modification of the work for the customer's own use and reverse engineering for debugging such modifications. - -You must give prominent notice with each copy of the work that the Library is used in it and that the Library and its use are covered by this License. You must supply a copy of this License. If the work during execution displays copyright notices, you must include the copyright notice for the Library among them, as well as a reference directing the user to the copy of this License. Also, you must do one of these things: - - a) Accompany the work with the complete corresponding machine-readable source code for the Library including whatever changes were used in the work (which must be distributed under Sections 1 and 2 above); and, if the work is an executable linked with the Library, with the complete machine-readable "work that uses the Library", as object code and/or source code, so that the user can modify the Library and then relink to produce a modified executable containing the modified Library. (It is understood that the user who changes the contents of definitions files in the Library will not necessarily be able to recompile the application to use the modified definitions.) - - b) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (1) uses at run time a copy of the library already present on the user's computer system, rather than copying library functions into the executable, and (2) will operate properly with a modified version of the library, if the user installs one, as long as the modified version is interface-compatible with the version that the work was made with. - - c) Accompany the work with a written offer, valid for at least three years, to give the same user the materials specified in Subsection 6a, above, for a charge no more than the cost of performing this distribution. - - d) If distribution of the work is made by offering access to copy from a designated place, offer equivalent access to copy the above specified materials from the same place. - - e) Verify that the user has already received a copy of these materials or that you have already sent this user a copy. - -For an executable, the required form of the "work that uses the Library" must include any data and utility programs needed for reproducing the executable from it. However, as a special exception, the materials to be distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. - -It may happen that this requirement contradicts the license restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. - -7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined library, provided that the separate distribution of the work based on the Library and of the other library facilities is otherwise permitted, and provided that you do these two things: - - a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities. This must be distributed under the terms of the Sections above. - - b) Give prominent notice with the combined library of the fact that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. - -8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, link with, or distribute the Library is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. - -9. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Library or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Library (or any work based on the Library), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Library or works based on it. - -10. Each time you redistribute the Library (or any work based on the Library), the recipient automatically receives a license from the original licensor to copy, distribute, link with or modify the Library subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties with this License. - -11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Library at all. For example, if a patent license would not permit royalty-free redistribution of the Library by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Library. - -If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply, and the section as a whole is intended to apply in other circumstances. - -It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. - -This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. - -12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Library under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. - -13. The Free Software Foundation may publish revised and/or new versions of the Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. - -Each version is given a distinguishing version number. If the Library specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. - -14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. - -NO WARRANTY - -15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - -16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. - -END OF TERMS AND CONDITIONS - -How to Apply These Terms to Your New Libraries - -If you develop a new library, and you want it to be of the greatest possible use to the public, we recommend making it free software that everyone can redistribute and change. You can do so by permitting redistribution under these terms (or, alternatively, under the terms of the ordinary General Public License). - -To apply these terms, attach the following notices to the library. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. - - one line to give the library's name and an idea of what it does. - Copyright (C) year name of author - - This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; version 2.1 of the License. - - This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Also add information on how to contact you by electronic and paper mail. - -You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the library, if necessary. Here is a sample; alter the names: - -Yoyodyne, Inc., hereby disclaims all copyright interest in -the library `Frob' (a library for tweaking knobs) written -by James Random Hacker. - -signature of Ty Coon, 1 April 1990 -Ty Coon, President of Vice -That's all there is to it! diff --git a/tools/linear/README.md b/tools/linear/README.md deleted file mode 100644 index d2e1ed2b21..0000000000 --- a/tools/linear/README.md +++ /dev/null @@ -1,67 +0,0 @@ -# @corbits/linear-tools - -A minimal Linear integration: one client call, one `@intx/agent` tool -bundle. Built for the morning-brief workflow but not specific -to it — any agent that needs a user's recently updated Linear issues -pins this package. - -## Tool - -`linear_list_recent_issues` — lists issues assigned to the caller -(`id`, `identifier`, `title`, `state`, `url`, `updatedAt`), -most-recently-updated first, optionally filtered to issues updated -after a given ISO 8601 timestamp. - -## Credential - -The bundle's env carries `credentials`, the harness's consumer-gated -`CredentialCapability` (`@intx/harness/src/credential-capability.ts`). -When it is absent, or `credentials.resolve("linear")` throws — no bound -handle, or a grant that doesn't authorize this consumer — the tool never -throws itself: it returns a completed result with `isError: true` and -content naming the source "not connected", so a calling agent can -degrade gracefully instead of failing its turn. - -This package declares one credential handle, `linear`, in its -`package.json`'s `interchange.credentials` field. Connect -Linear once, in Settings · Connections, and every workflow that pins -`@corbits/linear-tools` and binds this handle — `collateral-generation`, -`morning-brief` — resolves the same tenant-owned credential at launch; -no per-workflow reconnection. - -Launch-time resolution (`buildCredentialDelivery`) is proven in -`test/credential-delivery.drizzle.test.ts`; the full chain — seeded -credential through the sidecar's step wiring -(`apps/sidecar/src/step-agent-tools.ts`) to this bundle's tool call — is -proven in `test/credential-wiring-e2e.drizzle.test.ts`. - -**Provider plugin.** Linear's API expects the raw key verbatim in -`authorization`, not a `Bearer `-prefixed token. `@intx/harness`'s -vendored `http` provider always sends Bearer, so a Linear provider row -MUST set `plugin: "http-raw-authorization"` -(`@corbits/credential-header`) rather than the `"http"` default other -connectors use — seeding it wrong sends the wrong header shape and -Linear rejects the call. See `docs/credential-wiring.md` and -`test/linear-raw-authorization-regression.test.ts`, a regression guard -for exactly this bug. - -## Usage - -```ts -import { linearTools } from "@corbits/linear-tools"; - -const agent = defineAgent({ - // ... - tools: [linearTools], -}); -``` - -## Running tests - -``` -cd tools/linear && bun test -``` - -`test/credential-delivery.drizzle.test.ts` and -`test/credential-wiring-e2e.drizzle.test.ts` need a live Postgres: -`DATABASE_URL=postgres://localhost:5432/workbench_e2e`. diff --git a/tools/linear/package.json b/tools/linear/package.json deleted file mode 100644 index 733f7e2546..0000000000 --- a/tools/linear/package.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "name": "@corbits/linear-tools", - "version": "0.0.5", - "description": "Linear integration: a minimal client and an @intx/agent tool bundle for listing a user's recently updated issues", - "license": "LGPL-2.1-only", - "repository": { - "type": "git", - "url": "https://github.com/corbitsdev/workbench.git", - "directory": "tools/linear" - }, - "files": [ - "src", - "LICENSE", - "README.md" - ], - "type": "module", - "exports": { - ".": "./src/index.ts" - }, - "publishConfig": { - "access": "public" - }, - "scripts": { - "typecheck": "tsc --noEmit", - "test": "bun test" - }, - "dependencies": { - "arktype": "catalog:" - }, - "devDependencies": { - "@intx/authz": "0.3.0", - "@intx/crypto": "0.3.0", - "@intx/db": "workspace:*", - "@intx/harness": "workspace:*", - "@types/bun": "catalog:", - "typescript": "catalog:" - }, - "peerDependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0" - }, - "interchange": { - "credentials": [ - { - "handle": "linear" - } - ] - } -} diff --git a/tools/linear/src/client.test.ts b/tools/linear/src/client.test.ts deleted file mode 100644 index aae2b0dce2..0000000000 --- a/tools/linear/src/client.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { expect, test } from "bun:test"; - -import { listRecentLinearIssues } from "./client"; - -const NODE = { - id: "issue_1", - identifier: "CL-1", - title: "Fix the thing", - url: "https://linear.app/abklabs/issue/CL-1", - updatedAt: "2026-08-12T09:00:00.000Z", - state: { name: "In Progress" }, -}; - -test("returns the parsed issues on a successful call", async () => { - const fetchImpl = (async () => - new Response(JSON.stringify({ data: { issues: { nodes: [NODE] } } }), { - status: 200, - })) as unknown as typeof fetch; - - const issues = await listRecentLinearIssues({ - apiKey: "test-key", - fetchImpl, - }); - expect(issues).toEqual([ - { - id: "issue_1", - identifier: "CL-1", - title: "Fix the thing", - url: "https://linear.app/abklabs/issue/CL-1", - updatedAt: "2026-08-12T09:00:00.000Z", - state: "In Progress", - }, - ]); -}); - -test("posts the api key as the authorization header", async () => { - const captured: { auth: string | null } = { auth: null }; - const fetchImpl = (async (_input: URL | string, init?: RequestInit) => { - captured.auth = - (init?.headers as Record<string, string> | undefined)?.["authorization"] ?? null; - return new Response(JSON.stringify({ data: { issues: { nodes: [] } } }), { - status: 200, - }); - }) as unknown as typeof fetch; - - await listRecentLinearIssues({ apiKey: "secret", fetchImpl }); - expect(captured.auth).toBe("secret"); -}); - -test("throws on a non-ok HTTP response", async () => { - const fetchImpl = (async () => new Response("nope", { status: 401 })) as unknown as typeof fetch; - - await expect(listRecentLinearIssues({ apiKey: "bad", fetchImpl })).rejects.toThrow(/401/); -}); - -test("throws on a GraphQL error envelope", async () => { - const fetchImpl = (async () => - new Response(JSON.stringify({ errors: [{ message: "not authenticated" }] }), { - status: 200, - })) as unknown as typeof fetch; - - await expect(listRecentLinearIssues({ apiKey: "bad", fetchImpl })).rejects.toThrow( - /GraphQL errors/, - ); -}); - -test("throws when a node does not match the expected issue shape", async () => { - const fetchImpl = (async () => - new Response(JSON.stringify({ data: { issues: { nodes: [{ id: "issue_1" }] } } }), { - status: 200, - })) as unknown as typeof fetch; - - await expect(listRecentLinearIssues({ apiKey: "test-key", fetchImpl })).rejects.toThrow( - /did not match the expected shape/, - ); -}); diff --git a/tools/linear/src/client.ts b/tools/linear/src/client.ts deleted file mode 100644 index b47c7d45ea..0000000000 --- a/tools/linear/src/client.ts +++ /dev/null @@ -1,125 +0,0 @@ -// A minimal Linear API client: one call, one shape — the issues -// assigned to or created by the caller, most-recently-updated first. -// Callers that need Linear's fuller surface (projects, cycles, -// comments, ...) extend this module rather than reach around it. -import { type } from "arktype"; - -export const LinearIssue = type({ - id: "string", - identifier: "string", - title: "string", - "state?": "string", - "url?": "string", - updatedAt: "string", -}); -export type LinearIssue = typeof LinearIssue.infer; - -export interface LinearClientConfig { - /** - * Absent when `fetchImpl` already authenticates its requests (a - * mediated credential's `fetch` injects its own bearer header per - * call — see `@intx/harness`'s `createHttpCredentialProvider`). - * Supplied directly for tests and any caller holding a raw key. - */ - readonly apiKey?: string; - /** Override for tests; defaults to Linear's real GraphQL endpoint. */ - readonly baseUrl?: string; - readonly fetchImpl?: typeof fetch; -} - -const DEFAULT_BASE_URL = "https://api.linear.app/graphql"; - -const RECENT_ISSUES_QUERY = ` - query RecentAssignedIssues($updatedAfter: DateTimeOrDuration) { - issues( - filter: { assignee: { isMe: { eq: true } }, updatedAt: { gte: $updatedAfter } } - orderBy: updatedAt - ) { - nodes { - id - identifier - title - url - updatedAt - state { - name - } - } - } - } -`; - -interface LinearGraphQlIssueNode { - readonly id: string; - readonly identifier: string; - readonly title: string; - readonly url?: string; - readonly updatedAt: string; - readonly state?: { readonly name?: string }; -} - -const LinearGraphQlResponse = type({ - "data?": { - "issues?": { - "nodes?": "unknown[]", - }, - }, - "errors?": "unknown[]", -}); - -/** - * Lists issues assigned to the caller, most-recently-updated first. - * Throws on any transport, HTTP, GraphQL, or shape failure — callers - * that need graceful degradation (e.g. the morning-brief tool) catch - * at their own boundary rather than this client silently swallowing - * errors. - */ -export async function listRecentLinearIssues( - config: LinearClientConfig, - params: { readonly since?: string } = {}, -): Promise<readonly LinearIssue[]> { - const doFetch = config.fetchImpl ?? fetch; - const headers: Record<string, string> = { - "content-type": "application/json", - }; - if (config.apiKey !== undefined) headers.authorization = config.apiKey; - const response = await doFetch(config.baseUrl ?? DEFAULT_BASE_URL, { - method: "POST", - headers, - body: JSON.stringify({ - query: RECENT_ISSUES_QUERY, - variables: { updatedAfter: params.since }, - }), - }); - if (!response.ok) { - throw new Error(`Linear list-issues request failed: ${response.status} ${response.statusText}`); - } - const body: unknown = await response.json(); - const parsed = LinearGraphQlResponse(body); - if (parsed instanceof type.errors) { - throw new Error( - `Linear list-issues response did not match the expected shape: ${parsed.summary}`, - ); - } - if (parsed.errors !== undefined && parsed.errors.length > 0) { - throw new Error(`Linear list-issues returned GraphQL errors: ${JSON.stringify(parsed.errors)}`); - } - const nodes = (parsed.data?.issues?.nodes ?? []) as LinearGraphQlIssueNode[]; - return nodes.map((node) => { - const issue: Record<string, unknown> = { - id: node.id, - identifier: node.identifier, - title: node.title, - updatedAt: node.updatedAt, - }; - if (node.url !== undefined) issue["url"] = node.url; - if (node.state?.name !== undefined) issue["state"] = node.state.name; - const validated = LinearIssue(issue); - if (validated instanceof type.errors) { - throw new Error( - `Linear list-issues response did not match the expected shape: ${validated.summary}`, - ); - } - return validated; - }); -} diff --git a/tools/linear/src/index.ts b/tools/linear/src/index.ts deleted file mode 100644 index 3f489f6fdd..0000000000 --- a/tools/linear/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { LinearIssue, listRecentLinearIssues } from "./client"; -export type { LinearClientConfig } from "./client"; -export { LINEAR_LIST_RECENT_ISSUES_TOOL, linearTools } from "./tool"; -export type { LinearEnv } from "./tool"; diff --git a/tools/linear/src/manifest.test.ts b/tools/linear/src/manifest.test.ts deleted file mode 100644 index af9fe641e8..0000000000 --- a/tools/linear/src/manifest.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -// Proves this package's own `package.json` satisfies the vendored -// `PackageJSON` manifest schema (`@intx/types/src/package-json.ts`) -// with an `interchange.credentials` declaration the deploy-time -// `ToolPackageManifest` harvests into `topLevel[].credentials` — the -// static half of the credential-binding seam adopts. Not a test -// of the vendored schema itself, only that this package's manifest is -// shaped the way that schema requires. -import { expect, test } from "bun:test"; -import { type } from "arktype"; -import { PackageJSON } from "@intx/types/package-json"; - -import packageJson from "../package.json"; - -test("package.json parses against the vendored PackageJSON manifest schema", () => { - const parsed = PackageJSON(packageJson); - expect(parsed instanceof type.errors).toBe(false); -}); - -test("declares exactly one credential handle: linear", () => { - const parsed = PackageJSON(packageJson); - if (parsed instanceof type.errors) { - throw new Error(parsed.summary); - } - expect(parsed.interchange?.credentials).toEqual([{ handle: "linear" }]); -}); diff --git a/tools/linear/src/tool.test.ts b/tools/linear/src/tool.test.ts deleted file mode 100644 index 779c129660..0000000000 --- a/tools/linear/src/tool.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { expect, test } from "bun:test"; -import type { ToolCall } from "@intx/types/runtime"; -import type { CredentialCapability, MediatedCredential } from "@intx/types"; - -import { LINEAR_LIST_RECENT_ISSUES_TOOL, linearTools } from "./tool"; -import type { LinearEnv } from "./tool"; - -const CALL: ToolCall = { - id: "call_1", - name: LINEAR_LIST_RECENT_ISSUES_TOOL, - arguments: {}, -}; - -/** - * A fake `credentials` capability mirroring the REAL provider a Linear - * binding resolves through in production: - * `@corbits/credential-header`'s `http-raw-authorization` preset, not - * `@intx/harness`'s Bearer-prefixed `http` provider. Linear's API expects - * the raw key verbatim in `authorization` (see - * `@corbits/credential-header`'s `rawAuthorizationCredentialProvider`); - * a bound `secret` resolves to a mediated `fetch` that injects it - * unprefixed and delegates to `globalThis.fetch`. An unbound handle - * throws, matching the real gate's "no credential is bound to handle" - * failure. - */ -function fakeCredentials(secret: string | undefined): CredentialCapability { - return { - resolve(handle: string): Promise<MediatedCredential> { - if (secret === undefined) { - return Promise.reject(new Error(`no credential is bound to handle "${handle}"`)); - } - return Promise.resolve({ - kind: "http", - fetch: (input, init) => { - const headers = new Headers(init?.headers); - headers.set("authorization", secret); - return fetch(input as string | URL, { ...init, headers }); - }, - dispose: () => {}, - }); - }, - }; -} - -function fakeEnv(credentials: CredentialCapability | undefined): LinearEnv { - return { credentials } as unknown as LinearEnv; -} - -test("declares the linear_list_recent_issues tool", () => { - const bundle = linearTools(fakeEnv(fakeCredentials("key"))); - expect(bundle.definitions.map((d) => d.name)).toEqual([LINEAR_LIST_RECENT_ISSUES_TOOL]); -}); - -test("degrades to a non-throwing 'not connected' error when no credential is bound", async () => { - const bundle = linearTools(fakeEnv(fakeCredentials(undefined))); - const result = await bundle.run(CALL, new AbortController().signal); - expect(result.isError).toBe(true); - expect(result.content).toMatch(/not connected/i); -}); - -test("degrades the same way when the step carries no credentials capability at all", async () => { - const bundle = linearTools(fakeEnv(undefined)); - const result = await bundle.run(CALL, new AbortController().signal); - expect(result.isError).toBe(true); - expect(result.content).toMatch(/not connected/i); -}); - -test("returns the issues as JSON content on a successful call", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = (async (_input: unknown, init?: RequestInit) => { - expect((init?.headers as Headers | undefined)?.get("authorization")).toBe("key"); - return new Response( - JSON.stringify({ - data: { - issues: { - nodes: [ - { - id: "issue_1", - identifier: "CL-1", - title: "Fix the thing", - updatedAt: "2026-08-12T09:00:00.000Z", - }, - ], - }, - }, - }), - { status: 200 }, - ); - }) as unknown as typeof fetch; - try { - const bundle = linearTools(fakeEnv(fakeCredentials("key"))); - const result = await bundle.run(CALL, new AbortController().signal); - expect(result.isError).toBeUndefined(); - expect(JSON.parse(result.content as string)).toEqual({ - issues: [ - { - id: "issue_1", - identifier: "CL-1", - title: "Fix the thing", - updatedAt: "2026-08-12T09:00:00.000Z", - }, - ], - }); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("degrades to an error result (never throws) when the underlying call fails", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = (async () => new Response("nope", { status: 500 })) as unknown as typeof fetch; - try { - const bundle = linearTools(fakeEnv(fakeCredentials("key"))); - const result = await bundle.run(CALL, new AbortController().signal); - expect(result.isError).toBe(true); - } finally { - globalThis.fetch = originalFetch; - } -}); diff --git a/tools/linear/src/tool.ts b/tools/linear/src/tool.ts deleted file mode 100644 index 60ba6fc6c0..0000000000 --- a/tools/linear/src/tool.ts +++ /dev/null @@ -1,121 +0,0 @@ -// The `linear_list_recent_issues` tool bundle: an agent-facing wrapper -// around `./client.ts` that never throws. A missing credential or a -// failed call both come back as a completed `ToolResult` with -// `isError: true` — the calling agent's job (e.g. the morning-brief -// workflow) is to read that as "Linear is not available right now" -// and say so honestly, never to have the run itself fail because one -// source is unreachable. -// -// declared this package's "linear" credential handle in -// `package.json` (`interchange.credentials`); wires the runtime -// half. `env.credentials` is the harness's consumer-gated capability -// (`@intx/harness/src/credential-capability.ts`, -// `createCredentialCapability`): the sidecar's step-invoker binding -// (`apps/sidecar/src/step-agent-tools.ts`) shapes one scoped to this -// package's consumer identity from the step's `CredentialWiring` -// (`ChildStepInvoker`'s 6th parameter, threaded through -// `apps/sidecar/src/workflow-substrate-factory/index.ts`'s `invokeStep`) -// and hands it to this bundle's factory. `credentials.resolve("linear")` -// throws when the handle has no bound credential (a workflow that never -// declared `credentialBindings` for this package) or the run's grants -// don't authorize this consumer to use it; both, like a network failure, -// degrade to the same honest "not connected" `ToolResult` rather than -// throwing out of the tool. -import { defineTool } from "@intx/agent"; -import type { BaseEnv } from "@intx/agent"; -import type { CredentialCapability } from "@intx/types"; -import type { ToolCall, ToolResult } from "@intx/types/runtime"; - -import { listRecentLinearIssues } from "./client"; - -export const LINEAR_LIST_RECENT_ISSUES_TOOL = "linear_list_recent_issues"; - -/** The handle this package declares in `interchange.credentials`. */ -const LINEAR_CREDENTIAL_HANDLE = "linear"; - -/** Env this bundle needs beyond `BaseEnv`: the mediated-credential capability. */ -export interface LinearEnv extends BaseEnv { - readonly credentials?: CredentialCapability; -} - -function notConnectedResult(callId: string): ToolResult { - return { - callId, - content: "Linear is not connected for this user.", - isError: true, - }; -} - -/** - * Resolve this bundle's mediated Linear credential, or `null` when it is - * not connected -- an absent `env.credentials`, an unbound handle, or a - * denied grant all collapse to the same "not connected" signal, never a - * thrown error out of the tool. - */ -async function resolveLinearCredential( - env: LinearEnv, -): Promise<{ fetchImpl: typeof fetch } | null> { - if (env.credentials === undefined) return null; - try { - const mediated = await env.credentials.resolve(LINEAR_CREDENTIAL_HANDLE); - return { fetchImpl: mediated.fetch as unknown as typeof fetch }; - } catch { - return null; - } -} - -async function runLinearListRecentIssues(env: LinearEnv, call: ToolCall): Promise<ToolResult> { - const credential = await resolveLinearCredential(env); - if (credential === null) { - return notConnectedResult(call.id); - } - const since = call.arguments["since"]; - try { - const issues = await listRecentLinearIssues( - { fetchImpl: credential.fetchImpl }, - typeof since === "string" ? { since } : {}, - ); - return { callId: call.id, content: JSON.stringify({ issues }) }; - } catch (err) { - return { - callId: call.id, - content: err instanceof Error ? err.message : String(err), - isError: true, - }; - } -} - -/** - * The `@corbits/linear-tools` bundle factory: one tool, one mediated - * credential. Pin this package's `li` bundle on any agent that needs - * the caller's recently updated Linear issues. The bundle id's local - * segment is kept short so `<id>:<tool name>` fits the 64-char OpenAI - * function-name cap once wire-encoded (see `tool-name-limits.test.ts`). - */ -export const linearTools = defineTool<LinearEnv>({ - id: "@corbits/linear-tools/li", - requires: ["credentials"], - definitions: [{ name: LINEAR_LIST_RECENT_ISSUES_TOOL }], - factory: (env) => ({ - definitions: [ - { - name: LINEAR_LIST_RECENT_ISSUES_TOOL, - description: - "Lists issues assigned to the caller (identifier, title, state, " + - "url, updated-at), most-recently-updated first. Returns an " + - 'error result naming "not connected" when no Linear credential ' + - "is configured — never fabricate issues when this happens.", - inputSchema: { - type: "object", - properties: { - since: { - type: "string", - description: "ISO 8601 timestamp; only issues updated after this are returned", - }, - }, - }, - }, - ], - run: (call, _signal) => runLinearListRecentIssues(env, call), - }), -}); diff --git a/tools/linear/test/credential-delivery.drizzle.test.ts b/tools/linear/test/credential-delivery.drizzle.test.ts deleted file mode 100644 index 1239f8fb2e..0000000000 --- a/tools/linear/test/credential-delivery.drizzle.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -// DB-gated: skipped when no DATABASE_URL is reachable (a fresh checkout -// still runs the unit gates), mirroring -// `packages/webhook-triggers/test/store.drizzle.test.ts`. Runs against -// its own Postgres schema, never the developer's or the walking-skeleton -// suite's. -// -// Proves the launch-time half of its credential-binding adoption -// using only the platform's own, already-built functions -// (`buildCredentialDelivery`, `createEnvKeyCredentialCipher`, -// `credentialAad`) — never a hand-rolled resolver: -// -// - a `CredentialBinding` naming this package's declared "linear" -// handle resolves to a seeded tenant-owned credential's decrypted -// secret; -// - the same binding fails closed (`unresolved`) when no credential -// exists for the provider, rather than delivering nothing silently. -import { afterAll, beforeAll, expect, test } from "bun:test"; -import { buildCredentialDelivery, createDB, runMigrations, dropSchema } from "@intx/db"; -import { schema } from "@intx/db"; -import { createEnvKeyCredentialCipher } from "@intx/crypto"; -import { credentialAad } from "@intx/types"; -import type { CredentialBinding } from "@intx/types"; - -import { dbTargetFromUrl } from "../../../scripts/db-setup"; -import { e2eDatabaseUrl } from "../../../test/database-url"; -import { dbGate } from "../../../test/db-gate"; - -const databaseUrl = e2eDatabaseUrl(); -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -const SCHEMA = "linear_tools_credential_delivery_test"; -const KEY = new Uint8Array(32).fill(11); -const CIPHER = createEnvKeyCredentialCipher(KEY); - -const LINEAR_BINDING: CredentialBinding = { - package: "@corbits/linear-tools", - handle: "linear", - provider: "linear", - locator: "tenant", -}; - -describeIfDb("buildCredentialDelivery: the linear handle binding", () => { - const target = dbTargetFromUrl(databaseUrl ?? "postgres://localhost:5432/unused"); - - beforeAll(async () => { - await runMigrations(target, { schema: SCHEMA }); - }); - - afterAll(async () => { - await dropSchema(target, { schema: SCHEMA }); - }); - - test("resolves the bound handle to the seeded tenant credential's decrypted secret", async () => { - const { db, close } = createDB({ ...target, schema: SCHEMA }); - try { - await db.insert(schema.tenant).values({ - id: "tnt_linear_resolves", - name: "Linear Resolves Tenant", - slug: "linear-resolves-tenant", - domain: "linear-resolves.workbench.test", - }); - await db.insert(schema.provider).values({ - id: "prov_linear", - tenantId: "tnt_linear_resolves", - name: "linear", - plugin: "linear", - apiBaseUrl: "https://api.linear.app/graphql", - }); - const credentialId = "cred_linear"; - await db.insert(schema.credential).values({ - id: credentialId, - tenantId: "tnt_linear_resolves", - providerId: "prov_linear", - name: "linear-key", - type: "api_key", - secret: await CIPHER.encrypt( - "linear-plaintext-secret", - credentialAad(credentialId, "secret"), - ), - status: "active", - }); - - const result = await buildCredentialDelivery({ - db, - tenantId: "tnt_linear_resolves", - bindings: [LINEAR_BINDING], - creatorPrincipalId: null, - invokerPrincipalId: null, - credentialCipher: CIPHER, - }); - - expect(result.ok).toBe(true); - if (!result.ok) throw new Error("expected resolution to succeed"); - expect(result.delivery?.bindings).toEqual([ - { - handle: "linear", - credentialId, - consumer: "tool:@corbits/linear-tools", - }, - ]); - expect(result.delivery?.materials).toEqual([ - { - credentialId, - providerKey: "linear", - origin: "https://api.linear.app/graphql", - secret: "linear-plaintext-secret", - }, - ]); - } finally { - await close(); - } - }); - - test("fails closed with 'unresolved' when no credential backs the provider", async () => { - const { db, close } = createDB({ ...target, schema: SCHEMA }); - try { - await db.insert(schema.tenant).values({ - id: "tnt_linear_unresolved", - name: "Linear Unresolved Tenant", - slug: "linear-unresolved-tenant", - domain: "linear-unresolved.workbench.test", - }); - // No provider, no credential row for this tenant: the binding's - // provider never resolves. - - const result = await buildCredentialDelivery({ - db, - tenantId: "tnt_linear_unresolved", - bindings: [LINEAR_BINDING], - creatorPrincipalId: null, - invokerPrincipalId: null, - credentialCipher: CIPHER, - }); - - expect(result.ok).toBe(false); - if (result.ok) throw new Error("expected resolution to fail closed"); - expect(result.reason).toEqual({ - code: "unresolved", - binding: { - provider: "linear", - package: "@corbits/linear-tools", - handle: "linear", - }, - message: - "No credential resolves the binding for provider linear " + - "(package @corbits/linear-tools, handle linear)", - }); - } finally { - await close(); - } - }); -}); diff --git a/tools/linear/tsconfig.json b/tools/linear/tsconfig.json deleted file mode 100644 index a8c2b9966d..0000000000 --- a/tools/linear/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "./tsconfig.src.json", - "compilerOptions": { - "composite": false, - "noEmit": true, - "disableSourceOfProjectReferenceRedirect": true, - "declaration": false, - "declarationMap": false, - "emitDeclarationOnly": false, - "tsBuildInfoFile": "tsconfig.tsbuildinfo", - "rootDir": "../.." - }, - "include": ["src", "test"], - "exclude": [] -} diff --git a/tools/linear/tsconfig.src.json b/tools/linear/tsconfig.src.json deleted file mode 100644 index fc8724305f..0000000000 --- a/tools/linear/tsconfig.src.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "include": ["src", "package.json", "src/**/*.json"], - "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"], - "compilerOptions": { - "types": ["bun"], - "composite": true, - "emitDeclarationOnly": true, - "outDir": "dist", - "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" - } -} diff --git a/tools/reddit/LICENSE b/tools/reddit/LICENSE deleted file mode 100644 index 173c3a21d0..0000000000 --- a/tools/reddit/LICENSE +++ /dev/null @@ -1,176 +0,0 @@ -GNU LESSER GENERAL PUBLIC LICENSE - -Version 2.1, February 1999 - -Copyright (C) 1991, 1999 Free Software Foundation, Inc. -51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - -Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. - -[This is the first released version of the Lesser GPL. It also counts as the successor of the GNU Library Public License, version 2, hence the version number 2.1.] - -Preamble - -The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public Licenses are intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. - -This license, the Lesser General Public License, applies to some specially designated software packages--typically libraries--of the Free Software Foundation and other authors who decide to use it. You can use it too, but we suggest you first think carefully about whether this license or the ordinary General Public License is the better strategy to use in any particular case, based on the explanations below. - -When we speak of free software, we are referring to freedom of use, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish); that you receive source code or can get it if you want it; that you can change the software and use pieces of it in new free programs; and that you are informed that you can do these things. - -To protect your rights, we need to make restrictions that forbid distributors to deny you these rights or to ask you to surrender these rights. These restrictions translate to certain responsibilities for you if you distribute copies of the library or if you modify it. - -For example, if you distribute copies of the library, whether gratis or for a fee, you must give the recipients all the rights that we gave you. You must make sure that they, too, receive or can get the source code. If you link other code with the library, you must provide complete object files to the recipients, so that they can relink them with the library after making changes to the library and recompiling it. And you must show them these terms so they know their rights. - -We protect your rights with a two-step method: (1) we copyright the library, and (2) we offer you this license, which gives you legal permission to copy, distribute and/or modify the library. - -To protect each distributor, we want to make it very clear that there is no warranty for the free library. Also, if the library is modified by someone else and passed on, the recipients should know that what they have is not the original version, so that the original author's reputation will not be affected by problems that might be introduced by others. - -Finally, software patents pose a constant threat to the existence of any free program. We wish to make sure that a company cannot effectively restrict the users of a free program by obtaining a restrictive license from a patent holder. Therefore, we insist that any patent license obtained for a version of the library must be consistent with the full freedom of use specified in this license. - -Most GNU software, including some libraries, is covered by the ordinary GNU General Public License. This license, the GNU Lesser General Public License, applies to certain designated libraries, and is quite different from the ordinary General Public License. We use this license for certain libraries in order to permit linking those libraries into non-free programs. - -When a program is linked with a library, whether statically or using a shared library, the combination of the two is legally speaking a combined work, a derivative of the original library. The ordinary General Public License therefore permits such linking only if the entire combination fits its criteria of freedom. The Lesser General Public License permits more lax criteria for linking other code with the library. - -We call this license the "Lesser" General Public License because it does Less to protect the user's freedom than the ordinary General Public License. It also provides other free software developers Less of an advantage over competing non-free programs. These disadvantages are the reason we use the ordinary General Public License for many libraries. However, the Lesser license provides advantages in certain special circumstances. - -For example, on rare occasions, there may be a special need to encourage the widest possible use of a certain library, so that it becomes a de-facto standard. To achieve this, non-free programs must be allowed to use the library. A more frequent case is that a free library does the same job as widely used non-free libraries. In this case, there is little to gain by limiting the free library to free software only, so we use the Lesser General Public License. - -In other cases, permission to use a particular library in non-free programs enables a greater number of people to use a large body of free software. For example, permission to use the GNU C Library in non-free programs enables many more people to use the whole GNU operating system, as well as its variant, the GNU/Linux operating system. - -Although the Lesser General Public License is Less protective of the users' freedom, it does ensure that the user of a program that is linked with the Library has the freedom and the wherewithal to run that program using a modified version of the Library. - -The precise terms and conditions for copying, distribution and modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, whereas the latter must be combined with the library in order to run. - -GNU LESSER GENERAL PUBLIC LICENSE -TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - -0. This License Agreement applies to any software library or other program which contains a notice placed by the copyright holder or other authorized party saying it may be distributed under the terms of this Lesser General Public License (also called "this License"). Each licensee is addressed as "you". - -A "library" means a collection of software functions and/or data prepared so as to be conveniently linked with application programs (which use some of those functions and data) to form executables. - -The "Library", below, refers to any such software library or work which has been distributed under these terms. A "work based on the Library" means either the Library or any derivative work under copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with modifications and/or translated straightforwardly into another language. (Hereinafter, translation is included without limitation in the term "modification".) - -"Source code" for a work means the preferred form of the work for making modifications to it. For a library, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library. - -Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running a program using the Library is not restricted, and output from such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does. - -1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and distribute a copy of this License along with the Library. - -You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. - -2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: - - a) The modified work must itself be a software library. - - b) You must cause the files modified to carry prominent notices stating that you changed the files and the date of any change. - - c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms of this License. - - d) If a facility in the modified Library refers to a function or a table of data to be supplied by an application program that uses the facility, other than as an argument passed when the facility is invoked, then you must make a good faith effort to ensure that, in the event an application does not supply such function or table, the facility still operates, and performs whatever part of its purpose remains meaningful. - -(For example, a function in a library to compute square roots has a purpose that is entirely well-defined independent of the application. Therefore, Subsection 2d requires that any application-supplied function or table used by this function must be optional: if the application does not supply it, the square root function must still compute square roots.) - -These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Library, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Library, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. - -Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Library. - -In addition, mere aggregation of another work not based on the Library with the Library (or with a work based on the Library) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. - -3. You may opt to apply the terms of the ordinary GNU General Public License instead of this License to a given copy of the Library. To do this, you must alter all the notices that refer to this License, so that they refer to the ordinary GNU General Public License, version 2, instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. - -Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. - -This option is useful when you wish to copy part of the code of the Library into a program that is not a library. - -4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange. - -If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code, even though third parties are not compelled to copy the source along with the object code. - -5. A program that contains no derivative of any portion of the Library, but is designed to work with the Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in isolation, is not a derivative work of the Library, and therefore falls outside the scope of this License. - -However, linking a "work that uses the Library" with the Library creates an executable that is a derivative of the Library (because it contains portions of the Library), rather than a "work that uses the library". The executable is therefore covered by this License. Section 6 states terms for distribution of such executables. - -When a "work that uses the Library" uses material from a header file that is part of the Library, the object code for the work may be a derivative work of the Library even though the source code is not. Whether this is true is especially significant if the work can be linked without the Library, or if the work is itself a library. The threshold for this to be true is not precisely defined by law. - -If such an object file uses only numerical parameters, data structure layouts and accessors, and small macros and small inline functions (ten lines or less in length), then the use of the object file is unrestricted, regardless of whether it is legally a derivative work. (Executables containing this object code plus portions of the Library will still fall under Section 6.) - -Otherwise, if the work is a derivative of the Library, you may distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. - -6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work under terms of your choice, provided that the terms permit modification of the work for the customer's own use and reverse engineering for debugging such modifications. - -You must give prominent notice with each copy of the work that the Library is used in it and that the Library and its use are covered by this License. You must supply a copy of this License. If the work during execution displays copyright notices, you must include the copyright notice for the Library among them, as well as a reference directing the user to the copy of this License. Also, you must do one of these things: - - a) Accompany the work with the complete corresponding machine-readable source code for the Library including whatever changes were used in the work (which must be distributed under Sections 1 and 2 above); and, if the work is an executable linked with the Library, with the complete machine-readable "work that uses the Library", as object code and/or source code, so that the user can modify the Library and then relink to produce a modified executable containing the modified Library. (It is understood that the user who changes the contents of definitions files in the Library will not necessarily be able to recompile the application to use the modified definitions.) - - b) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (1) uses at run time a copy of the library already present on the user's computer system, rather than copying library functions into the executable, and (2) will operate properly with a modified version of the library, if the user installs one, as long as the modified version is interface-compatible with the version that the work was made with. - - c) Accompany the work with a written offer, valid for at least three years, to give the same user the materials specified in Subsection 6a, above, for a charge no more than the cost of performing this distribution. - - d) If distribution of the work is made by offering access to copy from a designated place, offer equivalent access to copy the above specified materials from the same place. - - e) Verify that the user has already received a copy of these materials or that you have already sent this user a copy. - -For an executable, the required form of the "work that uses the Library" must include any data and utility programs needed for reproducing the executable from it. However, as a special exception, the materials to be distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. - -It may happen that this requirement contradicts the license restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. - -7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined library, provided that the separate distribution of the work based on the Library and of the other library facilities is otherwise permitted, and provided that you do these two things: - - a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities. This must be distributed under the terms of the Sections above. - - b) Give prominent notice with the combined library of the fact that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. - -8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, link with, or distribute the Library is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. - -9. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Library or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Library (or any work based on the Library), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Library or works based on it. - -10. Each time you redistribute the Library (or any work based on the Library), the recipient automatically receives a license from the original licensor to copy, distribute, link with or modify the Library subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties with this License. - -11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Library at all. For example, if a patent license would not permit royalty-free redistribution of the Library by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Library. - -If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply, and the section as a whole is intended to apply in other circumstances. - -It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. - -This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. - -12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Library under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. - -13. The Free Software Foundation may publish revised and/or new versions of the Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. - -Each version is given a distinguishing version number. If the Library specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. - -14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. - -NO WARRANTY - -15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - -16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. - -END OF TERMS AND CONDITIONS - -How to Apply These Terms to Your New Libraries - -If you develop a new library, and you want it to be of the greatest possible use to the public, we recommend making it free software that everyone can redistribute and change. You can do so by permitting redistribution under these terms (or, alternatively, under the terms of the ordinary General Public License). - -To apply these terms, attach the following notices to the library. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. - - one line to give the library's name and an idea of what it does. - Copyright (C) year name of author - - This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; version 2.1 of the License. - - This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Also add information on how to contact you by electronic and paper mail. - -You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the library, if necessary. Here is a sample; alter the names: - -Yoyodyne, Inc., hereby disclaims all copyright interest in -the library `Frob' (a library for tweaking knobs) written -by James Random Hacker. - -signature of Ty Coon, 1 April 1990 -Ty Coon, President of Vice -That's all there is to it! diff --git a/tools/reddit/README.md b/tools/reddit/README.md deleted file mode 100644 index d9c603ba63..0000000000 --- a/tools/reddit/README.md +++ /dev/null @@ -1,54 +0,0 @@ -# @corbits/reddit-tools - -A minimal Reddit search integration: one client, one `@intx/agent` tool -bundle. Built for the reddit-opportunity-scanner workflow but -not specific to it — any agent that needs to search Reddit pins this -package. - -## Why ScrapeCreators, not Reddit's own API - -Matching the OG `gtm-workbench`'s `tools-reddit` package: Reddit's public -JSON endpoints get IP-blocked from datacenter hosts, and its -authenticated API needs an OAuth flow this platform does not run -server-side. This client proxies through -[ScrapeCreators](https://scrapecreators.com) instead — a plain `GET` -with an `x-api-key` header, no SDK. There is no separate Reddit -credential: it reuses the same ScrapeCreators API key any other -ScrapeCreators-backed integration in this workspace would use. - -## Tools - -- `reddit_search` — searches Reddit across all subreddits for a query. -- `reddit_subreddit_search` — searches one named subreddit for a query. - -Both return posts as `{ title, url, permalink, subreddit, createdAt, -upvotes, numComments }`, newest-first as ScrapeCreators returns them. -There is no "fetch one thread" tool — the OG's `tools-reddit` package -only ever exposed the two search endpoints above, so this port keeps -the same minimal surface. - -## Credential - -The bundle's env requires `scrapeCreatorsApiKey`. When it is absent or -empty, the tool never throws — it returns a completed result with -`isError: true` and content naming the source "not connected", so a -calling agent can degrade gracefully instead of failing its turn. - -## Usage - -```ts -import { redditTools } from "@corbits/reddit-tools"; - -const agent = defineAgent({ - // ... - tools: [redditTools], -}); -``` - -## Running tests - -``` -cd tools/reddit && bun test -``` - -No live database required. diff --git a/tools/reddit/package.json b/tools/reddit/package.json deleted file mode 100644 index d65a21dc75..0000000000 --- a/tools/reddit/package.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "name": "@corbits/reddit-tools", - "version": "0.0.2", - "description": "Reddit integration (via ScrapeCreators): a minimal client and an @intx/agent tool bundle for searching Reddit sitewide or within one subreddit", - "license": "LGPL-2.1-only", - "repository": { - "type": "git", - "url": "https://github.com/corbitsdev/workbench.git", - "directory": "tools/reddit" - }, - "files": [ - "src", - "LICENSE", - "README.md" - ], - "type": "module", - "exports": { - ".": "./src/index.ts" - }, - "publishConfig": { - "access": "public" - }, - "scripts": { - "typecheck": "tsc --noEmit", - "test": "bun test" - }, - "dependencies": { - "arktype": "catalog:" - }, - "devDependencies": { - "@types/bun": "catalog:", - "typescript": "catalog:" - }, - "peerDependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0" - }, - "interchange": { - "credentials": [ - { - "handle": "scrapecreators" - } - ] - } -} diff --git a/tools/reddit/src/client.test.ts b/tools/reddit/src/client.test.ts deleted file mode 100644 index 62e717cb2c..0000000000 --- a/tools/reddit/src/client.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { expect, test } from "bun:test"; - -import { searchReddit, searchSubreddit } from "./client"; - -const RAW_POST_EPOCH = { - title: "Anyone using a tool for this?", - url: "https://reddit.com/r/startups/comments/abc123/anyone_using_a_tool_for_this", - permalink: "/r/startups/comments/abc123/anyone_using_a_tool_for_this", - subreddit: "startups", - created_utc: 1770000000, - ups: 42, - num_comments: 7, -}; - -const RAW_POST_ISO = { - title: "Frustrated with our current workflow", - url: "https://reddit.com/r/smallbusiness/comments/def456/frustrated", - permalink: "/r/smallbusiness/comments/def456/frustrated", - subreddit: "smallbusiness", - created_at: "2026-08-01T12:00:00.000Z", - upvotes: 11, - comments: 3, -}; - -test("searchReddit returns parsed posts, tolerating the epoch created_utc shape", async () => { - const fetchImpl = (async () => - new Response(JSON.stringify({ posts: [RAW_POST_EPOCH] }), { - status: 200, - })) as unknown as typeof fetch; - - const posts = await searchReddit({ apiKey: "test-key", fetchImpl }, { query: "workflow tool" }); - expect(posts).toEqual([ - { - title: "Anyone using a tool for this?", - url: "https://reddit.com/r/startups/comments/abc123/anyone_using_a_tool_for_this", - permalink: "/r/startups/comments/abc123/anyone_using_a_tool_for_this", - subreddit: "startups", - createdAt: new Date(1770000000 * 1000).toISOString(), - upvotes: 42, - numComments: 7, - }, - ]); -}); - -test("searchSubreddit returns parsed posts, tolerating the ISO created_at shape", async () => { - const fetchImpl = (async () => - new Response(JSON.stringify({ data: [RAW_POST_ISO] }), { - status: 200, - })) as unknown as typeof fetch; - - const posts = await searchSubreddit( - { apiKey: "test-key", fetchImpl }, - { subreddit: "smallbusiness", query: "workflow" }, - ); - expect(posts).toEqual([ - { - title: "Frustrated with our current workflow", - url: "https://reddit.com/r/smallbusiness/comments/def456/frustrated", - permalink: "/r/smallbusiness/comments/def456/frustrated", - subreddit: "smallbusiness", - createdAt: "2026-08-01T12:00:00.000Z", - upvotes: 11, - numComments: 3, - }, - ]); -}); - -test("posts the api key as the x-api-key header", async () => { - const captured: { key: string | null } = { key: null }; - const fetchImpl = (async (_input: URL | string, init?: RequestInit) => { - captured.key = (init?.headers as Record<string, string> | undefined)?.["x-api-key"] ?? null; - return new Response(JSON.stringify({ posts: [] }), { status: 200 }); - }) as unknown as typeof fetch; - - await searchReddit({ apiKey: "secret", fetchImpl }, { query: "anything" }); - expect(captured.key).toBe("secret"); -}); - -test("throws on a non-ok HTTP response", async () => { - const fetchImpl = (async () => new Response("nope", { status: 401 })) as unknown as typeof fetch; - - await expect(searchReddit({ apiKey: "bad", fetchImpl }, { query: "anything" })).rejects.toThrow( - /401/, - ); -}); - -test("drops posts that do not match the expected shape rather than throwing", async () => { - const fetchImpl = (async () => - new Response(JSON.stringify({ posts: [{ title: "no permalink here" }] }), { - status: 200, - })) as unknown as typeof fetch; - - const posts = await searchReddit({ apiKey: "test-key", fetchImpl }, { query: "anything" }); - expect(posts).toEqual([]); -}); - -test("returns no posts when the response body has no recognizable list", async () => { - const fetchImpl = (async () => - new Response(JSON.stringify({ unexpected: true }), { - status: 200, - })) as unknown as typeof fetch; - - const posts = await searchSubreddit( - { apiKey: "test-key", fetchImpl }, - { subreddit: "startups", query: "anything" }, - ); - expect(posts).toEqual([]); -}); diff --git a/tools/reddit/src/client.ts b/tools/reddit/src/client.ts deleted file mode 100644 index ca7a5f0463..0000000000 --- a/tools/reddit/src/client.ts +++ /dev/null @@ -1,164 +0,0 @@ -// A minimal Reddit search client. Reddit's own public JSON endpoints get -// IP-blocked from datacenter hosts, and its authenticated API needs an -// OAuth flow this platform does not run server-side — so, matching the -// OG gtm-workbench's `tools-reddit` package, this client proxies through -// ScrapeCreators (`https://api.scrapecreators.com`, header -// `x-api-key`) rather than Reddit directly. There is no separate Reddit -// credential: this reuses the same ScrapeCreators API key any other -// ScrapeCreators-backed integration in this workspace would use. -import { type } from "arktype"; - -export const RedditPost = type({ - title: "string", - url: "string", - permalink: "string", - subreddit: "string", - createdAt: "string", - upvotes: "number", - numComments: "number", -}); -export type RedditPost = typeof RedditPost.infer; - -export interface RedditClientConfig { - /** - * Absent when `fetchImpl` already authenticates its requests (a - * mediated credential's `fetch` injects its own `x-api-key` header - * per call — see `@intx/harness`'s `createHttpCredentialProvider`). - * Supplied directly for tests and any caller holding a raw key. - */ - readonly apiKey?: string; - /** Override for tests; defaults to ScrapeCreators' real base URL. */ - readonly baseUrl?: string; - readonly fetchImpl?: typeof fetch; -} - -export interface RedditSearchParams { - readonly query: string; - readonly sort?: string; - readonly timeframe?: string; -} - -export interface RedditSubredditSearchParams extends RedditSearchParams { - readonly subreddit: string; -} - -const DEFAULT_BASE_URL = "https://api.scrapecreators.com"; -const MAX_ERROR_BODY_LENGTH = 500; - -interface RawRedditPost { - readonly title?: unknown; - readonly url?: unknown; - readonly permalink?: unknown; - readonly subreddit?: unknown; - readonly created_utc?: unknown; - readonly created_at?: unknown; - readonly ups?: unknown; - readonly upvotes?: unknown; - readonly num_comments?: unknown; - readonly comments?: unknown; -} - -function extractRawPosts(body: unknown): readonly RawRedditPost[] { - if (Array.isArray(body)) return body as RawRedditPost[]; - if (typeof body === "object" && body !== null) { - const record = body as Record<string, unknown>; - for (const key of ["posts", "data", "results"]) { - const value = record[key]; - if (Array.isArray(value)) return value as RawRedditPost[]; - } - } - return []; -} - -function toCreatedAt(raw: RawRedditPost): string | undefined { - if (typeof raw.created_at === "string") return raw.created_at; - if (typeof raw.created_utc === "number") { - return new Date(raw.created_utc * 1000).toISOString(); - } - return undefined; -} - -function parseRawPost(raw: RawRedditPost): RedditPost | undefined { - const createdAt = toCreatedAt(raw); - if ( - typeof raw.title !== "string" || - typeof raw.url !== "string" || - typeof raw.permalink !== "string" || - typeof raw.subreddit !== "string" || - createdAt === undefined - ) { - return undefined; - } - const upvotes = raw.ups ?? raw.upvotes; - const numComments = raw.num_comments ?? raw.comments; - const parsed = RedditPost({ - title: raw.title, - url: raw.url, - permalink: raw.permalink, - subreddit: raw.subreddit, - createdAt, - upvotes: typeof upvotes === "number" ? upvotes : 0, - numComments: typeof numComments === "number" ? numComments : 0, - }); - return parsed instanceof type.errors ? undefined : parsed; -} - -async function fetchPosts( - config: RedditClientConfig, - path: string, - params: Record<string, string | undefined>, -): Promise<readonly RedditPost[]> { - const url = new URL(path, config.baseUrl ?? DEFAULT_BASE_URL); - for (const [key, value] of Object.entries(params)) { - if (value !== undefined && value !== "") url.searchParams.set(key, value); - } - const doFetch = config.fetchImpl ?? fetch; - const response = await doFetch(url.toString(), { - headers: config.apiKey !== undefined ? { "x-api-key": config.apiKey } : {}, - }); - if (!response.ok) { - const body = await response.text().catch(() => ""); - const detail = body.length > 0 ? `: ${body.slice(0, MAX_ERROR_BODY_LENGTH)}` : ""; - throw new Error( - `ScrapeCreators Reddit request failed: ${response.status} ${response.statusText}${detail}`, - ); - } - const body: unknown = await response.json(); - const posts = extractRawPosts(body) - .map(parseRawPost) - .filter((post): post is RedditPost => post !== undefined); - return posts; -} - -/** - * Searches Reddit across all subreddits for a query. Throws on any - * transport, HTTP, or shape failure — callers that need graceful - * degradation (e.g. the reddit-tools tool bundle) catch at their own - * boundary rather than this client silently swallowing errors. - */ -export function searchReddit( - config: RedditClientConfig, - params: RedditSearchParams, -): Promise<readonly RedditPost[]> { - return fetchPosts(config, "/v1/reddit/search", { - query: params.query, - sort: params.sort, - timeframe: params.timeframe, - }); -} - -/** - * Searches one subreddit for a query. Same failure contract as - * `searchReddit`. - */ -export function searchSubreddit( - config: RedditClientConfig, - params: RedditSubredditSearchParams, -): Promise<readonly RedditPost[]> { - return fetchPosts(config, "/v1/reddit/subreddit/search", { - subreddit: params.subreddit, - query: params.query, - sort: params.sort, - timeframe: params.timeframe, - }); -} diff --git a/tools/reddit/src/index.ts b/tools/reddit/src/index.ts deleted file mode 100644 index 48fcd0ac19..0000000000 --- a/tools/reddit/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { RedditPost, searchReddit, searchSubreddit } from "./client"; -export type { RedditClientConfig, RedditSearchParams, RedditSubredditSearchParams } from "./client"; -export { REDDIT_SEARCH_TOOL, REDDIT_SUBREDDIT_SEARCH_TOOL, redditTools } from "./tool"; -export type { RedditEnv } from "./tool"; diff --git a/tools/reddit/src/manifest.test.ts b/tools/reddit/src/manifest.test.ts deleted file mode 100644 index 85e80c922e..0000000000 --- a/tools/reddit/src/manifest.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -// Proves this package's own `package.json` satisfies the vendored -// `PackageJSON` manifest schema (`@intx/types/src/package-json.ts`) -// with an `interchange.credentials` declaration the deploy-time -// `ToolPackageManifest` harvests into `topLevel[].credentials` — the -// static half of the credential-binding seam adopts. Not a test -// of the vendored schema itself, only that this package's manifest is -// shaped the way that schema requires. -import { expect, test } from "bun:test"; -import { type } from "arktype"; -import { PackageJSON } from "@intx/types/package-json"; - -import packageJson from "../package.json"; - -test("package.json parses against the vendored PackageJSON manifest schema", () => { - const parsed = PackageJSON(packageJson); - expect(parsed instanceof type.errors).toBe(false); -}); - -test("declares exactly one credential handle: scrapecreators", () => { - const parsed = PackageJSON(packageJson); - if (parsed instanceof type.errors) { - throw new Error(parsed.summary); - } - expect(parsed.interchange?.credentials).toEqual([{ handle: "scrapecreators" }]); -}); diff --git a/tools/reddit/src/tool.test.ts b/tools/reddit/src/tool.test.ts deleted file mode 100644 index 5155eee534..0000000000 --- a/tools/reddit/src/tool.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { expect, test } from "bun:test"; -import type { ToolCall } from "@intx/types/runtime"; -import type { CredentialCapability, MediatedCredential } from "@intx/types"; - -import { REDDIT_SEARCH_TOOL, REDDIT_SUBREDDIT_SEARCH_TOOL, redditTools } from "./tool"; -import type { RedditEnv } from "./tool"; - -const SEARCH_CALL: ToolCall = { - id: "call_1", - name: REDDIT_SEARCH_TOOL, - arguments: { query: "onboarding pain" }, -}; - -const SUBREDDIT_SEARCH_CALL: ToolCall = { - id: "call_2", - name: REDDIT_SUBREDDIT_SEARCH_TOOL, - arguments: { subreddit: "startups", query: "onboarding pain" }, -}; - -/** - * A fake `credentials` capability mirroring the platform's own - * `createCredentialCapability`/`createHttpCredentialProvider` shape: a - * bound `secret` resolves to a mediated `fetch` that injects a header - * and delegates to `globalThis.fetch`; an unbound handle throws, - * matching the real gate's "no credential is bound to handle" failure. - */ -function fakeCredentials(secret: string | undefined): CredentialCapability { - return { - resolve(handle: string): Promise<MediatedCredential> { - if (secret === undefined) { - return Promise.reject(new Error(`no credential is bound to handle "${handle}"`)); - } - return Promise.resolve({ - kind: "http", - fetch: (input, init) => { - const headers = new Headers(init?.headers); - headers.set("x-api-key", secret); - return fetch(input as string | URL, { ...init, headers }); - }, - dispose: () => {}, - }); - }, - }; -} - -function fakeEnv(credentials: CredentialCapability | undefined): RedditEnv { - return { credentials } as unknown as RedditEnv; -} - -test("declares both reddit tools", () => { - const bundle = redditTools(fakeEnv(fakeCredentials("key"))); - expect(bundle.definitions.map((d) => d.name)).toEqual([ - REDDIT_SEARCH_TOOL, - REDDIT_SUBREDDIT_SEARCH_TOOL, - ]); -}); - -test("degrades to a non-throwing 'not connected' error when no credential is bound", async () => { - const bundle = redditTools(fakeEnv(fakeCredentials(undefined))); - const result = await bundle.run(SEARCH_CALL, new AbortController().signal); - expect(result.isError).toBe(true); - expect(result.content).toMatch(/not connected/i); -}); - -test("degrades the same way when the step carries no credentials capability at all", async () => { - const bundle = redditTools(fakeEnv(undefined)); - const result = await bundle.run(SUBREDDIT_SEARCH_CALL, new AbortController().signal); - expect(result.isError).toBe(true); - expect(result.content).toMatch(/not connected/i); -}); - -test("rejects a search call with no query, without throwing", async () => { - const bundle = redditTools(fakeEnv(fakeCredentials("key"))); - const result = await bundle.run( - { id: "call_3", name: REDDIT_SEARCH_TOOL, arguments: {} }, - new AbortController().signal, - ); - expect(result.isError).toBe(true); - expect(result.content).toContain("requires a non-empty query"); -}); - -test("rejects a subreddit search call with no subreddit, without throwing", async () => { - const bundle = redditTools(fakeEnv(fakeCredentials("key"))); - const result = await bundle.run( - { - id: "call_4", - name: REDDIT_SUBREDDIT_SEARCH_TOOL, - arguments: { query: "onboarding" }, - }, - new AbortController().signal, - ); - expect(result.isError).toBe(true); - expect(result.content).toContain("requires non-empty subreddit and query"); -}); - -test("returns posts as JSON content on a successful search call", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = (async (_input: unknown, init?: RequestInit) => { - expect((init?.headers as Headers | undefined)?.get("x-api-key")).toBe("key"); - return new Response( - JSON.stringify({ - posts: [ - { - title: "Onboarding takes forever", - url: "https://reddit.com/r/startups/comments/xyz/onboarding", - permalink: "/r/startups/comments/xyz/onboarding", - subreddit: "startups", - created_at: "2026-08-01T00:00:00.000Z", - ups: 5, - num_comments: 2, - }, - ], - }), - { status: 200 }, - ); - }) as unknown as typeof fetch; - try { - const bundle = redditTools(fakeEnv(fakeCredentials("key"))); - const result = await bundle.run(SEARCH_CALL, new AbortController().signal); - expect(result.isError).toBeUndefined(); - expect(JSON.parse(result.content as string)).toEqual({ - posts: [ - { - title: "Onboarding takes forever", - url: "https://reddit.com/r/startups/comments/xyz/onboarding", - permalink: "/r/startups/comments/xyz/onboarding", - subreddit: "startups", - createdAt: "2026-08-01T00:00:00.000Z", - upvotes: 5, - numComments: 2, - }, - ], - }); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("degrades to an error result (never throws) when the underlying call fails", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = (async () => new Response("nope", { status: 500 })) as unknown as typeof fetch; - try { - const bundle = redditTools(fakeEnv(fakeCredentials("key"))); - const result = await bundle.run(SUBREDDIT_SEARCH_CALL, new AbortController().signal); - expect(result.isError).toBe(true); - } finally { - globalThis.fetch = originalFetch; - } -}); diff --git a/tools/reddit/src/tool.ts b/tools/reddit/src/tool.ts deleted file mode 100644 index 11f7694b9b..0000000000 --- a/tools/reddit/src/tool.ts +++ /dev/null @@ -1,208 +0,0 @@ -// The `reddit_search` and `reddit_subreddit_search` tool bundle: an -// agent-facing wrapper around `./client.ts` that never throws. A missing -// credential or a failed call both come back as a completed `ToolResult` -// with `isError: true` — the calling agent's job (e.g. the -// reddit-opportunity-scanner workflow) is to read that as "Reddit is not -// available right now" and say so honestly, never to have the run itself -// fail because the source is unreachable. -import { defineTool } from "@intx/agent"; -import type { BaseEnv } from "@intx/agent"; -import type { CredentialCapability } from "@intx/types"; -import type { ToolCall, ToolResult } from "@intx/types/runtime"; - -import { searchReddit, searchSubreddit } from "./client"; -import type { RedditSearchParams } from "./client"; - -export const REDDIT_SEARCH_TOOL = "reddit_search"; -export const REDDIT_SUBREDDIT_SEARCH_TOOL = "reddit_subreddit_search"; - -/** The handle this package declares in `interchange.credentials`. */ -const SCRAPECREATORS_CREDENTIAL_HANDLE = "scrapecreators"; - -/** - * Env this bundle needs beyond `BaseEnv`: the mediated-credential - * capability, bound to a ScrapeCreators credential. There is no separate - * Reddit credential — Reddit is reached through ScrapeCreators, the same - * as any other ScrapeCreators-backed integration, since Reddit's own - * public endpoints are IP-blocked from datacenter hosts and its - * authenticated API needs a server-side OAuth flow this platform does - * not run. - */ -export interface RedditEnv extends BaseEnv { - readonly credentials?: CredentialCapability; -} - -function notConnectedResult(callId: string): ToolResult { - return { - callId, - content: "Reddit is not connected for this user.", - isError: true, - }; -} - -/** - * Resolve this bundle's mediated ScrapeCreators credential, or `null` - * when it is not connected -- an absent `env.credentials`, an unbound - * handle, or a denied grant all collapse to the same "not connected" - * signal, never a thrown error out of the tool. - */ -async function resolveRedditCredential( - env: RedditEnv, -): Promise<{ fetchImpl: typeof fetch } | null> { - if (env.credentials === undefined) return null; - try { - const mediated = await env.credentials.resolve(SCRAPECREATORS_CREDENTIAL_HANDLE); - return { fetchImpl: mediated.fetch as unknown as typeof fetch }; - } catch { - return null; - } -} - -function requiredStringArg(call: ToolCall, name: string): string | undefined { - const value = call.arguments[name]; - return typeof value === "string" && value !== "" ? value : undefined; -} - -function optionalStringArg(call: ToolCall, name: string): string | undefined { - const value = call.arguments[name]; - return typeof value === "string" ? value : undefined; -} - -function searchOptions(call: ToolCall): Pick<RedditSearchParams, "sort" | "timeframe"> { - const options: { sort?: string; timeframe?: string } = {}; - const sort = optionalStringArg(call, "sort"); - const timeframe = optionalStringArg(call, "timeframe"); - if (sort !== undefined) options.sort = sort; - if (timeframe !== undefined) options.timeframe = timeframe; - return options; -} - -async function runRedditSearch(env: RedditEnv, call: ToolCall): Promise<ToolResult> { - const credential = await resolveRedditCredential(env); - if (credential === null) { - return notConnectedResult(call.id); - } - const query = requiredStringArg(call, "query"); - if (query === undefined) { - return { - callId: call.id, - content: `${REDDIT_SEARCH_TOOL} requires a non-empty query argument`, - isError: true, - }; - } - try { - const posts = await searchReddit( - { fetchImpl: credential.fetchImpl }, - { query, ...searchOptions(call) }, - ); - return { callId: call.id, content: JSON.stringify({ posts }) }; - } catch (err) { - return { - callId: call.id, - content: err instanceof Error ? err.message : String(err), - isError: true, - }; - } -} - -async function runRedditSubredditSearch(env: RedditEnv, call: ToolCall): Promise<ToolResult> { - const credential = await resolveRedditCredential(env); - if (credential === null) { - return notConnectedResult(call.id); - } - const subreddit = requiredStringArg(call, "subreddit"); - const query = requiredStringArg(call, "query"); - if (subreddit === undefined || query === undefined) { - return { - callId: call.id, - content: `${REDDIT_SUBREDDIT_SEARCH_TOOL} requires non-empty subreddit and query arguments`, - isError: true, - }; - } - try { - const posts = await searchSubreddit( - { fetchImpl: credential.fetchImpl }, - { subreddit, query, ...searchOptions(call) }, - ); - return { callId: call.id, content: JSON.stringify({ posts }) }; - } catch (err) { - return { - callId: call.id, - content: err instanceof Error ? err.message : String(err), - isError: true, - }; - } -} - -/** - * The `@corbits/reddit-tools` bundle factory: two tools sharing one - * mediated credential (handle "scrapecreators"). Pin this package's - * `reddit` bundle on any agent that needs to search Reddit, sitewide or - * scoped to one subreddit. - */ -export const redditTools = defineTool<RedditEnv>({ - id: "@corbits/reddit-tools/reddit", - requires: ["credentials"], - definitions: [{ name: REDDIT_SEARCH_TOOL }, { name: REDDIT_SUBREDDIT_SEARCH_TOOL }], - factory: (env) => ({ - definitions: [ - { - name: REDDIT_SEARCH_TOOL, - description: - "Searches Reddit across all subreddits for a query (title, url, " + - "permalink, subreddit, created-at, upvotes, comment count). " + - 'Returns an error result naming "not connected" when no ' + - "ScrapeCreators credential is configured — never fabricate " + - "results when this happens.", - inputSchema: { - type: "object", - properties: { - query: { type: "string", description: "Search query text." }, - sort: { - type: "string", - description: 'e.g. "relevance", "new", "top".', - }, - timeframe: { - type: "string", - description: 'e.g. "day", "week", "month", "year", "all".', - }, - }, - required: ["query"], - }, - }, - { - name: REDDIT_SUBREDDIT_SEARCH_TOOL, - description: - "Searches one named subreddit for a query. Same result shape " + - `and honest "not connected" degradation as ${REDDIT_SEARCH_TOOL}.`, - inputSchema: { - type: "object", - properties: { - subreddit: { - type: "string", - description: 'Subreddit name, without "r/", e.g. "startups".', - }, - query: { type: "string", description: "Search query text." }, - sort: { - type: "string", - description: 'e.g. "relevance", "new", "top".', - }, - timeframe: { - type: "string", - description: 'e.g. "day", "week", "month", "year", "all".', - }, - }, - required: ["subreddit", "query"], - }, - }, - ], - run: (call, _signal) => { - switch (call.name) { - case REDDIT_SUBREDDIT_SEARCH_TOOL: - return runRedditSubredditSearch(env, call); - default: - return runRedditSearch(env, call); - } - }, - }), -}); diff --git a/tools/reddit/tsconfig.json b/tools/reddit/tsconfig.json deleted file mode 100644 index e125e90614..0000000000 --- a/tools/reddit/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "./tsconfig.src.json", - "compilerOptions": { - "composite": false, - "noEmit": true, - "disableSourceOfProjectReferenceRedirect": true, - "declaration": false, - "declarationMap": false, - "emitDeclarationOnly": false, - "tsBuildInfoFile": "tsconfig.tsbuildinfo", - "rootDir": "../.." - }, - "include": ["src"], - "exclude": [] -} diff --git a/tools/reddit/tsconfig.src.json b/tools/reddit/tsconfig.src.json deleted file mode 100644 index fc8724305f..0000000000 --- a/tools/reddit/tsconfig.src.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "include": ["src", "package.json", "src/**/*.json"], - "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"], - "compilerOptions": { - "types": ["bun"], - "composite": true, - "emitDeclarationOnly": true, - "outDir": "dist", - "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" - } -} diff --git a/tools/web-search/LICENSE b/tools/web-search/LICENSE deleted file mode 100644 index 173c3a21d0..0000000000 --- a/tools/web-search/LICENSE +++ /dev/null @@ -1,176 +0,0 @@ -GNU LESSER GENERAL PUBLIC LICENSE - -Version 2.1, February 1999 - -Copyright (C) 1991, 1999 Free Software Foundation, Inc. -51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - -Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. - -[This is the first released version of the Lesser GPL. It also counts as the successor of the GNU Library Public License, version 2, hence the version number 2.1.] - -Preamble - -The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public Licenses are intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. - -This license, the Lesser General Public License, applies to some specially designated software packages--typically libraries--of the Free Software Foundation and other authors who decide to use it. You can use it too, but we suggest you first think carefully about whether this license or the ordinary General Public License is the better strategy to use in any particular case, based on the explanations below. - -When we speak of free software, we are referring to freedom of use, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish); that you receive source code or can get it if you want it; that you can change the software and use pieces of it in new free programs; and that you are informed that you can do these things. - -To protect your rights, we need to make restrictions that forbid distributors to deny you these rights or to ask you to surrender these rights. These restrictions translate to certain responsibilities for you if you distribute copies of the library or if you modify it. - -For example, if you distribute copies of the library, whether gratis or for a fee, you must give the recipients all the rights that we gave you. You must make sure that they, too, receive or can get the source code. If you link other code with the library, you must provide complete object files to the recipients, so that they can relink them with the library after making changes to the library and recompiling it. And you must show them these terms so they know their rights. - -We protect your rights with a two-step method: (1) we copyright the library, and (2) we offer you this license, which gives you legal permission to copy, distribute and/or modify the library. - -To protect each distributor, we want to make it very clear that there is no warranty for the free library. Also, if the library is modified by someone else and passed on, the recipients should know that what they have is not the original version, so that the original author's reputation will not be affected by problems that might be introduced by others. - -Finally, software patents pose a constant threat to the existence of any free program. We wish to make sure that a company cannot effectively restrict the users of a free program by obtaining a restrictive license from a patent holder. Therefore, we insist that any patent license obtained for a version of the library must be consistent with the full freedom of use specified in this license. - -Most GNU software, including some libraries, is covered by the ordinary GNU General Public License. This license, the GNU Lesser General Public License, applies to certain designated libraries, and is quite different from the ordinary General Public License. We use this license for certain libraries in order to permit linking those libraries into non-free programs. - -When a program is linked with a library, whether statically or using a shared library, the combination of the two is legally speaking a combined work, a derivative of the original library. The ordinary General Public License therefore permits such linking only if the entire combination fits its criteria of freedom. The Lesser General Public License permits more lax criteria for linking other code with the library. - -We call this license the "Lesser" General Public License because it does Less to protect the user's freedom than the ordinary General Public License. It also provides other free software developers Less of an advantage over competing non-free programs. These disadvantages are the reason we use the ordinary General Public License for many libraries. However, the Lesser license provides advantages in certain special circumstances. - -For example, on rare occasions, there may be a special need to encourage the widest possible use of a certain library, so that it becomes a de-facto standard. To achieve this, non-free programs must be allowed to use the library. A more frequent case is that a free library does the same job as widely used non-free libraries. In this case, there is little to gain by limiting the free library to free software only, so we use the Lesser General Public License. - -In other cases, permission to use a particular library in non-free programs enables a greater number of people to use a large body of free software. For example, permission to use the GNU C Library in non-free programs enables many more people to use the whole GNU operating system, as well as its variant, the GNU/Linux operating system. - -Although the Lesser General Public License is Less protective of the users' freedom, it does ensure that the user of a program that is linked with the Library has the freedom and the wherewithal to run that program using a modified version of the Library. - -The precise terms and conditions for copying, distribution and modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, whereas the latter must be combined with the library in order to run. - -GNU LESSER GENERAL PUBLIC LICENSE -TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - -0. This License Agreement applies to any software library or other program which contains a notice placed by the copyright holder or other authorized party saying it may be distributed under the terms of this Lesser General Public License (also called "this License"). Each licensee is addressed as "you". - -A "library" means a collection of software functions and/or data prepared so as to be conveniently linked with application programs (which use some of those functions and data) to form executables. - -The "Library", below, refers to any such software library or work which has been distributed under these terms. A "work based on the Library" means either the Library or any derivative work under copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with modifications and/or translated straightforwardly into another language. (Hereinafter, translation is included without limitation in the term "modification".) - -"Source code" for a work means the preferred form of the work for making modifications to it. For a library, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library. - -Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running a program using the Library is not restricted, and output from such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does. - -1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and distribute a copy of this License along with the Library. - -You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. - -2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: - - a) The modified work must itself be a software library. - - b) You must cause the files modified to carry prominent notices stating that you changed the files and the date of any change. - - c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms of this License. - - d) If a facility in the modified Library refers to a function or a table of data to be supplied by an application program that uses the facility, other than as an argument passed when the facility is invoked, then you must make a good faith effort to ensure that, in the event an application does not supply such function or table, the facility still operates, and performs whatever part of its purpose remains meaningful. - -(For example, a function in a library to compute square roots has a purpose that is entirely well-defined independent of the application. Therefore, Subsection 2d requires that any application-supplied function or table used by this function must be optional: if the application does not supply it, the square root function must still compute square roots.) - -These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Library, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Library, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. - -Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Library. - -In addition, mere aggregation of another work not based on the Library with the Library (or with a work based on the Library) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. - -3. You may opt to apply the terms of the ordinary GNU General Public License instead of this License to a given copy of the Library. To do this, you must alter all the notices that refer to this License, so that they refer to the ordinary GNU General Public License, version 2, instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. - -Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. - -This option is useful when you wish to copy part of the code of the Library into a program that is not a library. - -4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange. - -If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code, even though third parties are not compelled to copy the source along with the object code. - -5. A program that contains no derivative of any portion of the Library, but is designed to work with the Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in isolation, is not a derivative work of the Library, and therefore falls outside the scope of this License. - -However, linking a "work that uses the Library" with the Library creates an executable that is a derivative of the Library (because it contains portions of the Library), rather than a "work that uses the library". The executable is therefore covered by this License. Section 6 states terms for distribution of such executables. - -When a "work that uses the Library" uses material from a header file that is part of the Library, the object code for the work may be a derivative work of the Library even though the source code is not. Whether this is true is especially significant if the work can be linked without the Library, or if the work is itself a library. The threshold for this to be true is not precisely defined by law. - -If such an object file uses only numerical parameters, data structure layouts and accessors, and small macros and small inline functions (ten lines or less in length), then the use of the object file is unrestricted, regardless of whether it is legally a derivative work. (Executables containing this object code plus portions of the Library will still fall under Section 6.) - -Otherwise, if the work is a derivative of the Library, you may distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. - -6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work under terms of your choice, provided that the terms permit modification of the work for the customer's own use and reverse engineering for debugging such modifications. - -You must give prominent notice with each copy of the work that the Library is used in it and that the Library and its use are covered by this License. You must supply a copy of this License. If the work during execution displays copyright notices, you must include the copyright notice for the Library among them, as well as a reference directing the user to the copy of this License. Also, you must do one of these things: - - a) Accompany the work with the complete corresponding machine-readable source code for the Library including whatever changes were used in the work (which must be distributed under Sections 1 and 2 above); and, if the work is an executable linked with the Library, with the complete machine-readable "work that uses the Library", as object code and/or source code, so that the user can modify the Library and then relink to produce a modified executable containing the modified Library. (It is understood that the user who changes the contents of definitions files in the Library will not necessarily be able to recompile the application to use the modified definitions.) - - b) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (1) uses at run time a copy of the library already present on the user's computer system, rather than copying library functions into the executable, and (2) will operate properly with a modified version of the library, if the user installs one, as long as the modified version is interface-compatible with the version that the work was made with. - - c) Accompany the work with a written offer, valid for at least three years, to give the same user the materials specified in Subsection 6a, above, for a charge no more than the cost of performing this distribution. - - d) If distribution of the work is made by offering access to copy from a designated place, offer equivalent access to copy the above specified materials from the same place. - - e) Verify that the user has already received a copy of these materials or that you have already sent this user a copy. - -For an executable, the required form of the "work that uses the Library" must include any data and utility programs needed for reproducing the executable from it. However, as a special exception, the materials to be distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. - -It may happen that this requirement contradicts the license restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. - -7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined library, provided that the separate distribution of the work based on the Library and of the other library facilities is otherwise permitted, and provided that you do these two things: - - a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities. This must be distributed under the terms of the Sections above. - - b) Give prominent notice with the combined library of the fact that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. - -8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, link with, or distribute the Library is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. - -9. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Library or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Library (or any work based on the Library), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Library or works based on it. - -10. Each time you redistribute the Library (or any work based on the Library), the recipient automatically receives a license from the original licensor to copy, distribute, link with or modify the Library subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties with this License. - -11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Library at all. For example, if a patent license would not permit royalty-free redistribution of the Library by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Library. - -If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply, and the section as a whole is intended to apply in other circumstances. - -It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. - -This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. - -12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Library under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. - -13. The Free Software Foundation may publish revised and/or new versions of the Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. - -Each version is given a distinguishing version number. If the Library specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. - -14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. - -NO WARRANTY - -15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - -16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. - -END OF TERMS AND CONDITIONS - -How to Apply These Terms to Your New Libraries - -If you develop a new library, and you want it to be of the greatest possible use to the public, we recommend making it free software that everyone can redistribute and change. You can do so by permitting redistribution under these terms (or, alternatively, under the terms of the ordinary General Public License). - -To apply these terms, attach the following notices to the library. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. - - one line to give the library's name and an idea of what it does. - Copyright (C) year name of author - - This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; version 2.1 of the License. - - This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Also add information on how to contact you by electronic and paper mail. - -You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the library, if necessary. Here is a sample; alter the names: - -Yoyodyne, Inc., hereby disclaims all copyright interest in -the library `Frob' (a library for tweaking knobs) written -by James Random Hacker. - -signature of Ty Coon, 1 April 1990 -Ty Coon, President of Vice -That's all there is to it! diff --git a/tools/web-search/README.md b/tools/web-search/README.md deleted file mode 100644 index 1b1f5e0846..0000000000 --- a/tools/web-search/README.md +++ /dev/null @@ -1,41 +0,0 @@ -# @corbits/web-search-tools - -A minimal web-search integration: one client call, one `@intx/agent` tool -bundle. Built for the last-30-days-research workflow port; -backed by Exa, the same provider the OG gtm-workbench's -`last30days-research` workflow called for this source -(`packages/tools-exa`) — a real, honest backend rather than a placeholder. - -## Tools - -- `web_search` — searches the web for a query, returning up to 25 results - (default 5) as `{ url, title, publishedAt (ISO 8601), source: "web", -author? }`. When a result has no real publish date, `publishedAt` falls - back to fetch time and `provenance: "degraded"` is set, so it stays in - a date window but ranks below dated results instead of being dropped. - -## Credential - -The bundle's env requires `webSearchApiKey` (an Exa API key). When it is -absent or empty, the tool never throws — it returns a completed result -with `isError: true` and content naming the source "not connected", so a -calling agent can degrade gracefully instead of failing its turn. - -## Usage - -```ts -import { webSearchTools } from "@corbits/web-search-tools"; - -const agent = defineAgent({ - // ... - tools: [webSearchTools], -}); -``` - -## Running tests - -``` -cd tools/web-search && bun test -``` - -No live database required. diff --git a/tools/web-search/package.json b/tools/web-search/package.json deleted file mode 100644 index 986dd6e6b5..0000000000 --- a/tools/web-search/package.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "name": "@corbits/web-search-tools", - "version": "0.0.4", - "description": "Web search integration: a minimal Exa client and an @intx/agent tool bundle exposing web_search", - "license": "LGPL-2.1-only", - "repository": { - "type": "git", - "url": "https://github.com/corbitsdev/workbench.git", - "directory": "tools/web-search" - }, - "files": [ - "src", - "LICENSE", - "README.md" - ], - "type": "module", - "exports": { - ".": "./src/index.ts" - }, - "publishConfig": { - "access": "public" - }, - "scripts": { - "typecheck": "tsc --noEmit", - "test": "bun test" - }, - "dependencies": { - "arktype": "catalog:" - }, - "devDependencies": { - "@types/bun": "catalog:", - "typescript": "catalog:" - }, - "peerDependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0" - }, - "interchange": { - "credentials": [ - { - "handle": "exa" - } - ] - } -} diff --git a/tools/web-search/src/client.test.ts b/tools/web-search/src/client.test.ts deleted file mode 100644 index 932d82fbbe..0000000000 --- a/tools/web-search/src/client.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { expect, test } from "bun:test"; - -import { searchWeb } from "./client"; - -test("returns normalized results on a successful call", async () => { - const fetchImpl = (async (input: URL | string, init?: RequestInit) => { - expect(String(input)).toBe("https://api.exa.ai/search"); - expect(init?.method).toBe("POST"); - expect((init?.headers as Record<string, string> | undefined)?.["x-api-key"]).toBe("test-key"); - const body: unknown = JSON.parse(String(init?.body)); - expect(body).toEqual({ query: "agent workflows", numResults: 5 }); - return new Response( - JSON.stringify({ - results: [ - { - title: "Agent workflows explained", - url: "https://example.test/a", - publishedDate: "2026-08-01T00:00:00.000Z", - author: "Jane Doe", - }, - ], - }), - { status: 200 }, - ); - }) as unknown as typeof fetch; - - const results = await searchWeb({ apiKey: "test-key", fetchImpl }, { query: "agent workflows" }); - expect(results).toEqual([ - { - url: "https://example.test/a", - title: "Agent workflows explained", - publishedAt: "2026-08-01T00:00:00.000Z", - source: "web", - author: "Jane Doe", - }, - ]); -}); - -test("falls back to fetch time and marks provenance degraded when a result has no publish date", async () => { - const fetchImpl = (async () => - new Response( - JSON.stringify({ - results: [{ title: "Undated page", url: "https://example.test/b" }], - }), - { status: 200 }, - )) as unknown as typeof fetch; - - const results = await searchWeb({ apiKey: "test-key", fetchImpl }, { query: "topic" }); - expect(results).toHaveLength(1); - expect(results[0]?.provenance).toBe("degraded"); - expect(typeof results[0]?.publishedAt).toBe("string"); -}); - -test("drops results with no url", async () => { - const fetchImpl = (async () => - new Response(JSON.stringify({ results: [{ title: "No url" }] }), { - status: 200, - })) as unknown as typeof fetch; - - const results = await searchWeb({ apiKey: "test-key", fetchImpl }, { query: "topic" }); - expect(results).toEqual([]); -}); - -test("clamps numResults to the 1-25 range, defaulting to 5", async () => { - const captured: unknown[] = []; - const fetchImpl = (async (_input: URL | string, init?: RequestInit) => { - captured.push(JSON.parse(String(init?.body))); - return new Response(JSON.stringify({ results: [] }), { status: 200 }); - }) as unknown as typeof fetch; - - await searchWeb({ apiKey: "test-key", fetchImpl }, { query: "topic", numResults: 999 }); - await searchWeb({ apiKey: "test-key", fetchImpl }, { query: "topic", numResults: -1 }); - expect(captured).toEqual([ - { query: "topic", numResults: 25 }, - { query: "topic", numResults: 5 }, - ]); -}); - -test("throws on a non-ok HTTP response", async () => { - const fetchImpl = (async () => new Response("nope", { status: 401 })) as unknown as typeof fetch; - - await expect(searchWeb({ apiKey: "bad", fetchImpl }, { query: "topic" })).rejects.toThrow(/401/); -}); - -test("throws when the response body does not match the expected shape", async () => { - const fetchImpl = (async () => - new Response(JSON.stringify({ results: "not-an-array" }), { - status: 200, - })) as unknown as typeof fetch; - - await expect(searchWeb({ apiKey: "test-key", fetchImpl }, { query: "topic" })).rejects.toThrow( - /did not match the expected shape/, - ); -}); diff --git a/tools/web-search/src/client.ts b/tools/web-search/src/client.ts deleted file mode 100644 index 47e1c8112c..0000000000 --- a/tools/web-search/src/client.ts +++ /dev/null @@ -1,104 +0,0 @@ -// A minimal Exa search client: one call, one normalized shape. Exa is the -// backend the OG gtm-workbench's last30days-research workflow actually -// called for its web-search source (`packages/tools-exa`) — ported as the -// same real integration rather than a placeholder, so this package's -// honesty holds the moment a tenant supplies a real key. -import { type } from "arktype"; - -const ExaSearchResult = type({ - "title?": "string", - "url?": "string", - "publishedDate?": "string", - "author?": "string", -}); -export type ExaSearchResult = typeof ExaSearchResult.infer; - -const ExaSearchResponse = type({ results: ExaSearchResult.array() }); - -export interface WebSearchResult { - readonly url: string; - readonly title: string; - /** ISO 8601. Falls back to fetch time when Exa reports no publish date. */ - readonly publishedAt: string; - readonly source: "web"; - readonly author?: string; - /** Set when publishedAt is a fallback, not a real publish date. */ - readonly provenance?: "degraded"; -} - -export interface WebSearchClientConfig { - /** - * Absent when `fetchImpl` already authenticates its requests (a - * mediated credential's `fetch` injects its own `x-api-key` header per - * call — see `@intx/harness`'s `createHttpCredentialProvider`). - * Supplied directly for tests and any caller holding a raw key. - */ - readonly apiKey?: string; - /** Override for tests; defaults to the real Exa API host. */ - readonly baseUrl?: string; - readonly fetchImpl?: typeof fetch; -} - -const DEFAULT_BASE_URL = "https://api.exa.ai"; -const DEFAULT_NUM_RESULTS = 5; -const MAX_NUM_RESULTS = 25; - -function clampNumResults(requested: number | undefined): number { - if (requested === undefined || !Number.isInteger(requested) || requested <= 0) { - return DEFAULT_NUM_RESULTS; - } - return Math.min(requested, MAX_NUM_RESULTS); -} - -function normalizeResult(result: ExaSearchResult, retrievedAt: string): WebSearchResult | null { - const url = result.url ?? ""; - if (url.length === 0) return null; - const base = { - url, - title: result.title ?? "", - publishedAt: result.publishedDate ?? retrievedAt, - source: "web" as const, - }; - const withAuthor = result.author !== undefined ? { ...base, author: result.author } : base; - const item: WebSearchResult = - result.publishedDate === undefined - ? { ...withAuthor, provenance: "degraded" as const } - : withAuthor; - return item; -} - -/** - * Searches the web via Exa. Throws on any transport, HTTP, or shape - * failure — callers that need graceful degradation (e.g. this package's - * `web_search` tool) catch at their own boundary. - */ -export async function searchWeb( - config: WebSearchClientConfig, - params: { readonly query: string; readonly numResults?: number }, -): Promise<readonly WebSearchResult[]> { - const doFetch = config.fetchImpl ?? fetch; - const url = new URL("/search", config.baseUrl ?? DEFAULT_BASE_URL); - const baseHeaders = { "content-type": "application/json" }; - const headers = - config.apiKey !== undefined ? { ...baseHeaders, "x-api-key": config.apiKey } : baseHeaders; - const response = await doFetch(url, { - method: "POST", - headers, - body: JSON.stringify({ - query: params.query, - numResults: clampNumResults(params.numResults), - }), - }); - if (!response.ok) { - throw new Error(`Exa search request failed: ${response.status} ${response.statusText}`); - } - const body: unknown = await response.json(); - const parsed = ExaSearchResponse(body); - if (parsed instanceof type.errors) { - throw new Error(`Exa search response did not match the expected shape: ${parsed.summary}`); - } - const retrievedAt = new Date().toISOString(); - return parsed.results - .map((result) => normalizeResult(result, retrievedAt)) - .filter((item): item is WebSearchResult => item !== null); -} diff --git a/tools/web-search/src/index.ts b/tools/web-search/src/index.ts deleted file mode 100644 index 3dadd15758..0000000000 --- a/tools/web-search/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { searchWeb } from "./client"; -export type { WebSearchClientConfig, WebSearchResult } from "./client"; -export { WEB_SEARCH_TOOL, webSearchTools } from "./tool"; -export type { WebSearchEnv } from "./tool"; diff --git a/tools/web-search/src/manifest.test.ts b/tools/web-search/src/manifest.test.ts deleted file mode 100644 index 08f19fbb51..0000000000 --- a/tools/web-search/src/manifest.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -// Proves this package's own `package.json` satisfies the vendored -// `PackageJSON` manifest schema (`@intx/types/src/package-json.ts`) -// with an `interchange.credentials` declaration the deploy-time -// `ToolPackageManifest` harvests into `topLevel[].credentials` — the -// static half of the credential-binding seam adopts. Not a test -// of the vendored schema itself, only that this package's manifest is -// shaped the way that schema requires. -import { expect, test } from "bun:test"; -import { type } from "arktype"; -import { PackageJSON } from "@intx/types/package-json"; - -import packageJson from "../package.json"; - -test("package.json parses against the vendored PackageJSON manifest schema", () => { - const parsed = PackageJSON(packageJson); - expect(parsed instanceof type.errors).toBe(false); -}); - -test("declares exactly one credential handle: exa", () => { - const parsed = PackageJSON(packageJson); - if (parsed instanceof type.errors) { - throw new Error(parsed.summary); - } - expect(parsed.interchange?.credentials).toEqual([{ handle: "exa" }]); -}); diff --git a/tools/web-search/src/tool.test.ts b/tools/web-search/src/tool.test.ts deleted file mode 100644 index b06d2512ac..0000000000 --- a/tools/web-search/src/tool.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { expect, test } from "bun:test"; -import type { ToolCall } from "@intx/types/runtime"; -import type { CredentialCapability, MediatedCredential } from "@intx/types"; - -import { WEB_SEARCH_TOOL, webSearchTools } from "./tool"; -import type { WebSearchEnv } from "./tool"; - -const CALL: ToolCall = { - id: "call_1", - name: WEB_SEARCH_TOOL, - arguments: { query: "agent workflows" }, -}; - -/** - * A fake `credentials` capability mirroring the platform's own - * `createCredentialCapability`/`createHttpCredentialProvider` shape: a - * bound `secret` resolves to a mediated `fetch` that injects a header - * and delegates to `globalThis.fetch`; an unbound handle throws, - * matching the real gate's "no credential is bound to handle" failure. - */ -function fakeCredentials(secret: string | undefined): CredentialCapability { - return { - resolve(handle: string): Promise<MediatedCredential> { - if (secret === undefined) { - return Promise.reject(new Error(`no credential is bound to handle "${handle}"`)); - } - return Promise.resolve({ - kind: "http", - fetch: (input, init) => { - const headers = new Headers(init?.headers); - headers.set("x-api-key", secret); - return fetch(input as string | URL, { ...init, headers }); - }, - dispose: () => {}, - }); - }, - }; -} - -function fakeEnv(credentials: CredentialCapability | undefined): WebSearchEnv { - return { credentials } as unknown as WebSearchEnv; -} - -test("declares the web_search tool", () => { - const bundle = webSearchTools(fakeEnv(fakeCredentials("key"))); - expect(bundle.definitions.map((d) => d.name)).toEqual([WEB_SEARCH_TOOL]); -}); - -test("degrades to a non-throwing 'not connected' error when no credential is bound", async () => { - const bundle = webSearchTools(fakeEnv(fakeCredentials(undefined))); - const result = await bundle.run(CALL, new AbortController().signal); - expect(result.isError).toBe(true); - expect(result.content).toMatch(/not connected/i); -}); - -test("degrades the same way when the step carries no credentials capability at all", async () => { - const bundle = webSearchTools(fakeEnv(undefined)); - const result = await bundle.run(CALL, new AbortController().signal); - expect(result.isError).toBe(true); - expect(result.content).toMatch(/not connected/i); -}); - -test("rejects a missing query without calling the network", async () => { - const originalFetch = globalThis.fetch; - let called = false; - globalThis.fetch = (async () => { - called = true; - return new Response("{}", { status: 200 }); - }) as unknown as typeof fetch; - try { - const bundle = webSearchTools(fakeEnv(fakeCredentials("key"))); - const result = await bundle.run( - { id: "call_2", name: WEB_SEARCH_TOOL, arguments: {} }, - new AbortController().signal, - ); - expect(called).toBe(false); - expect(result.isError).toBe(true); - expect(result.content).toContain("query"); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("returns results as JSON content on a successful call", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = (async (_input: unknown, init?: RequestInit) => { - expect((init?.headers as Headers | undefined)?.get("x-api-key")).toBe("key"); - return new Response( - JSON.stringify({ - results: [ - { - title: "Agent workflows explained", - url: "https://example.test/a", - publishedDate: "2026-08-01T00:00:00.000Z", - }, - ], - }), - { status: 200 }, - ); - }) as unknown as typeof fetch; - try { - const bundle = webSearchTools(fakeEnv(fakeCredentials("key"))); - const result = await bundle.run(CALL, new AbortController().signal); - expect(result.isError).toBeUndefined(); - const parsed = JSON.parse(result.content as string) as { - results: unknown[]; - }; - expect(parsed.results).toHaveLength(1); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("degrades to an error result (never throws) when the underlying call fails", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = (async () => new Response("nope", { status: 500 })) as unknown as typeof fetch; - try { - const bundle = webSearchTools(fakeEnv(fakeCredentials("key"))); - const result = await bundle.run(CALL, new AbortController().signal); - expect(result.isError).toBe(true); - } finally { - globalThis.fetch = originalFetch; - } -}); diff --git a/tools/web-search/src/tool.ts b/tools/web-search/src/tool.ts deleted file mode 100644 index fc9837194a..0000000000 --- a/tools/web-search/src/tool.ts +++ /dev/null @@ -1,113 +0,0 @@ -// The `web_search` tool: an agent-facing wrapper around `./client.ts` that -// never throws. A missing credential or a failed call both come back as a -// completed `ToolResult` with `isError: true` — the calling agent's job -// (e.g. the last-30-days-research workflow) is to read that as "web search -// is not available right now" and say so honestly, never to have the run -// itself fail because one source is unreachable. Same contract as -// `@corbits/granola-tools`. -import { defineTool } from "@intx/agent"; -import type { BaseEnv } from "@intx/agent"; -import type { CredentialCapability } from "@intx/types"; -import type { ToolCall, ToolResult } from "@intx/types/runtime"; - -import { searchWeb } from "./client"; - -export const WEB_SEARCH_TOOL = "web_search"; - -/** The handle this package declares in `interchange.credentials`. */ -const EXA_CREDENTIAL_HANDLE = "exa"; - -/** Env this bundle needs beyond `BaseEnv`: the mediated-credential capability. */ -export interface WebSearchEnv extends BaseEnv { - readonly credentials?: CredentialCapability; -} - -function notConnectedResult(callId: string): ToolResult { - return { - callId, - content: "Web search is not connected for this user.", - isError: true, - }; -} - -/** - * Resolve this bundle's mediated Exa credential, or `null` when it is - * not connected -- an absent `env.credentials`, an unbound handle, or a - * denied grant all collapse to the same "not connected" signal, never a - * thrown error out of the tool. - */ -async function resolveWebSearchCredential( - env: WebSearchEnv, -): Promise<{ fetchImpl: typeof fetch } | null> { - if (env.credentials === undefined) return null; - try { - const mediated = await env.credentials.resolve(EXA_CREDENTIAL_HANDLE); - return { fetchImpl: mediated.fetch as unknown as typeof fetch }; - } catch { - return null; - } -} - -async function runWebSearch(env: WebSearchEnv, call: ToolCall): Promise<ToolResult> { - const credential = await resolveWebSearchCredential(env); - if (credential === null) { - return notConnectedResult(call.id); - } - const query = call.arguments["query"]; - if (typeof query !== "string" || query.trim().length === 0) { - return { - callId: call.id, - content: `${WEB_SEARCH_TOOL} requires a non-empty query argument`, - isError: true, - }; - } - const numResults = call.arguments["numResults"]; - try { - const params = typeof numResults === "number" ? { query, numResults } : { query }; - const results = await searchWeb({ fetchImpl: credential.fetchImpl }, params); - return { callId: call.id, content: JSON.stringify({ results }) }; - } catch (err) { - return { - callId: call.id, - content: err instanceof Error ? err.message : String(err), - isError: true, - }; - } -} - -/** - * The `@corbits/web-search-tools` bundle factory: one tool, one mediated - * credential (handle "exa"). Pin this package on any agent that needs - * current web results for a topic — backed by Exa, the same provider - * the OG gtm-workbench's last30days-research workflow used for this - * source. - */ -export const webSearchTools = defineTool<WebSearchEnv>({ - id: "@corbits/web-search-tools/web-search", - requires: ["credentials"], - definitions: [{ name: WEB_SEARCH_TOOL }], - factory: (env) => ({ - definitions: [ - { - name: WEB_SEARCH_TOOL, - description: - "Searches the web for current information on a topic. " + - 'Returns an error result naming "not connected" when no ' + - "web-search credential is configured — never fabricate " + - "results when this happens.", - inputSchema: { - type: "object", - properties: { - query: { type: "string", description: "The search query." }, - numResults: { - type: "number", - description: "Maximum results to return (1-25, default 5).", - }, - }, - required: ["query"], - }, - }, - ], - run: (call, _signal) => runWebSearch(env, call), - }), -}); diff --git a/tools/web-search/tsconfig.json b/tools/web-search/tsconfig.json deleted file mode 100644 index e125e90614..0000000000 --- a/tools/web-search/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "./tsconfig.src.json", - "compilerOptions": { - "composite": false, - "noEmit": true, - "disableSourceOfProjectReferenceRedirect": true, - "declaration": false, - "declarationMap": false, - "emitDeclarationOnly": false, - "tsBuildInfoFile": "tsconfig.tsbuildinfo", - "rootDir": "../.." - }, - "include": ["src"], - "exclude": [] -} diff --git a/tools/web-search/tsconfig.src.json b/tools/web-search/tsconfig.src.json deleted file mode 100644 index fc8724305f..0000000000 --- a/tools/web-search/tsconfig.src.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "include": ["src", "package.json", "src/**/*.json"], - "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"], - "compilerOptions": { - "types": ["bun"], - "composite": true, - "emitDeclarationOnly": true, - "outDir": "dist", - "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" - } -} diff --git a/tsconfig.build.json b/tsconfig.build.json index 794d27f334..d5069227ba 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -16,9 +16,6 @@ { "path": "./packages/artifact-ui/tsconfig.src.json" }, - { - "path": "./packages/code-review/tsconfig.src.json" - }, { "path": "./packages/collections/tsconfig.src.json" }, @@ -52,27 +49,12 @@ { "path": "./tools/catalog/tsconfig.src.json" }, - { - "path": "./tools/github/tsconfig.src.json" - }, - { - "path": "./tools/granola/tsconfig.src.json" - }, { "path": "./tools/interaction/tsconfig.src.json" }, - { - "path": "./tools/linear/tsconfig.src.json" - }, - { - "path": "./tools/reddit/tsconfig.src.json" - }, { "path": "./tools/skills/tsconfig.src.json" }, - { - "path": "./tools/web-search/tsconfig.src.json" - }, { "path": "./tools/workflow-authoring/tsconfig.src.json" },