diff --git a/core/agency.test.ts b/core/agency.test.ts index f96b619..23eda98 100644 --- a/core/agency.test.ts +++ b/core/agency.test.ts @@ -1,11 +1,15 @@ import { describe, expect, it } from "vitest"; import { + calendarAsk, + calendarEventsLine, + calendarEventsQuery, githubAccountLine, githubAsk, gmailSearchQuery, mailAsk, mailSnippetsLine, parseMailDraft, + presentCalendarEvents, presentGithubAccount, parseExtractedWorklogFacts, parseSuggestedReply, @@ -214,6 +218,22 @@ describe("Agency.run", () => { expect(mailAsk("can tell me what me latest email said")).toBe("search"); expect(githubAsk("What should I capture next?")).toBe(false); expect(githubAsk("what repos do I have on GitHub")).toBe(true); + expect(calendarAsk("What should I capture next?")).toBe(false); + expect(calendarAsk("what interviews are on my calendar")).toBe(true); + const query = calendarEventsQuery(new Date("2026-10-01T12:00:00.000Z")); + expect(query).toContain("calendar/v3/calendars/primary/events?"); + expect(query).toContain("singleEvents=true"); + expect(query).toContain("timeMin=2026-10-01T12%3A00%3A00.000Z"); + expect( + calendarEventsLine( + presentCalendarEvents({ + items: [ + { status: "cancelled", summary: "Gone", start: { dateTime: "2026-10-02T13:00:00.000Z" } }, + { summary: "Screen", start: { dateTime: "2026-10-02T17:00:00.000Z" } }, + ], + }), + ), + ).toBe("Calendar, upcoming:\n- 2026-10-02T17:00:00.000Z — Screen"); expect( githubAccountLine( presentGithubAccount( diff --git a/core/agency.ts b/core/agency.ts index bd881b9..b7137da 100644 --- a/core/agency.ts +++ b/core/agency.ts @@ -98,7 +98,7 @@ export function parseExtractedWorklogFacts( } export const COMMAND_SYSTEM_PROMPT = - "You are Proforna. Continue this conversation using only the vault gist and the messages. If a Mail section is present, use only that mail. If a GitHub section is present, use only that GitHub account. If a Connected accounts section is present, those are the accounts the occupant turned on. If an account section includes a tool result, use only that result. Do not claim you changed Career Memory, a repo, an issue, a pull request, or an outside account. Do not send messages. Do not invent facts."; + "You are Proforna. Continue this conversation using only the vault gist and the messages. If a Mail section is present, use only that mail. If a GitHub section is present, use only that GitHub account. If a Calendar section is present, use only that calendar. If a Connected accounts section is present, those are the accounts the occupant turned on. If an account section includes a tool result, use only that result. Do not claim you changed Career Memory, a repo, an issue, a pull request, a calendar event, or an outside account. Do not send messages. Do not invent facts."; export const MAIL_DRAFT_SYSTEM_PROMPT = "You are Proforna. Draft one email the occupant asked for. Use only the vault gist and the command. Do not invent facts. Do not claim you sent it. Return ONLY JSON: {\"to\":\"string\",\"subject\":\"string\",\"body\":\"string\"}."; @@ -202,6 +202,55 @@ export function githubUnreadLine(): string { return "GitHub: GitHub could not be read."; } +const CALENDAR_WORD = /\b(?:calendar|schedule|schedules|appointment|appointments|interview|interviews)\b/i; + +export function calendarAsk(prompt: string): boolean { + return CALENDAR_WORD.test(prompt.trim()); +} + +export function calendarEventsQuery(now: Date): string { + const params = new URLSearchParams({ + maxResults: "8", + singleEvents: "true", + orderBy: "startTime", + timeMin: now.toISOString(), + }); + return `calendar/v3/calendars/primary/events?${params}`; +} + +export function presentCalendarEvents(payload: unknown): Array<{ when: string; title: string }> { + const root = payload && typeof payload === "object" ? (payload as { items?: unknown }) : null; + const items = Array.isArray(root?.items) ? root.items : []; + return items + .flatMap((item) => { + if (!item || typeof item !== "object") return []; + const row = item as { + status?: unknown; + summary?: unknown; + start?: { dateTime?: unknown; date?: unknown }; + }; + if (row.status === "cancelled") return []; + const when = text(row.start?.dateTime) || text(row.start?.date); + const title = text(row.summary).slice(0, 120); + if (!when || !title) return []; + return [{ when, title }]; + }) + .slice(0, 8); +} + +export function calendarEventsLine(events: ReadonlyArray<{ when: string; title: string }>): string { + if (events.length === 0) return "Calendar: no upcoming events."; + return `Calendar, upcoming:\n${events.map((event) => `- ${event.when} — ${event.title}`).join("\n")}`; +} + +export function calendarUnavailableLine(): string { + return "Calendar: Google Calendar is not available."; +} + +export function calendarUnreadLine(): string { + return "Calendar: Google Calendar could not be read."; +} + function text(value: unknown): string { return typeof value === "string" ? value.trim() : ""; } diff --git a/core/connection-access.ts b/core/connection-access.ts index 2633242..04546d0 100644 --- a/core/connection-access.ts +++ b/core/connection-access.ts @@ -10,7 +10,7 @@ export type ConnectionTool = { required: string[]; }; -const DEDICATED = new Set(["gmail", "github"]); +const DEDICATED = new Set(["gmail", "github", "google-calendar"]); const STOP = new Set([ "search", diff --git a/core/index.ts b/core/index.ts index d7ff23b..37b1c3f 100644 --- a/core/index.ts +++ b/core/index.ts @@ -35,10 +35,16 @@ export { INSPECT_SYSTEM_PROMPT, MAIL_DRAFT_SYSTEM_PROMPT, SUGGEST_REPLY_SYSTEM_PROMPT, + calendarAsk, + calendarEventsLine, + calendarEventsQuery, + calendarUnavailableLine, + calendarUnreadLine, githubAccountLine, githubAsk, githubUnavailableLine, githubUnreadLine, + presentCalendarEvents, gmailSearchQuery, mailAsk, mailSnippetsLine, diff --git a/core/nango.test.ts b/core/nango.test.ts index f8aaf4b..53cf935 100644 --- a/core/nango.test.ts +++ b/core/nango.test.ts @@ -27,6 +27,17 @@ describe("sign-in helper", () => { tags: { end_user_id: "local" }, allowed_integrations: ["github"], }); + expect(connectSessionBody("local", "google-calendar")).toEqual({ + tags: { end_user_id: "local" }, + allowed_integrations: ["google-calendar"], + integrations_config_defaults: { + "google-calendar": { + authorization_params: { + scope: "https://www.googleapis.com/auth/calendar.readonly", + }, + }, + }, + }); expect(connectSessionBody("local", "google-mail")).toEqual({ tags: { end_user_id: "local" }, allowed_integrations: ["google-mail"], diff --git a/core/nango.ts b/core/nango.ts index 72d78ad..b6e3cf5 100644 --- a/core/nango.ts +++ b/core/nango.ts @@ -10,6 +10,7 @@ const PROVIDER_ALIASES: Record = { const SIGN_IN_SCOPES: Record = { "google-mail": "https://www.googleapis.com/auth/gmail.readonly https://www.googleapis.com/auth/gmail.compose", + "google-calendar": "https://www.googleapis.com/auth/calendar.readonly", }; export function matchNangoIntegration( diff --git a/core/plugins.test.ts b/core/plugins.test.ts index 992dff9..281abb2 100644 --- a/core/plugins.test.ts +++ b/core/plugins.test.ts @@ -7,6 +7,7 @@ import { prepareIntegrationValues, presentIntegrationCatalog, readThirdPartyPlugin, + signInOffered, thirdPartyBlock, type ThirdPartyPlugin, } from "./plugins"; @@ -45,6 +46,8 @@ describe("third-party plugins", () => { const byName = new Map(catalog.map((plugin) => [plugin.name, plugin])); expect(thirdPartyBlock(byName.get("gmail")!)).toBe("preview"); expect(thirdPartyBlock(byName.get("google-calendar")!)).toBe("preview"); + expect(signInOffered(byName.get("google-calendar")!)).toBe(true); + expect(signInOffered(byName.get("gmail")!)).toBe(false); expect(thirdPartyBlock(byName.get("google-drive")!)).toBe("foreign-host"); expect(thirdPartyBlock(byName.get("outlook")!)).toBe("foreign-host"); expect(thirdPartyBlock(byName.get("ashby")!)).toBe("outside-career"); diff --git a/core/plugins.ts b/core/plugins.ts index 7690109..96b213b 100644 --- a/core/plugins.ts +++ b/core/plugins.ts @@ -142,9 +142,16 @@ export function defaultThirdPartyPlugins( .sort((a, b) => a.name.localeCompare(b.name)); } -function integrationAvailable(plugin: ThirdPartyPlugin): boolean { +/** Preview hosts this app reads through sign-in instead of the plugin URL. */ +const PROXY_SIGN_IN = new Set(["google-calendar"]); + +export function signInOffered(plugin: ThirdPartyPlugin): boolean { const block = thirdPartyBlock(plugin); - return block === null || block === "outside-career"; + return block === null || block === "outside-career" || (block === "preview" && PROXY_SIGN_IN.has(plugin.name)); +} + +function integrationAvailable(plugin: ThirdPartyPlugin): boolean { + return signInOffered(plugin); } function integrationRank(plugin: IntegrationListing): number { diff --git a/docs/adr/0005-turned-on-integrations-are-read-through-their-own-tools.md b/docs/adr/0005-turned-on-integrations-are-read-through-their-own-tools.md new file mode 100644 index 0000000..5eb0720 --- /dev/null +++ b/docs/adr/0005-turned-on-integrations-are-read-through-their-own-tools.md @@ -0,0 +1,44 @@ +# 0005 — A turned-on integration is read through its own tools + +- **Status:** Accepted +- **Date:** 2026-09-30 +- **Deciders:** Sory Kaba +- **Tags:** product, agency, integrations + +## Context + +A command Agent Run could read mail and GitHub only because each had its own +reader. Any other integration the occupant turned on stayed unused until +someone wrote another reader. + +Calling every integration on every question would fetch data the question did +not ask for. Handing the model the credential would let it act outside the +run. Writing through those tools would change an outside service without an +Approval. + +The occupant-facing list was also offering integrations that have no sign-in +configured yet. Their plugin files stay in the repo so that work can be +finished later. An integration here is a named outside service in Settings. +It is not a Job Source. + +## Decision + +- Settings and onboarding list only integrations whose sign-in is already + configured. The plugin files stay. +- A home command lists the integrations that are turned on. +- When the prompt names one, Proforna calls that integration's own read tool. + It picks a read that needs no arguments. It does not call a tool that + creates, updates, deletes, or sends. +- The credential stays on the server. The model sees the result, not the + token, and must not claim it changed the integration. +- Mail and GitHub keep the readers they already have. The general path skips + them so the same question is not fetched twice. +- An integration that is off, or that the prompt does not name, is not called. + +## Consequences + +- A newly configured integration does not need its own command reader once + the occupant turns it on. +- A read that needs arguments is named and not called. +- Changing an outside integration still needs an Approval. This path does not + send mail or change a repo. diff --git a/docs/adr/README.md b/docs/adr/README.md index e8e2fa2..468e479 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -6,3 +6,4 @@ Decisions that bind the rebuild. Start with - [0002 — Local career vault with a projection-only publishing relay](0002-local-vault-and-publishing-relay.md) - [0003 — Opportunities finds and pursues on the career map](0003-opportunities-find-and-pursue.md) - [0004 — Onboarding verifies the extract before it is kept](0004-onboarding-verifies-the-extract.md) +- [0005 — A turned-on integration is read through its own tools](0005-turned-on-integrations-are-read-through-their-own-tools.md) diff --git a/plugins/third_party/google-calendar/.cursor-plugin/plugin.json b/plugins/third_party/google-calendar/.cursor-plugin/plugin.json index f814a1b..2ec3667 100644 --- a/plugins/third_party/google-calendar/.cursor-plugin/plugin.json +++ b/plugins/third_party/google-calendar/.cursor-plugin/plugin.json @@ -2,6 +2,6 @@ "name": "google-calendar", "displayName": "Google Calendar", "version": "1.0.0", - "description": "Search events and schedule meetings.", + "description": "Read upcoming events. Proforna does not create or move them.", "mcpServers": "./mcp.json" } diff --git a/server/agency.test.ts b/server/agency.test.ts index e412add..c0e9317 100644 --- a/server/agency.test.ts +++ b/server/agency.test.ts @@ -389,6 +389,64 @@ describe("Agency HTTP seam", () => { } }); + it("answers a calendar question from upcoming events and does not read them when the calendar is off", async () => { + process.env.NANGO_SECRET_KEY = "test-secret-key"; + process.env.NANGO_HOST = "http://127.0.0.1:3003"; + const fetchImpl = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/proxy/calendar/v3/calendars/primary/events")) { + return Response.json({ + items: [{ summary: "Screen", start: { dateTime: "2026-10-02T17:00:00.000Z" } }], + }); + } + return new Response("no", { status: 404 }); + }); + vi.stubGlobal("fetch", fetchImpl); + const complete = vi.fn(async (input: { messages: Array<{ content: string }> }) => { + const content = input.messages[1]?.content ?? ""; + if (content.includes("Command: what interviews are on my calendar") && content.includes("Screen")) { + expect(content).not.toContain("test-secret-key"); + return { text: "You have a screen on October 2.", model: "local-test" }; + } + return { text: "Capture a measured outcome.", model: "local-test" }; + }); + const db = openDatabase(":memory:"); + const app = createApp(db, { complete }); + try { + await saveLocalModel(app); + saveCalendar(db); + const quiet = await command(app, "What should I capture next?"); + expect(quiet.status).toBe(201); + expect(fetchImpl).not.toHaveBeenCalled(); + const ran = await command(app, "what interviews are on my calendar"); + expect(ran.status).toBe(201); + expect(await ran.json()).toMatchObject({ + run: { answer: "You have a screen on October 2.", changeSet: null }, + }); + expect(fetchImpl.mock.calls.some((call) => String(call[0]).includes("calendarmcp.googleapis.com"))).toBe( + false, + ); + + db.prepare("UPDATE integration_accounts SET enabled = 0 WHERE name = 'google-calendar'").run(); + fetchImpl.mockClear(); + const off = await command(app, "what interviews are on my calendar"); + expect(off.status).toBe(201); + expect(fetchImpl).not.toHaveBeenCalled(); + expect(complete).toHaveBeenLastCalledWith( + expect.objectContaining({ + messages: expect.arrayContaining([ + expect.objectContaining({ content: expect.stringContaining("Google Calendar is not available.") }), + ]), + }), + ); + } finally { + delete process.env.NANGO_SECRET_KEY; + delete process.env.NANGO_HOST; + vi.unstubAllGlobals(); + db.close(); + } + }); + it("reads a connected account from its own tools without giving the model the token", async () => { process.env.NANGO_SECRET_KEY = "test-secret-key"; process.env.NANGO_HOST = "http://127.0.0.1:3003"; @@ -567,6 +625,15 @@ function saveZoom(db: DatabaseSync) { ).run(occupant.id, "2026-09-30T00:00:00.000Z"); } +function saveCalendar(db: DatabaseSync) { + const occupant = db.prepare("SELECT id FROM occupants").get() as { id: string }; + db.prepare( + `INSERT INTO integration_accounts + (occupant_id, name, secrets_json, updated_at, enabled, nango_connection_id, nango_provider_key) + VALUES (?, 'google-calendar', '{}', ?, 1, 'conn-calendar', 'google-calendar')`, + ).run(occupant.id, "2026-09-30T00:00:00.000Z"); +} + function saveGithub(db: DatabaseSync) { const occupant = db.prepare("SELECT id FROM occupants").get() as { id: string }; db.prepare( diff --git a/server/agency.ts b/server/agency.ts index 020e8cc..bbbc333 100644 --- a/server/agency.ts +++ b/server/agency.ts @@ -6,10 +6,16 @@ import { INSPECT_SYSTEM_PROMPT, MAIL_DRAFT_SYSTEM_PROMPT, SUGGEST_REPLY_SYSTEM_PROMPT, + calendarAsk, + calendarEventsLine, + calendarEventsQuery, + calendarUnavailableLine, + calendarUnreadLine, githubAccountLine, githubAsk, githubUnavailableLine, githubUnreadLine, + presentCalendarEvents, gmailSearchQuery, mailAsk, presentGithubAccount, @@ -99,6 +105,7 @@ export async function runAgency( const history = parseCommandHistory(input.history); const mail = await commandMail(db, occupantId, planned.value, occupantPrompt); const github = await commandGithub(db, occupantId, planned.value, occupantPrompt); + const calendar = await commandCalendar(db, occupantId, planned.value, occupantPrompt); const connections = await commandConnections(db, occupantId, planned.value, occupantPrompt); const context = loadRunContext( db, @@ -108,6 +115,7 @@ export async function runAgency( history, mail, github, + calendar, connections, ); if (!context.ok) throw new AgencyStoreError(context.error); @@ -343,6 +351,44 @@ function readEnabledGithubLink( return readIntegrationNangoLink(db, occupantId, "github"); } +async function commandCalendar( + db: DatabaseSync, + occupantId: string, + run: AgentRun, + prompt: string, +): Promise { + if (run.purpose !== "command" || run.scope.type !== "home") return ""; + if (!calendarAsk(prompt)) return ""; + const link = readEnabledCalendarLink(db, occupantId); + const runtime = nangoRuntime(); + if (!link || !runtime) return calendarUnavailableLine(); + try { + const response = await nangoProxy(runtime, { + method: "GET", + path: calendarEventsQuery(new Date()), + providerConfigKey: link.providerKey, + connectionId: link.connectionId, + }); + if (!response.ok) return calendarUnreadLine(); + return calendarEventsLine(presentCalendarEvents(await response.json())); + } catch { + return calendarUnreadLine(); + } +} + +function readEnabledCalendarLink( + db: DatabaseSync, + occupantId: string, +): { connectionId: string; providerKey: string } | null { + const row = db + .prepare( + `SELECT enabled FROM integration_accounts WHERE occupant_id = ? AND name = 'google-calendar'`, + ) + .get(occupantId) as { enabled: number } | undefined; + if (!row || row.enabled !== 1) return null; + return readIntegrationNangoLink(db, occupantId, "google-calendar"); +} + async function commandConnections( db: DatabaseSync, occupantId: string, @@ -462,6 +508,7 @@ function loadRunContext( history: Array<{ speaker: CommandSpeaker; body: string }>, mail: CommandMail, github: string, + calendar: string, connections: string, ): | { ok: true; prompt: string; userContent: string; jsonObject: boolean } @@ -477,7 +524,7 @@ function loadRunContext( occupantId, occupantPrompt, history, - [mail.section, github, connections].filter(Boolean).join("\n"), + [mail.section, github, calendar, connections].filter(Boolean).join("\n"), ), }; } diff --git a/server/app.ts b/server/app.ts index 305175a..f5197a6 100644 --- a/server/app.ts +++ b/server/app.ts @@ -134,7 +134,7 @@ import { setIntegrationEnabled, } from "./integration-catalog"; import { matchNangoIntegration } from "../core/nango"; -import { thirdPartyBlock, type ThirdPartyPlugin } from "../core/plugins"; +import { signInOffered, type ThirdPartyPlugin } from "../core/plugins"; import { createNangoConnectLink, deleteNangoConnection, @@ -2157,8 +2157,7 @@ function signInFailure(error: unknown): { error: string; status: 400 | 502 } | n } function offeredPlugin(plugin: ThirdPartyPlugin): boolean { - const block = thirdPartyBlock(plugin); - return block === null || block === "outside-career"; + return signInOffered(plugin); } async function forgetNangoLink( diff --git a/server/integration-catalog.ts b/server/integration-catalog.ts index d1b5104..77516c1 100644 --- a/server/integration-catalog.ts +++ b/server/integration-catalog.ts @@ -6,7 +6,7 @@ import { prepareIntegrationValues, presentIntegrationCatalog, readThirdPartyPlugin, - thirdPartyBlock, + signInOffered, type IntegrationField, type ThirdPartyPlugin, } from "../core/plugins"; @@ -141,10 +141,7 @@ export function saveNangoIntegration( ): void { const plugin = plugins.find((item) => item.name === name); if (!plugin) throw new IntegrationCatalogError("integration-unknown"); - const block = thirdPartyBlock(plugin); - if (block !== null && block !== "outside-career") { - throw new IntegrationCatalogError("integration-unavailable"); - } + if (!signInOffered(plugin)) throw new IntegrationCatalogError("integration-unavailable"); const sealed = sealSecret("{}", vaultKey(true)); db.prepare( `INSERT INTO integration_accounts