Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions core/agency.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { describe, expect, it } from "vitest";
import {
calendarAsk,
calendarEventsLine,
calendarEventsQuery,
githubAccountLine,
githubAsk,
gmailSearchQuery,
mailAsk,
mailSnippetsLine,
parseMailDraft,
presentCalendarEvents,
presentGithubAccount,
parseExtractedWorklogFacts,
parseSuggestedReply,
Expand Down Expand Up @@ -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(
Expand Down
51 changes: 50 additions & 1 deletion core/agency.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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\"}.";
Expand Down Expand Up @@ -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() : "";
}
Expand Down
2 changes: 1 addition & 1 deletion core/connection-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 11 additions & 0 deletions core/nango.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
1 change: 1 addition & 0 deletions core/nango.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const PROVIDER_ALIASES: Record<string, readonly string[]> = {
const SIGN_IN_SCOPES: Record<string, string> = {
"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(
Expand Down
3 changes: 3 additions & 0 deletions core/plugins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
prepareIntegrationValues,
presentIntegrationCatalog,
readThirdPartyPlugin,
signInOffered,
thirdPartyBlock,
type ThirdPartyPlugin,
} from "./plugins";
Expand Down Expand Up @@ -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");
Expand Down
11 changes: 9 additions & 2 deletions core/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
67 changes: 67 additions & 0 deletions server/agency.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading