granola-toolkit 0.46.2 → 0.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli.js +1292 -761
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { Input, ProcessTerminal, TUI, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
3
3
  import { createHash, randomUUID } from "node:crypto";
4
- import { appendFile, mkdir, readFile, rm, stat, unlink, writeFile } from "node:fs/promises";
4
+ import { appendFile, mkdir, mkdtemp, readFile, rm, stat, unlink, writeFile } from "node:fs/promises";
5
5
  import { dirname, join, resolve } from "node:path";
6
6
  import { existsSync } from "node:fs";
7
- import { homedir, platform } from "node:os";
7
+ import { homedir, platform, tmpdir } from "node:os";
8
8
  import { NodeHtmlMarkdown } from "node-html-markdown";
9
9
  import { execFile, spawn } from "node:child_process";
10
10
  import { promisify } from "node:util";
@@ -118,7 +118,7 @@ function mergeHeaders(...values) {
118
118
  }
119
119
  return headers;
120
120
  }
121
- async function responseError(response) {
121
+ async function responseError$1(response) {
122
122
  let message = `${response.status} ${response.statusText}`.trim();
123
123
  try {
124
124
  const payload = await response.json();
@@ -159,14 +159,14 @@ var GranolaServerClient = class GranolaServerClient {
159
159
  ...options.password?.trim() ? { "x-granola-password": options.password.trim() } : {},
160
160
  accept: "application/json"
161
161
  }) });
162
- if (!infoResponse.ok) throw await responseError(infoResponse);
162
+ if (!infoResponse.ok) throw await responseError$1(infoResponse);
163
163
  const info = await infoResponse.json();
164
164
  if (info.protocolVersion !== 2) throw new Error(`unsupported Granola transport protocol: expected 2, got ${info.protocolVersion}`);
165
165
  const response = await fetchImpl(new URL(granolaTransportPaths.state, url), { headers: mergeHeaders({
166
166
  ...options.password?.trim() ? { "x-granola-password": options.password.trim() } : {},
167
167
  accept: "application/json"
168
168
  }) });
169
- if (!response.ok) throw await responseError(response);
169
+ if (!response.ok) throw await responseError$1(response);
170
170
  const client = new GranolaServerClient(info, url, await response.json(), options);
171
171
  client.startEvents();
172
172
  return client;
@@ -294,7 +294,7 @@ var GranolaServerClient = class GranolaServerClient {
294
294
  accept: "application/json"
295
295
  }, init.headers)
296
296
  });
297
- if (!response.ok) throw await responseError(response);
297
+ if (!response.ok) throw await responseError$1(response);
298
298
  return response;
299
299
  }
300
300
  async requestJson(path, init = {}) {
@@ -449,7 +449,7 @@ function asRecord(value) {
449
449
  function stringValue(value) {
450
450
  return typeof value === "string" ? value : "";
451
451
  }
452
- function stringArray$1(value) {
452
+ function stringArray$2(value) {
453
453
  if (!Array.isArray(value)) return [];
454
454
  return value.filter((item) => typeof item === "string");
455
455
  }
@@ -2604,127 +2604,6 @@ const attachCommand = {
2604
2604
  }
2605
2605
  };
2606
2606
  //#endregion
2607
- //#region src/automation-actions.ts
2608
- function cloneAction$1(action) {
2609
- switch (action.kind) {
2610
- case "ask-user": return { ...action };
2611
- case "command": return {
2612
- ...action,
2613
- args: action.args ? [...action.args] : void 0,
2614
- env: action.env ? { ...action.env } : void 0
2615
- };
2616
- case "export-notes":
2617
- case "export-transcript": return { ...action };
2618
- }
2619
- }
2620
- function automationActionName(action) {
2621
- return action.name || action.id;
2622
- }
2623
- function buildAutomationActionRunId(match, actionId) {
2624
- return `${match.id}:${actionId}`;
2625
- }
2626
- function enabledAutomationActions(rule) {
2627
- return (rule.actions ?? []).filter((action) => action.enabled !== false).map((action) => cloneAction$1(action));
2628
- }
2629
- function baseRun(match, rule, action, startedAt) {
2630
- return {
2631
- actionId: action.id,
2632
- actionKind: action.kind,
2633
- actionName: automationActionName(action),
2634
- eventId: match.eventId,
2635
- eventKind: match.eventKind,
2636
- folders: match.folders.map((folder) => ({ ...folder })),
2637
- id: buildAutomationActionRunId(match, action.id),
2638
- matchedAt: match.matchedAt,
2639
- meetingId: match.meetingId,
2640
- ruleId: rule.id,
2641
- ruleName: rule.name,
2642
- startedAt,
2643
- status: "completed",
2644
- tags: [...match.tags],
2645
- title: match.title,
2646
- transcriptLoaded: match.transcriptLoaded
2647
- };
2648
- }
2649
- function completedRun(run, finishedAt, patch = {}) {
2650
- return {
2651
- ...run,
2652
- ...patch,
2653
- finishedAt,
2654
- status: "completed"
2655
- };
2656
- }
2657
- function failedRun(run, finishedAt, error) {
2658
- return {
2659
- ...run,
2660
- error: error instanceof Error ? error.message : String(error),
2661
- finishedAt,
2662
- status: "failed"
2663
- };
2664
- }
2665
- function skippedRun(run, finishedAt, reason) {
2666
- return {
2667
- ...run,
2668
- finishedAt,
2669
- result: reason,
2670
- status: "skipped"
2671
- };
2672
- }
2673
- async function executeAutomationAction(match, rule, action, handlers) {
2674
- const run = baseRun(match, rule, action, handlers.nowIso());
2675
- switch (action.kind) {
2676
- case "ask-user": return {
2677
- ...run,
2678
- meta: action.details ? { details: action.details } : void 0,
2679
- prompt: action.prompt,
2680
- result: "Pending user decision",
2681
- status: "pending"
2682
- };
2683
- case "command": try {
2684
- const result = await handlers.runCommand(match, rule, action);
2685
- return completedRun(run, handlers.nowIso(), {
2686
- meta: {
2687
- command: result.command,
2688
- cwd: result.cwd
2689
- },
2690
- result: result.output
2691
- });
2692
- } catch (error) {
2693
- return failedRun(run, handlers.nowIso(), error);
2694
- }
2695
- case "export-notes": try {
2696
- const result = await handlers.exportNotes(match, action);
2697
- if (!result) return skippedRun(run, handlers.nowIso(), "Meeting notes were unavailable for export");
2698
- return completedRun(run, handlers.nowIso(), {
2699
- meta: {
2700
- format: result.format,
2701
- outputDir: result.outputDir,
2702
- scope: result.scope,
2703
- written: result.written
2704
- },
2705
- result: `Exported notes to ${result.outputDir}`
2706
- });
2707
- } catch (error) {
2708
- return failedRun(run, handlers.nowIso(), error);
2709
- }
2710
- case "export-transcript": try {
2711
- const result = await handlers.exportTranscripts(match, action);
2712
- if (!result) return skippedRun(run, handlers.nowIso(), "Transcript data was unavailable for export");
2713
- return completedRun(run, handlers.nowIso(), {
2714
- meta: {
2715
- format: result.format,
2716
- outputDir: result.outputDir,
2717
- scope: result.scope,
2718
- written: result.written
2719
- },
2720
- result: `Exported transcript to ${result.outputDir}`
2721
- });
2722
- } catch (error) {
2723
- return failedRun(run, handlers.nowIso(), error);
2724
- }
2725
- }
2726
- }
2727
- //#endregion
2728
2607
  //#region src/persistence/layout.ts
2729
2608
  function defaultGranolaToolkitDataDirectory(targetPlatform = platform(), homeDirectory = homedir()) {
2730
2609
  return targetPlatform === "darwin" ? join(homeDirectory, "Library", "Application Support", "granola-toolkit") : join(homeDirectory, ".config", "granola-toolkit");
@@ -2733,6 +2612,7 @@ function defaultGranolaToolkitPersistenceLayout(options = {}) {
2733
2612
  const targetPlatform = options.platform ?? platform();
2734
2613
  const dataDirectory = defaultGranolaToolkitDataDirectory(targetPlatform, options.homeDirectory ?? homedir());
2735
2614
  return {
2615
+ agentHarnessesFile: join(dataDirectory, "agent-harnesses.json"),
2736
2616
  automationMatchesFile: join(dataDirectory, "automation-matches.jsonl"),
2737
2617
  automationRulesFile: join(dataDirectory, "automation-rules.json"),
2738
2618
  automationRunsFile: join(dataDirectory, "automation-runs.jsonl"),
@@ -2748,110 +2628,757 @@ function defaultGranolaToolkitPersistenceLayout(options = {}) {
2748
2628
  };
2749
2629
  }
2750
2630
  //#endregion
2751
- //#region src/automation-matches.ts
2752
- function cloneMatch(match) {
2631
+ //#region src/agent-harnesses.ts
2632
+ function stringArray$1(value) {
2633
+ if (!Array.isArray(value)) return;
2634
+ const values = value.filter((item) => typeof item === "string" && item.trim().length > 0).map((item) => item.trim());
2635
+ return values.length > 0 ? [...new Set(values)] : void 0;
2636
+ }
2637
+ function parseMatch(value) {
2638
+ if (!value || typeof value !== "object" || Array.isArray(value)) return;
2639
+ const record = value;
2753
2640
  return {
2754
- ...match,
2755
- folders: match.folders.map((folder) => ({ ...folder })),
2756
- tags: [...match.tags]
2641
+ calendarEventIds: stringArray$1(record.calendarEventIds),
2642
+ eventKinds: stringArray$1(record.eventKinds),
2643
+ folderIds: stringArray$1(record.folderIds),
2644
+ folderNames: stringArray$1(record.folderNames),
2645
+ meetingIds: stringArray$1(record.meetingIds),
2646
+ recurringEventIds: stringArray$1(record.recurringEventIds),
2647
+ tags: stringArray$1(record.tags),
2648
+ titleIncludes: stringArray$1(record.titleIncludes),
2649
+ titleMatches: typeof record.titleMatches === "string" && record.titleMatches.trim() ? record.titleMatches.trim() : void 0,
2650
+ transcriptLoaded: typeof record.transcriptLoaded === "boolean" ? record.transcriptLoaded : void 0
2757
2651
  };
2758
2652
  }
2759
- var FileAutomationMatchStore = class {
2760
- constructor(filePath = defaultAutomationMatchesFilePath()) {
2761
- this.filePath = filePath;
2653
+ function parseHarness(value) {
2654
+ if (!value || typeof value !== "object" || Array.isArray(value)) return;
2655
+ const record = value;
2656
+ const id = typeof record.id === "string" && record.id.trim() ? record.id.trim() : void 0;
2657
+ const name = typeof record.name === "string" && record.name.trim() ? record.name.trim() : void 0;
2658
+ if (!id || !name) return;
2659
+ const provider = record.provider === "codex" || record.provider === "openai" || record.provider === "openrouter" ? record.provider : void 0;
2660
+ const prompt = typeof record.prompt === "string" && record.prompt.trim() ? record.prompt.trim() : void 0;
2661
+ const promptFile = typeof record.promptFile === "string" && record.promptFile.trim() ? record.promptFile.trim() : void 0;
2662
+ if (!prompt && !promptFile) return;
2663
+ return {
2664
+ cwd: typeof record.cwd === "string" && record.cwd.trim() ? record.cwd.trim() : void 0,
2665
+ id,
2666
+ match: parseMatch(record.match),
2667
+ model: typeof record.model === "string" && record.model.trim() ? record.model.trim() : void 0,
2668
+ name,
2669
+ priority: typeof record.priority === "number" && Number.isFinite(record.priority) ? record.priority : typeof record.priority === "string" && /^-?\d+$/.test(record.priority) ? Number(record.priority) : void 0,
2670
+ prompt,
2671
+ promptFile,
2672
+ provider,
2673
+ systemPrompt: typeof record.systemPrompt === "string" && record.systemPrompt.trim() ? record.systemPrompt.trim() : void 0,
2674
+ systemPromptFile: typeof record.systemPromptFile === "string" && record.systemPromptFile.trim() ? record.systemPromptFile.trim() : void 0
2675
+ };
2676
+ }
2677
+ function cloneHarness(harness) {
2678
+ return {
2679
+ ...harness,
2680
+ match: harness.match ? {
2681
+ ...harness.match,
2682
+ calendarEventIds: harness.match.calendarEventIds ? [...harness.match.calendarEventIds] : void 0,
2683
+ eventKinds: harness.match.eventKinds ? [...harness.match.eventKinds] : void 0,
2684
+ folderIds: harness.match.folderIds ? [...harness.match.folderIds] : void 0,
2685
+ folderNames: harness.match.folderNames ? [...harness.match.folderNames] : void 0,
2686
+ meetingIds: harness.match.meetingIds ? [...harness.match.meetingIds] : void 0,
2687
+ recurringEventIds: harness.match.recurringEventIds ? [...harness.match.recurringEventIds] : void 0,
2688
+ tags: harness.match.tags ? [...harness.match.tags] : void 0,
2689
+ titleIncludes: harness.match.titleIncludes ? [...harness.match.titleIncludes] : void 0
2690
+ } : void 0
2691
+ };
2692
+ }
2693
+ function includesIgnoreCase$1(candidate, values) {
2694
+ const lowerCandidate = candidate.toLowerCase();
2695
+ return values.some((value) => lowerCandidate.includes(value.toLowerCase()));
2696
+ }
2697
+ function harnessSpecificity(match) {
2698
+ if (!match) return 0;
2699
+ return [
2700
+ match.calendarEventIds?.length ?? 0,
2701
+ match.eventKinds?.length ?? 0,
2702
+ match.folderIds?.length ?? 0,
2703
+ match.folderNames?.length ?? 0,
2704
+ match.meetingIds?.length ?? 0,
2705
+ match.recurringEventIds?.length ?? 0,
2706
+ match.tags?.length ?? 0,
2707
+ match.titleIncludes?.length ?? 0,
2708
+ match.titleMatches ? 1 : 0,
2709
+ match.transcriptLoaded != null ? 1 : 0
2710
+ ].reduce((total, count) => total + count, 0);
2711
+ }
2712
+ function matchesHarness(harness, context) {
2713
+ const match = harness.match;
2714
+ if (!match) return true;
2715
+ if (match.eventKinds?.length && !match.eventKinds.includes(context.match.eventKind)) return false;
2716
+ if (match.meetingIds?.length && !match.meetingIds.includes(context.match.meetingId)) return false;
2717
+ if (match.folderIds?.length && !context.match.folders.some((folder) => match.folderIds?.includes(folder.id))) return false;
2718
+ if (match.folderNames?.length && !context.match.folders.some((folder) => match.folderNames?.includes(folder.name))) return false;
2719
+ if (match.tags?.length && !context.match.tags.some((tag) => match.tags?.includes(tag))) return false;
2720
+ if (match.transcriptLoaded != null && context.match.transcriptLoaded !== match.transcriptLoaded) return false;
2721
+ if (match.titleIncludes?.length && !includesIgnoreCase$1(context.match.title, match.titleIncludes)) return false;
2722
+ if (match.titleMatches) try {
2723
+ if (!new RegExp(match.titleMatches, "i").test(context.match.title)) return false;
2724
+ } catch {
2725
+ return false;
2762
2726
  }
2763
- async appendMatches(matches) {
2764
- if (matches.length === 0) return;
2765
- await mkdir(dirname(this.filePath), { recursive: true });
2766
- const payload = matches.map((match) => JSON.stringify(match)).join("\n");
2767
- await appendFile(this.filePath, `${payload}\n`, {
2768
- encoding: "utf8",
2769
- mode: 384
2770
- });
2727
+ const calendarEventId = context.bundle?.document.calendarEvent?.id;
2728
+ if (match.calendarEventIds?.length && !calendarEventId) return false;
2729
+ if (match.calendarEventIds?.length && !match.calendarEventIds.includes(calendarEventId)) return false;
2730
+ const recurringEventId = context.bundle?.document.calendarEvent?.recurringEventId;
2731
+ if (match.recurringEventIds?.length && !recurringEventId) return false;
2732
+ if (match.recurringEventIds?.length && !match.recurringEventIds.includes(recurringEventId)) return false;
2733
+ return true;
2734
+ }
2735
+ function matchAgentHarnesses(harnesses, context) {
2736
+ return harnesses.filter((harness) => matchesHarness(harness, context)).slice().sort((left, right) => {
2737
+ const priority = (right.priority ?? 0) - (left.priority ?? 0);
2738
+ if (priority !== 0) return priority;
2739
+ return harnessSpecificity(right.match) - harnessSpecificity(left.match);
2740
+ }).map(cloneHarness);
2741
+ }
2742
+ function resolveAgentHarness(harnesses, context, harnessId) {
2743
+ if (harnessId?.trim()) {
2744
+ const harness = harnesses.find((candidate) => candidate.id === harnessId.trim());
2745
+ if (!harness) throw new Error(`agent harness not found: ${harnessId.trim()}`);
2746
+ return cloneHarness(harness);
2771
2747
  }
2772
- async readMatches(limit = 50) {
2748
+ return matchAgentHarnesses(harnesses, context)[0];
2749
+ }
2750
+ var FileAgentHarnessStore = class {
2751
+ constructor(filePath = defaultAgentHarnessesFilePath()) {
2752
+ this.filePath = filePath;
2753
+ }
2754
+ async readHarnesses() {
2773
2755
  try {
2774
- const matches = (await readFile(this.filePath, "utf8")).split("\n").map((line) => line.trim()).filter(Boolean).map((line) => parseJsonString(line)).filter((match) => Boolean(match)).map(cloneMatch);
2775
- return (limit > 0 ? matches.slice(-limit) : matches).reverse();
2756
+ const parsed = parseJsonString(await readFile(this.filePath, "utf8"));
2757
+ return (Array.isArray(parsed) ? parsed : parsed && typeof parsed === "object" && Array.isArray(parsed.harnesses) ? parsed.harnesses : []).map((harness) => parseHarness(harness)).filter((harness) => Boolean(harness)).map(cloneHarness);
2776
2758
  } catch {
2777
2759
  return [];
2778
2760
  }
2779
2761
  }
2780
2762
  };
2781
- function defaultAutomationMatchesFilePath() {
2782
- return defaultGranolaToolkitPersistenceLayout().automationMatchesFile;
2763
+ function defaultAgentHarnessesFilePath() {
2764
+ return defaultGranolaToolkitPersistenceLayout().agentHarnessesFile;
2783
2765
  }
2784
- function createDefaultAutomationMatchStore(filePath) {
2785
- return new FileAutomationMatchStore(filePath);
2766
+ function createDefaultAgentHarnessStore(filePath) {
2767
+ return new FileAgentHarnessStore(filePath);
2786
2768
  }
2787
2769
  //#endregion
2788
- //#region src/automation-runs.ts
2789
- function cloneRun(run) {
2770
+ //#region src/client/auth.ts
2771
+ const execFileAsync$1 = promisify(execFile);
2772
+ const DEFAULT_CLIENT_ID = "client_GranolaMac";
2773
+ const KEYCHAIN_SERVICE_NAME = "com.granola.toolkit";
2774
+ const KEYCHAIN_ACCOUNT_NAME_API_KEY = "api-key";
2775
+ const KEYCHAIN_ACCOUNT_NAME = "session";
2776
+ const WORKOS_AUTH_URL = "https://api.workos.com/user_management/authenticate";
2777
+ function numberValue(value) {
2778
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
2779
+ }
2780
+ function parseSessionRecord(record) {
2781
+ const accessToken = stringValue(record.access_token);
2782
+ if (!accessToken.trim()) return;
2790
2783
  return {
2791
- ...run,
2792
- folders: run.folders.map((folder) => ({ ...folder })),
2793
- meta: run.meta ? structuredClone(run.meta) : void 0,
2794
- tags: [...run.tags]
2784
+ accessToken,
2785
+ clientId: stringValue(record.client_id) || DEFAULT_CLIENT_ID,
2786
+ expiresIn: numberValue(record.expires_in),
2787
+ externalId: stringValue(record.external_id) || void 0,
2788
+ obtainedAt: stringValue(record.obtained_at) || void 0,
2789
+ refreshToken: stringValue(record.refresh_token) || void 0,
2790
+ sessionId: stringValue(record.session_id) || void 0,
2791
+ signInMethod: stringValue(record.sign_in_method) || void 0,
2792
+ tokenType: stringValue(record.token_type) || void 0
2795
2793
  };
2796
2794
  }
2797
- function sortRuns(runs) {
2798
- return runs.slice().sort((left, right) => (right.finishedAt ?? right.startedAt).localeCompare(left.finishedAt ?? left.startedAt));
2795
+ function parseNestedRecord(value) {
2796
+ if (typeof value === "string") return parseJsonString(value);
2797
+ return asRecord(value);
2799
2798
  }
2800
- function mergeRuns(runs) {
2801
- const byId = /* @__PURE__ */ new Map();
2802
- for (const run of runs) byId.set(run.id, cloneRun(run));
2803
- return sortRuns([...byId.values()]);
2799
+ function getSessionFromSupabaseContents(supabaseContents) {
2800
+ const wrapper = parseJsonString(supabaseContents);
2801
+ if (!wrapper) throw new Error("failed to parse supabase.json");
2802
+ const workOsSession = parseSessionRecord(parseNestedRecord(wrapper.workos_tokens) ?? {});
2803
+ if (workOsSession) return workOsSession;
2804
+ const cognitoSession = parseSessionRecord(parseNestedRecord(wrapper.cognito_tokens) ?? {});
2805
+ if (cognitoSession) return cognitoSession;
2806
+ const legacySession = parseSessionRecord(wrapper);
2807
+ if (legacySession) return legacySession;
2808
+ throw new Error("access token not found in supabase.json");
2804
2809
  }
2805
- var FileAutomationRunStore = class {
2806
- constructor(filePath = defaultAutomationRunsFilePath()) {
2810
+ function getAccessTokenFromSupabaseContents(supabaseContents) {
2811
+ return getSessionFromSupabaseContents(supabaseContents).accessToken;
2812
+ }
2813
+ var SupabaseFileTokenSource = class {
2814
+ constructor(filePath) {
2807
2815
  this.filePath = filePath;
2808
2816
  }
2809
- async appendRuns(runs) {
2810
- if (runs.length === 0) return;
2811
- await mkdir(dirname(this.filePath), { recursive: true });
2812
- const payload = runs.map((run) => JSON.stringify(run)).join("\n");
2813
- await appendFile(this.filePath, `${payload}\n`, {
2814
- encoding: "utf8",
2815
- mode: 384
2816
- });
2817
+ async loadAccessToken() {
2818
+ return getAccessTokenFromSupabaseContents(await readFile(this.filePath, "utf8"));
2817
2819
  }
2818
- async readRun(id) {
2819
- return (await this.readRuns({ limit: 0 })).find((run) => run.id === id);
2820
+ };
2821
+ var SupabaseFileSessionSource = class {
2822
+ constructor(filePath) {
2823
+ this.filePath = filePath;
2820
2824
  }
2821
- async readRuns(options = {}) {
2822
- try {
2823
- const runs = mergeRuns((await readFile(this.filePath, "utf8")).split("\n").map((line) => line.trim()).filter(Boolean).map((line) => parseJsonString(line)).filter((run) => Boolean(run))).filter((run) => options.status ? run.status === options.status : true);
2824
- return (options.limit && options.limit > 0 ? runs.slice(0, options.limit) : runs).map(cloneRun);
2825
- } catch {
2826
- return [];
2827
- }
2825
+ async loadSession() {
2826
+ return getSessionFromSupabaseContents(await readFile(this.filePath, "utf8"));
2828
2827
  }
2829
2828
  };
2830
- function defaultAutomationRunsFilePath() {
2831
- return defaultGranolaToolkitPersistenceLayout().automationRunsFile;
2829
+ var NoopTokenStore = class {
2830
+ async clearToken() {}
2831
+ async readToken() {}
2832
+ async writeToken(_token) {}
2833
+ };
2834
+ var FileSessionStore = class {
2835
+ constructor(filePath = defaultSessionFilePath()) {
2836
+ this.filePath = filePath;
2837
+ }
2838
+ async clearSession() {
2839
+ try {
2840
+ await unlink(this.filePath);
2841
+ } catch {}
2842
+ }
2843
+ async readSession() {
2844
+ try {
2845
+ const parsed = parseJsonString(await readFile(this.filePath, "utf8"));
2846
+ return parsed?.accessToken ? parsed : void 0;
2847
+ } catch {
2848
+ return;
2849
+ }
2850
+ }
2851
+ async writeSession(session) {
2852
+ await mkdir(dirname(this.filePath), { recursive: true });
2853
+ await writeFile(this.filePath, `${JSON.stringify(session, null, 2)}\n`, {
2854
+ encoding: "utf8",
2855
+ mode: 384
2856
+ });
2857
+ }
2858
+ };
2859
+ var FileApiKeyStore = class {
2860
+ constructor(filePath = defaultApiKeyFilePath()) {
2861
+ this.filePath = filePath;
2862
+ }
2863
+ async clearApiKey() {
2864
+ try {
2865
+ await unlink(this.filePath);
2866
+ } catch {}
2867
+ }
2868
+ async readApiKey() {
2869
+ try {
2870
+ return (await readFile(this.filePath, "utf8")).trim() || void 0;
2871
+ } catch {
2872
+ return;
2873
+ }
2874
+ }
2875
+ async writeApiKey(apiKey) {
2876
+ const trimmed = apiKey.trim();
2877
+ if (!trimmed) throw new Error("Granola API key is required");
2878
+ await mkdir(dirname(this.filePath), { recursive: true });
2879
+ await writeFile(this.filePath, `${trimmed}\n`, {
2880
+ encoding: "utf8",
2881
+ mode: 384
2882
+ });
2883
+ }
2884
+ };
2885
+ var KeychainSessionStore = class {
2886
+ async clearSession() {
2887
+ try {
2888
+ await execFileAsync$1("security", [
2889
+ "delete-generic-password",
2890
+ "-s",
2891
+ KEYCHAIN_SERVICE_NAME,
2892
+ "-a",
2893
+ KEYCHAIN_ACCOUNT_NAME
2894
+ ]);
2895
+ } catch {}
2896
+ }
2897
+ async readSession() {
2898
+ try {
2899
+ const { stdout } = await execFileAsync$1("security", [
2900
+ "find-generic-password",
2901
+ "-s",
2902
+ KEYCHAIN_SERVICE_NAME,
2903
+ "-a",
2904
+ KEYCHAIN_ACCOUNT_NAME,
2905
+ "-w"
2906
+ ]);
2907
+ const parsed = parseJsonString(stdout.trim());
2908
+ return parsed?.accessToken ? parsed : void 0;
2909
+ } catch {
2910
+ return;
2911
+ }
2912
+ }
2913
+ async writeSession(session) {
2914
+ await execFileAsync$1("security", [
2915
+ "add-generic-password",
2916
+ "-U",
2917
+ "-s",
2918
+ KEYCHAIN_SERVICE_NAME,
2919
+ "-a",
2920
+ KEYCHAIN_ACCOUNT_NAME,
2921
+ "-w",
2922
+ JSON.stringify(session)
2923
+ ]);
2924
+ }
2925
+ };
2926
+ var KeychainApiKeyStore = class {
2927
+ async clearApiKey() {
2928
+ try {
2929
+ await execFileAsync$1("security", [
2930
+ "delete-generic-password",
2931
+ "-s",
2932
+ KEYCHAIN_SERVICE_NAME,
2933
+ "-a",
2934
+ KEYCHAIN_ACCOUNT_NAME_API_KEY
2935
+ ]);
2936
+ } catch {}
2937
+ }
2938
+ async readApiKey() {
2939
+ try {
2940
+ const { stdout } = await execFileAsync$1("security", [
2941
+ "find-generic-password",
2942
+ "-s",
2943
+ KEYCHAIN_SERVICE_NAME,
2944
+ "-a",
2945
+ KEYCHAIN_ACCOUNT_NAME_API_KEY,
2946
+ "-w"
2947
+ ]);
2948
+ return stdout.trim() || void 0;
2949
+ } catch {
2950
+ return;
2951
+ }
2952
+ }
2953
+ async writeApiKey(apiKey) {
2954
+ const trimmed = apiKey.trim();
2955
+ if (!trimmed) throw new Error("Granola API key is required");
2956
+ await execFileAsync$1("security", [
2957
+ "add-generic-password",
2958
+ "-U",
2959
+ "-s",
2960
+ KEYCHAIN_SERVICE_NAME,
2961
+ "-a",
2962
+ KEYCHAIN_ACCOUNT_NAME_API_KEY,
2963
+ "-w",
2964
+ trimmed
2965
+ ]);
2966
+ }
2967
+ };
2968
+ var CachedTokenProvider = class {
2969
+ #token;
2970
+ constructor(source, store = new NoopTokenStore()) {
2971
+ this.source = source;
2972
+ this.store = store;
2973
+ }
2974
+ async getAccessToken() {
2975
+ if (this.#token) return this.#token;
2976
+ const storedToken = await this.store.readToken();
2977
+ if (storedToken?.trim()) {
2978
+ this.#token = storedToken;
2979
+ return storedToken;
2980
+ }
2981
+ const token = await this.source.loadAccessToken();
2982
+ this.#token = token;
2983
+ await this.store.writeToken(token);
2984
+ return token;
2985
+ }
2986
+ async invalidate() {
2987
+ this.#token = void 0;
2988
+ await this.store.clearToken();
2989
+ }
2990
+ };
2991
+ var StoredSessionTokenProvider = class {
2992
+ #session;
2993
+ constructor(store, options = {}) {
2994
+ this.store = store;
2995
+ this.options = options;
2996
+ }
2997
+ async loadSession() {
2998
+ if (this.#session) return this.#session;
2999
+ const storedSession = await this.store.readSession();
3000
+ if (storedSession?.accessToken.trim()) {
3001
+ this.#session = storedSession;
3002
+ return storedSession;
3003
+ }
3004
+ if (!this.options.source) throw new Error("no stored Granola session found");
3005
+ const sourcedSession = await this.options.source.loadSession();
3006
+ this.#session = sourcedSession;
3007
+ return sourcedSession;
3008
+ }
3009
+ async getAccessToken() {
3010
+ return (await this.loadSession()).accessToken;
3011
+ }
3012
+ async invalidate() {
3013
+ const session = await this.loadSession().catch(() => void 0);
3014
+ if (session?.refreshToken && session.clientId) try {
3015
+ const refreshedSession = await refreshGranolaSession(session, this.options.fetchImpl);
3016
+ this.#session = refreshedSession;
3017
+ await this.store.writeSession(refreshedSession);
3018
+ return;
3019
+ } catch {
3020
+ if (!this.options.source) {
3021
+ this.#session = void 0;
3022
+ await this.store.clearSession();
3023
+ throw new Error("failed to refresh stored Granola session");
3024
+ }
3025
+ }
3026
+ if (this.options.source) {
3027
+ const sourcedSession = await this.options.source.loadSession();
3028
+ this.#session = sourcedSession;
3029
+ await this.store.writeSession(sourcedSession);
3030
+ return;
3031
+ }
3032
+ this.#session = void 0;
3033
+ await this.store.clearSession();
3034
+ }
3035
+ };
3036
+ async function refreshGranolaSession(session, fetchImpl = fetch) {
3037
+ if (!session.refreshToken?.trim()) throw new Error("refresh token not available");
3038
+ const response = await fetchImpl(WORKOS_AUTH_URL, {
3039
+ body: JSON.stringify({
3040
+ client_id: session.clientId,
3041
+ grant_type: "refresh_token",
3042
+ refresh_token: session.refreshToken
3043
+ }),
3044
+ headers: { "Content-Type": "application/json" },
3045
+ method: "POST"
3046
+ });
3047
+ if (!response.ok) throw new Error(`failed to refresh session: ${response.status} ${response.statusText}`);
3048
+ const refreshed = parseSessionRecord(await response.json());
3049
+ if (!refreshed) throw new Error("failed to parse refreshed session");
3050
+ return {
3051
+ ...session,
3052
+ ...refreshed,
3053
+ clientId: refreshed.clientId || session.clientId,
3054
+ obtainedAt: refreshed.obtainedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
3055
+ refreshToken: refreshed.refreshToken ?? session.refreshToken
3056
+ };
2832
3057
  }
2833
- function createDefaultAutomationRunStore(filePath) {
2834
- return new FileAutomationRunStore(filePath);
3058
+ function defaultSessionFilePath() {
3059
+ return defaultGranolaToolkitPersistenceLayout().sessionFile;
3060
+ }
3061
+ function defaultApiKeyFilePath() {
3062
+ return defaultGranolaToolkitPersistenceLayout().apiKeyFile;
3063
+ }
3064
+ function createDefaultSessionStore() {
3065
+ return defaultGranolaToolkitPersistenceLayout().sessionStoreKind === "keychain" ? new KeychainSessionStore() : new FileSessionStore();
3066
+ }
3067
+ function createDefaultApiKeyStore() {
3068
+ return defaultGranolaToolkitPersistenceLayout().sessionStoreKind === "keychain" ? new KeychainApiKeyStore() : new FileApiKeyStore();
3069
+ }
3070
+ //#endregion
3071
+ //#region src/client/http.ts
3072
+ const RETRYABLE_STATUS_CODES = new Set([
3073
+ 429,
3074
+ 500,
3075
+ 502,
3076
+ 503,
3077
+ 504
3078
+ ]);
3079
+ function sleep(delayMs) {
3080
+ return new Promise((resolve) => {
3081
+ setTimeout(resolve, delayMs);
3082
+ });
3083
+ }
3084
+ function parseRetryAfter(headerValue) {
3085
+ if (!headerValue?.trim()) return;
3086
+ if (/^\d+$/.test(headerValue.trim())) return Number(headerValue.trim()) * 1e3;
3087
+ const retryAt = Date.parse(headerValue);
3088
+ if (Number.isNaN(retryAt)) return;
3089
+ return Math.max(0, retryAt - Date.now());
3090
+ }
3091
+ var AuthenticatedHttpClient = class {
3092
+ fetchImpl;
3093
+ constructor(options) {
3094
+ this.fetchImpl = options.fetchImpl ?? fetch;
3095
+ this.logger = options.logger;
3096
+ this.maxRetries = options.maxRetries ?? 2;
3097
+ this.retryBaseDelayMs = options.retryBaseDelayMs ?? 500;
3098
+ this.retryMaxDelayMs = options.retryMaxDelayMs ?? 5e3;
3099
+ this.sleepImpl = options.sleepImpl ?? sleep;
3100
+ this.tokenProvider = options.tokenProvider;
3101
+ }
3102
+ logger;
3103
+ maxRetries;
3104
+ retryBaseDelayMs;
3105
+ retryMaxDelayMs;
3106
+ sleepImpl;
3107
+ tokenProvider;
3108
+ async retry(options, attempt, reason, response) {
3109
+ const retryAfterMs = parseRetryAfter(response?.headers.get("retry-after") ?? null);
3110
+ const delayMs = Math.min(retryAfterMs ?? this.retryBaseDelayMs * 2 ** attempt, this.retryMaxDelayMs);
3111
+ this.logger?.warn?.(`${reason}; retrying in ${delayMs}ms (${attempt + 1}/${this.maxRetries})`);
3112
+ await this.sleepImpl(delayMs);
3113
+ return this.request(options, attempt + 1);
3114
+ }
3115
+ async request(options, attempt = 0) {
3116
+ const { retryOnUnauthorized = true, timeoutMs, url } = options;
3117
+ const accessToken = await this.tokenProvider.getAccessToken();
3118
+ let response;
3119
+ try {
3120
+ response = await this.fetchImpl(url, {
3121
+ body: options.body,
3122
+ headers: {
3123
+ ...options.headers,
3124
+ Authorization: `Bearer ${accessToken}`
3125
+ },
3126
+ method: options.method ?? "GET",
3127
+ signal: AbortSignal.timeout(timeoutMs)
3128
+ });
3129
+ } catch (error) {
3130
+ if (attempt < this.maxRetries) {
3131
+ const message = error instanceof Error ? error.message : String(error);
3132
+ return this.retry(options, attempt, `request failed: ${message}`);
3133
+ }
3134
+ throw error;
3135
+ }
3136
+ if (response.status === 401 && retryOnUnauthorized) {
3137
+ this.logger?.warn?.("request returned 401; invalidating token provider and retrying once");
3138
+ await this.tokenProvider.invalidate();
3139
+ return this.request({
3140
+ ...options,
3141
+ retryOnUnauthorized: false
3142
+ }, attempt);
3143
+ }
3144
+ if (RETRYABLE_STATUS_CODES.has(response.status) && attempt < this.maxRetries) return this.retry(options, attempt, `request returned ${response.status} ${response.statusText || ""}`.trim(), response);
3145
+ return response;
3146
+ }
3147
+ async postJson(url, body, options = { timeoutMs: 3e4 }) {
3148
+ return this.request({
3149
+ ...options,
3150
+ body: JSON.stringify(body),
3151
+ headers: {
3152
+ Accept: "*/*",
3153
+ "Content-Type": "application/json",
3154
+ ...options.headers
3155
+ },
3156
+ method: "POST",
3157
+ url
3158
+ });
3159
+ }
3160
+ };
3161
+ //#endregion
3162
+ //#region src/agents.ts
3163
+ const DEFAULT_CODEX_MODEL = "gpt-5-codex";
3164
+ const DEFAULT_OPENAI_MODEL = "gpt-5-mini";
3165
+ const DEFAULT_OPENROUTER_MODEL = "openai/gpt-5-mini";
3166
+ const OPENROUTER_REFERER = "https://github.com/kkarimi/granola-toolkit";
3167
+ const OPENROUTER_TITLE = "granola-toolkit";
3168
+ function trimString(value) {
3169
+ return value?.trim() ? value.trim() : void 0;
3170
+ }
3171
+ function resolveProvider(request, config, env) {
3172
+ if (request.provider) return request.provider;
3173
+ if (config.agents?.defaultProvider) return config.agents.defaultProvider;
3174
+ if (trimString(env.OPENROUTER_API_KEY) || trimString(env.GRANOLA_OPENROUTER_API_KEY)) return "openrouter";
3175
+ if (trimString(env.OPENAI_API_KEY) || trimString(env.GRANOLA_OPENAI_API_KEY)) return "openai";
3176
+ return "codex";
3177
+ }
3178
+ function resolveModel(provider, request, config) {
3179
+ return trimString(request.model) ?? trimString(config.agents?.defaultModel) ?? (provider === "openrouter" ? DEFAULT_OPENROUTER_MODEL : provider === "openai" ? DEFAULT_OPENAI_MODEL : DEFAULT_CODEX_MODEL);
3180
+ }
3181
+ function resolveTimeoutMs(request, config) {
3182
+ return request.timeoutMs ?? config.agents?.timeoutMs ?? 3e5;
3183
+ }
3184
+ function resolveRetries(request, config) {
3185
+ return request.retries ?? config.agents?.maxRetries ?? 2;
3186
+ }
3187
+ function resolveDryRun(request, config) {
3188
+ return request.dryRun ?? config.agents?.dryRun ?? false;
3189
+ }
3190
+ function openaiApiKey(env) {
3191
+ return trimString(env.OPENAI_API_KEY) ?? trimString(env.GRANOLA_OPENAI_API_KEY);
3192
+ }
3193
+ function openrouterApiKey(env) {
3194
+ return trimString(env.OPENROUTER_API_KEY) ?? trimString(env.GRANOLA_OPENROUTER_API_KEY);
3195
+ }
3196
+ async function responseError(response, label) {
3197
+ let details = `${response.status} ${response.statusText}`.trim();
3198
+ try {
3199
+ const payload = await response.json();
3200
+ if (typeof payload.error === "string" && payload.error.trim()) details = payload.error;
3201
+ else if (payload.error && typeof payload.error === "object" && typeof payload.error.message === "string" && payload.error.message.trim()) details = payload.error.message;
3202
+ else if (typeof payload.message === "string" && payload.message.trim()) details = payload.message;
3203
+ } catch {
3204
+ const text = (await response.text()).trim();
3205
+ if (text) details = text;
3206
+ }
3207
+ return /* @__PURE__ */ new Error(`${label}: ${details}`);
3208
+ }
3209
+ function messageText(content) {
3210
+ if (typeof content === "string") return content.trim();
3211
+ if (!Array.isArray(content)) return "";
3212
+ return content.map((part) => {
3213
+ if (part && typeof part === "object" && "type" in part && part.type === "text" && "text" in part && typeof part.text === "string") return part.text;
3214
+ return "";
3215
+ }).join("").trim();
3216
+ }
3217
+ async function runCodexCliCommand(request) {
3218
+ const tempDirectory = await mkdtemp(join(tmpdir(), "granola-toolkit-codex-"));
3219
+ const outputFile = join(tempDirectory, "last-message.txt");
3220
+ const args = [
3221
+ "exec",
3222
+ "--skip-git-repo-check",
3223
+ "--color",
3224
+ "never"
3225
+ ];
3226
+ if (request.cwd) args.push("-C", request.cwd);
3227
+ if (request.model) args.push("-m", request.model);
3228
+ args.push("--output-last-message", outputFile, "-");
3229
+ const commandText = [request.command, ...args].join(" ");
3230
+ try {
3231
+ return {
3232
+ command: commandText,
3233
+ output: await new Promise((resolve$1, reject) => {
3234
+ const child = spawn(request.command, args, {
3235
+ cwd: request.cwd ? resolve(request.cwd) : process.cwd(),
3236
+ env: process.env,
3237
+ stdio: [
3238
+ "pipe",
3239
+ "pipe",
3240
+ "pipe"
3241
+ ]
3242
+ });
3243
+ const stdoutChunks = [];
3244
+ const stderrChunks = [];
3245
+ let timedOut = false;
3246
+ const timeout = setTimeout(() => {
3247
+ timedOut = true;
3248
+ child.kill("SIGTERM");
3249
+ }, request.timeoutMs);
3250
+ child.stdout.on("data", (chunk) => {
3251
+ stdoutChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
3252
+ });
3253
+ child.stderr.on("data", (chunk) => {
3254
+ stderrChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
3255
+ });
3256
+ child.on("error", (error) => {
3257
+ clearTimeout(timeout);
3258
+ reject(error);
3259
+ });
3260
+ child.on("close", async (code) => {
3261
+ clearTimeout(timeout);
3262
+ const stdout = Buffer.concat(stdoutChunks).toString("utf8").trim();
3263
+ const stderr = Buffer.concat(stderrChunks).toString("utf8").trim();
3264
+ if (timedOut) {
3265
+ reject(/* @__PURE__ */ new Error(`codex provider timed out after ${request.timeoutMs}ms`));
3266
+ return;
3267
+ }
3268
+ if (code !== 0) {
3269
+ reject(new Error(stderr || stdout || `codex exited with status ${String(code)}`));
3270
+ return;
3271
+ }
3272
+ try {
3273
+ resolve$1((await readFile(outputFile, "utf8")).trim() || stdout || void 0);
3274
+ } catch {
3275
+ resolve$1(stdout || void 0);
3276
+ }
3277
+ });
3278
+ child.stdin.write(request.prompt);
3279
+ child.stdin.end();
3280
+ })
3281
+ };
3282
+ } finally {
3283
+ await rm(tempDirectory, {
3284
+ force: true,
3285
+ recursive: true
3286
+ }).catch(() => void 0);
3287
+ }
3288
+ }
3289
+ async function runOpenAiCompatibleRequest(options) {
3290
+ const response = await new AuthenticatedHttpClient({
3291
+ fetchImpl: options.fetchImpl,
3292
+ maxRetries: options.maxRetries,
3293
+ tokenProvider: new CachedTokenProvider({ async loadAccessToken() {
3294
+ return options.token;
3295
+ } })
3296
+ }).postJson(`${options.baseUrl.replace(/\/$/, "")}/chat/completions`, {
3297
+ messages: [...options.systemPrompt ? [{
3298
+ content: options.systemPrompt,
3299
+ role: "system"
3300
+ }] : [], {
3301
+ content: options.prompt,
3302
+ role: "user"
3303
+ }],
3304
+ model: options.model
3305
+ }, {
3306
+ headers: {
3307
+ Accept: "application/json",
3308
+ ...options.headers
3309
+ },
3310
+ timeoutMs: options.timeoutMs
3311
+ });
3312
+ if (!response.ok) throw await responseError(response, options.label);
3313
+ const content = (await response.json()).choices?.[0]?.message?.content;
3314
+ return messageText(content) || void 0;
2835
3315
  }
2836
- //#endregion
2837
- //#region src/automation-rules.ts
2838
- function cloneRule(rule) {
2839
- return {
2840
- ...rule,
2841
- actions: rule.actions?.map((action) => cloneAction(action)),
2842
- when: {
2843
- ...rule.when,
2844
- eventKinds: rule.when.eventKinds ? [...rule.when.eventKinds] : void 0,
2845
- folderIds: rule.when.folderIds ? [...rule.when.folderIds] : void 0,
2846
- folderNames: rule.when.folderNames ? [...rule.when.folderNames] : void 0,
2847
- meetingIds: rule.when.meetingIds ? [...rule.when.meetingIds] : void 0,
2848
- tags: rule.when.tags ? [...rule.when.tags] : void 0,
2849
- titleIncludes: rule.when.titleIncludes ? [...rule.when.titleIncludes] : void 0
3316
+ function createDefaultAutomationAgentRunner(config, options = {}) {
3317
+ const env = options.env ?? process.env;
3318
+ const runCodexCommand = options.runCodexCommand ?? runCodexCliCommand;
3319
+ return { async run(request) {
3320
+ const provider = resolveProvider(request, config, env);
3321
+ const model = resolveModel(provider, request, config);
3322
+ const timeoutMs = resolveTimeoutMs(request, config);
3323
+ const retries = resolveRetries(request, config);
3324
+ const dryRun = resolveDryRun(request, config);
3325
+ if (dryRun) return {
3326
+ dryRun,
3327
+ model,
3328
+ output: void 0,
3329
+ prompt: request.prompt,
3330
+ provider,
3331
+ systemPrompt: request.systemPrompt
3332
+ };
3333
+ if (provider === "codex") {
3334
+ const result = await runCodexCommand({
3335
+ command: config.agents?.codexCommand ?? "codex",
3336
+ cwd: request.cwd,
3337
+ model,
3338
+ prompt: request.systemPrompt ? `${request.systemPrompt.trim()}\n\n${request.prompt}` : request.prompt,
3339
+ timeoutMs
3340
+ });
3341
+ return {
3342
+ command: result.command,
3343
+ dryRun,
3344
+ model,
3345
+ output: result.output,
3346
+ prompt: request.prompt,
3347
+ provider,
3348
+ systemPrompt: request.systemPrompt
3349
+ };
2850
3350
  }
2851
- };
3351
+ const token = provider === "openrouter" ? openrouterApiKey(env) : openaiApiKey(env);
3352
+ if (!token) throw new Error(provider === "openrouter" ? "OpenRouter API key not found. Set OPENROUTER_API_KEY or GRANOLA_OPENROUTER_API_KEY." : "OpenAI API key not found. Set OPENAI_API_KEY or GRANOLA_OPENAI_API_KEY.");
3353
+ return {
3354
+ dryRun,
3355
+ model,
3356
+ output: await runOpenAiCompatibleRequest({
3357
+ baseUrl: provider === "openrouter" ? config.agents?.openrouterBaseUrl ?? "https://openrouter.ai/api/v1" : config.agents?.openaiBaseUrl ?? "https://api.openai.com/v1",
3358
+ fetchImpl: options.fetchImpl,
3359
+ headers: provider === "openrouter" ? {
3360
+ "HTTP-Referer": OPENROUTER_REFERER,
3361
+ "X-Title": OPENROUTER_TITLE
3362
+ } : void 0,
3363
+ label: provider === "openrouter" ? "OpenRouter request failed" : "OpenAI request failed",
3364
+ maxRetries: retries,
3365
+ model,
3366
+ prompt: request.prompt,
3367
+ systemPrompt: request.systemPrompt,
3368
+ timeoutMs,
3369
+ token
3370
+ }),
3371
+ prompt: request.prompt,
3372
+ provider,
3373
+ systemPrompt: request.systemPrompt
3374
+ };
3375
+ } };
2852
3376
  }
2853
- function cloneAction(action) {
3377
+ //#endregion
3378
+ //#region src/automation-actions.ts
3379
+ function cloneAction$1(action) {
2854
3380
  switch (action.kind) {
3381
+ case "agent": return { ...action };
2855
3382
  case "ask-user": return { ...action };
2856
3383
  case "command": return {
2857
3384
  ...action,
@@ -2862,518 +3389,483 @@ function cloneAction(action) {
2862
3389
  case "export-transcript": return { ...action };
2863
3390
  }
2864
3391
  }
2865
- function stringArray(value) {
2866
- if (!Array.isArray(value)) return;
2867
- const values = value.filter((item) => typeof item === "string" && item.trim().length > 0);
2868
- return values.length > 0 ? [...new Set(values.map((item) => item.trim()))] : void 0;
2869
- }
2870
- function stringRecord(value) {
2871
- if (!value || typeof value !== "object" || Array.isArray(value)) return;
2872
- const entries = Object.entries(value).filter(([key, item]) => {
2873
- return typeof key === "string" && key.trim().length > 0 && typeof item === "string" && item.trim().length > 0;
2874
- });
2875
- if (entries.length === 0) return;
2876
- return Object.fromEntries(entries.map(([key, item]) => [key.trim(), item.trim()]));
2877
- }
2878
- function parseAction(value, index) {
2879
- if (!value || typeof value !== "object" || Array.isArray(value)) return;
2880
- const record = value;
2881
- const kind = typeof record.kind === "string" && record.kind.trim() ? record.kind.trim() : void 0;
2882
- const id = typeof record.id === "string" && record.id.trim() ? record.id.trim() : kind ? `${kind}-${index + 1}` : void 0;
2883
- const name = typeof record.name === "string" && record.name.trim() ? record.name.trim() : void 0;
2884
- const enabled = typeof record.enabled === "boolean" ? record.enabled : void 0;
2885
- switch (kind) {
2886
- case "ask-user": {
2887
- const prompt = typeof record.prompt === "string" && record.prompt.trim() ? record.prompt.trim() : void 0;
2888
- if (!id || !prompt) return;
2889
- return {
2890
- details: typeof record.details === "string" && record.details.trim() ? record.details.trim() : void 0,
2891
- enabled,
2892
- id,
2893
- kind,
2894
- name,
2895
- prompt
2896
- };
2897
- }
2898
- case "command": {
2899
- const command = typeof record.command === "string" && record.command.trim() ? record.command.trim() : void 0;
2900
- if (!id || !command) return;
2901
- return {
2902
- args: stringArray(record.args),
2903
- command,
2904
- cwd: typeof record.cwd === "string" && record.cwd.trim() ? record.cwd.trim() : void 0,
2905
- enabled,
2906
- env: stringRecord(record.env),
2907
- id,
2908
- kind,
2909
- name,
2910
- stdin: record.stdin === "json" || record.stdin === "none" ? record.stdin : void 0,
2911
- timeoutMs: typeof record.timeoutMs === "number" && Number.isFinite(record.timeoutMs) ? record.timeoutMs : typeof record.timeoutMs === "string" && /^\d+$/.test(record.timeoutMs) ? Number(record.timeoutMs) : void 0
2912
- };
2913
- }
2914
- case "export-notes":
2915
- if (!id) return;
2916
- return {
2917
- enabled,
2918
- format: record.format === "json" || record.format === "markdown" || record.format === "raw" || record.format === "yaml" ? record.format : void 0,
2919
- id,
2920
- kind,
2921
- name,
2922
- outputDir: typeof record.outputDir === "string" && record.outputDir.trim() ? record.outputDir.trim() : void 0,
2923
- scopedOutput: typeof record.scopedOutput === "boolean" ? record.scopedOutput : void 0
2924
- };
2925
- case "export-transcript":
2926
- if (!id) return;
2927
- return {
2928
- enabled,
2929
- format: record.format === "json" || record.format === "raw" || record.format === "text" || record.format === "yaml" ? record.format : void 0,
2930
- id,
2931
- kind,
2932
- name,
2933
- outputDir: typeof record.outputDir === "string" && record.outputDir.trim() ? record.outputDir.trim() : void 0,
2934
- scopedOutput: typeof record.scopedOutput === "boolean" ? record.scopedOutput : void 0
2935
- };
2936
- default: return;
2937
- }
2938
- }
2939
- function parseRule(value) {
2940
- if (!value || typeof value !== "object" || Array.isArray(value)) return;
2941
- const record = value;
2942
- const id = typeof record.id === "string" && record.id.trim() ? record.id.trim() : void 0;
2943
- const name = typeof record.name === "string" && record.name.trim() ? record.name.trim() : void 0;
2944
- const whenValue = record.when && typeof record.when === "object" && !Array.isArray(record.when) ? record.when : void 0;
2945
- if (!id || !name || !whenValue) return;
2946
- return {
2947
- actions: Array.isArray(record.actions) ? record.actions.map((action, index) => parseAction(action, index)).filter((action) => Boolean(action)) : void 0,
2948
- enabled: typeof record.enabled === "boolean" ? record.enabled : void 0,
2949
- id,
2950
- name,
2951
- when: {
2952
- eventKinds: stringArray(whenValue.eventKinds),
2953
- folderIds: stringArray(whenValue.folderIds),
2954
- folderNames: stringArray(whenValue.folderNames),
2955
- meetingIds: stringArray(whenValue.meetingIds),
2956
- tags: stringArray(whenValue.tags),
2957
- titleIncludes: stringArray(whenValue.titleIncludes),
2958
- titleMatches: typeof whenValue.titleMatches === "string" && whenValue.titleMatches.trim() ? whenValue.titleMatches.trim() : void 0,
2959
- transcriptLoaded: typeof whenValue.transcriptLoaded === "boolean" ? whenValue.transcriptLoaded : void 0
2960
- }
2961
- };
2962
- }
2963
- var FileAutomationRuleStore = class {
2964
- constructor(filePath = defaultAutomationRulesFilePath()) {
2965
- this.filePath = filePath;
2966
- }
2967
- async readRules() {
2968
- try {
2969
- const parsed = parseJsonString(await readFile(this.filePath, "utf8"));
2970
- return (Array.isArray(parsed) ? parsed : parsed && typeof parsed === "object" && Array.isArray(parsed.rules) ? parsed.rules : []).map((rule) => parseRule(rule)).filter((rule) => Boolean(rule)).map(cloneRule);
2971
- } catch {
2972
- return [];
2973
- }
2974
- }
2975
- };
2976
- function defaultAutomationRulesFilePath() {
2977
- return defaultGranolaToolkitPersistenceLayout().automationRulesFile;
2978
- }
2979
- function includesIgnoreCase(candidate, values) {
2980
- const lowerCandidate = candidate.toLowerCase();
2981
- return values.some((value) => lowerCandidate.includes(value.toLowerCase()));
2982
- }
2983
- function matchesRule(rule, event) {
2984
- if (rule.enabled === false) return false;
2985
- const { when } = rule;
2986
- if (when.eventKinds?.length && !when.eventKinds.includes(event.kind)) return false;
2987
- if (when.meetingIds?.length && !when.meetingIds.includes(event.meetingId)) return false;
2988
- if (when.folderIds?.length && !event.folders.some((folder) => when.folderIds?.includes(folder.id))) return false;
2989
- if (when.folderNames?.length && !event.folders.some((folder) => when.folderNames?.includes(folder.name))) return false;
2990
- if (when.tags?.length && !event.tags.some((tag) => when.tags?.includes(tag))) return false;
2991
- if (when.titleIncludes?.length && !includesIgnoreCase(event.title, when.titleIncludes)) return false;
2992
- if (when.titleMatches) try {
2993
- if (!new RegExp(when.titleMatches, "i").test(event.title)) return false;
2994
- } catch {
2995
- return false;
2996
- }
2997
- if (typeof when.transcriptLoaded === "boolean" && when.transcriptLoaded !== event.transcriptLoaded) return false;
2998
- return true;
2999
- }
3000
- function matchAutomationRules(rules, events, matchedAt) {
3001
- const matches = [];
3002
- for (const event of events) for (const rule of rules) {
3003
- if (!matchesRule(rule, event)) continue;
3004
- matches.push({
3005
- eventId: event.id,
3006
- eventKind: event.kind,
3007
- folders: event.folders.map((folder) => ({ ...folder })),
3008
- id: `${event.id}:${rule.id}`,
3009
- matchedAt,
3010
- meetingId: event.meetingId,
3011
- ruleId: rule.id,
3012
- ruleName: rule.name,
3013
- tags: [...event.tags],
3014
- title: event.title,
3015
- transcriptLoaded: event.transcriptLoaded
3016
- });
3017
- }
3018
- return matches;
3019
- }
3020
- function createDefaultAutomationRuleStore(filePath) {
3021
- return new FileAutomationRuleStore(filePath);
3392
+ function automationActionName(action) {
3393
+ return action.name || action.id;
3022
3394
  }
3023
- //#endregion
3024
- //#region src/cache.ts
3025
- function parseCacheDocument(id, value) {
3026
- const record = asRecord(value);
3027
- if (!record) return;
3028
- return {
3029
- createdAt: stringValue(record.created_at),
3030
- id,
3031
- title: stringValue(record.title),
3032
- updatedAt: stringValue(record.updated_at)
3033
- };
3395
+ function buildAutomationActionRunId(match, actionId) {
3396
+ return `${match.id}:${actionId}`;
3034
3397
  }
3035
- function parseTranscriptSegments(value) {
3036
- if (!Array.isArray(value)) return;
3037
- return value.flatMap((segment) => {
3038
- const record = asRecord(segment);
3039
- if (!record) return [];
3040
- return [{
3041
- documentId: stringValue(record.document_id),
3042
- endTimestamp: stringValue(record.end_timestamp),
3043
- id: stringValue(record.id),
3044
- isFinal: Boolean(record.is_final),
3045
- source: stringValue(record.source),
3046
- startTimestamp: stringValue(record.start_timestamp),
3047
- text: stringValue(record.text)
3048
- }];
3049
- });
3398
+ function enabledAutomationActions(rule) {
3399
+ return (rule.actions ?? []).filter((action) => action.enabled !== false).map((action) => cloneAction$1(action));
3050
3400
  }
3051
- function parseCacheContents(contents) {
3052
- const outer = parseJsonString(contents);
3053
- if (!outer) throw new Error("failed to parse cache JSON");
3054
- const rawCache = outer.cache;
3055
- let cachePayload;
3056
- if (typeof rawCache === "string") cachePayload = parseJsonString(rawCache);
3057
- else cachePayload = asRecord(rawCache);
3058
- const state = cachePayload ? asRecord(cachePayload.state) : void 0;
3059
- if (!state) throw new Error("failed to parse cache state");
3060
- const rawDocuments = asRecord(state.documents) ?? {};
3061
- const rawTranscripts = asRecord(state.transcripts) ?? {};
3062
- const documents = {};
3063
- for (const [id, rawDocument] of Object.entries(rawDocuments)) {
3064
- const document = parseCacheDocument(id, rawDocument);
3065
- if (document) documents[id] = document;
3066
- }
3067
- const transcripts = {};
3068
- for (const [id, rawTranscript] of Object.entries(rawTranscripts)) {
3069
- const segments = parseTranscriptSegments(rawTranscript);
3070
- if (segments) transcripts[id] = segments;
3071
- }
3401
+ function baseRun(match, rule, action, startedAt) {
3072
3402
  return {
3073
- documents,
3074
- transcripts
3403
+ actionId: action.id,
3404
+ actionKind: action.kind,
3405
+ actionName: automationActionName(action),
3406
+ eventId: match.eventId,
3407
+ eventKind: match.eventKind,
3408
+ folders: match.folders.map((folder) => ({ ...folder })),
3409
+ id: buildAutomationActionRunId(match, action.id),
3410
+ matchedAt: match.matchedAt,
3411
+ meetingId: match.meetingId,
3412
+ ruleId: rule.id,
3413
+ ruleName: rule.name,
3414
+ startedAt,
3415
+ status: "completed",
3416
+ tags: [...match.tags],
3417
+ title: match.title,
3418
+ transcriptLoaded: match.transcriptLoaded
3075
3419
  };
3076
3420
  }
3077
- //#endregion
3078
- //#region src/client/auth.ts
3079
- const execFileAsync$1 = promisify(execFile);
3080
- const DEFAULT_CLIENT_ID = "client_GranolaMac";
3081
- const KEYCHAIN_SERVICE_NAME = "com.granola.toolkit";
3082
- const KEYCHAIN_ACCOUNT_NAME_API_KEY = "api-key";
3083
- const KEYCHAIN_ACCOUNT_NAME = "session";
3084
- const WORKOS_AUTH_URL = "https://api.workos.com/user_management/authenticate";
3085
- function numberValue(value) {
3086
- return typeof value === "number" && Number.isFinite(value) ? value : void 0;
3421
+ function completedRun(run, finishedAt, patch = {}) {
3422
+ return {
3423
+ ...run,
3424
+ ...patch,
3425
+ finishedAt,
3426
+ status: "completed"
3427
+ };
3087
3428
  }
3088
- function parseSessionRecord(record) {
3089
- const accessToken = stringValue(record.access_token);
3090
- if (!accessToken.trim()) return;
3429
+ function failedRun(run, finishedAt, error) {
3091
3430
  return {
3092
- accessToken,
3093
- clientId: stringValue(record.client_id) || DEFAULT_CLIENT_ID,
3094
- expiresIn: numberValue(record.expires_in),
3095
- externalId: stringValue(record.external_id) || void 0,
3096
- obtainedAt: stringValue(record.obtained_at) || void 0,
3097
- refreshToken: stringValue(record.refresh_token) || void 0,
3098
- sessionId: stringValue(record.session_id) || void 0,
3099
- signInMethod: stringValue(record.sign_in_method) || void 0,
3100
- tokenType: stringValue(record.token_type) || void 0
3431
+ ...run,
3432
+ error: error instanceof Error ? error.message : String(error),
3433
+ finishedAt,
3434
+ status: "failed"
3101
3435
  };
3102
3436
  }
3103
- function parseNestedRecord(value) {
3104
- if (typeof value === "string") return parseJsonString(value);
3105
- return asRecord(value);
3437
+ function skippedRun(run, finishedAt, reason) {
3438
+ return {
3439
+ ...run,
3440
+ finishedAt,
3441
+ result: reason,
3442
+ status: "skipped"
3443
+ };
3106
3444
  }
3107
- function getSessionFromSupabaseContents(supabaseContents) {
3108
- const wrapper = parseJsonString(supabaseContents);
3109
- if (!wrapper) throw new Error("failed to parse supabase.json");
3110
- const workOsSession = parseSessionRecord(parseNestedRecord(wrapper.workos_tokens) ?? {});
3111
- if (workOsSession) return workOsSession;
3112
- const cognitoSession = parseSessionRecord(parseNestedRecord(wrapper.cognito_tokens) ?? {});
3113
- if (cognitoSession) return cognitoSession;
3114
- const legacySession = parseSessionRecord(wrapper);
3115
- if (legacySession) return legacySession;
3116
- throw new Error("access token not found in supabase.json");
3445
+ async function executeAutomationAction(match, rule, action, handlers) {
3446
+ const run = baseRun(match, rule, action, handlers.nowIso());
3447
+ switch (action.kind) {
3448
+ case "agent": try {
3449
+ const result = await handlers.runAgent(match, rule, action);
3450
+ return completedRun(run, handlers.nowIso(), {
3451
+ meta: {
3452
+ command: result.command,
3453
+ dryRun: result.dryRun,
3454
+ model: result.model,
3455
+ provider: result.provider,
3456
+ systemPrompt: result.systemPrompt
3457
+ },
3458
+ prompt: result.prompt,
3459
+ result: result.output ?? (result.dryRun ? "Dry run: provider request not executed" : "")
3460
+ });
3461
+ } catch (error) {
3462
+ return failedRun(run, handlers.nowIso(), error);
3463
+ }
3464
+ case "ask-user": return {
3465
+ ...run,
3466
+ meta: action.details ? { details: action.details } : void 0,
3467
+ prompt: action.prompt,
3468
+ result: "Pending user decision",
3469
+ status: "pending"
3470
+ };
3471
+ case "command": try {
3472
+ const result = await handlers.runCommand(match, rule, action);
3473
+ return completedRun(run, handlers.nowIso(), {
3474
+ meta: {
3475
+ command: result.command,
3476
+ cwd: result.cwd
3477
+ },
3478
+ result: result.output
3479
+ });
3480
+ } catch (error) {
3481
+ return failedRun(run, handlers.nowIso(), error);
3482
+ }
3483
+ case "export-notes": try {
3484
+ const result = await handlers.exportNotes(match, action);
3485
+ if (!result) return skippedRun(run, handlers.nowIso(), "Meeting notes were unavailable for export");
3486
+ return completedRun(run, handlers.nowIso(), {
3487
+ meta: {
3488
+ format: result.format,
3489
+ outputDir: result.outputDir,
3490
+ scope: result.scope,
3491
+ written: result.written
3492
+ },
3493
+ result: `Exported notes to ${result.outputDir}`
3494
+ });
3495
+ } catch (error) {
3496
+ return failedRun(run, handlers.nowIso(), error);
3497
+ }
3498
+ case "export-transcript": try {
3499
+ const result = await handlers.exportTranscripts(match, action);
3500
+ if (!result) return skippedRun(run, handlers.nowIso(), "Transcript data was unavailable for export");
3501
+ return completedRun(run, handlers.nowIso(), {
3502
+ meta: {
3503
+ format: result.format,
3504
+ outputDir: result.outputDir,
3505
+ scope: result.scope,
3506
+ written: result.written
3507
+ },
3508
+ result: `Exported transcript to ${result.outputDir}`
3509
+ });
3510
+ } catch (error) {
3511
+ return failedRun(run, handlers.nowIso(), error);
3512
+ }
3513
+ }
3117
3514
  }
3118
- function getAccessTokenFromSupabaseContents(supabaseContents) {
3119
- return getSessionFromSupabaseContents(supabaseContents).accessToken;
3515
+ //#endregion
3516
+ //#region src/automation-matches.ts
3517
+ function cloneMatch(match) {
3518
+ return {
3519
+ ...match,
3520
+ folders: match.folders.map((folder) => ({ ...folder })),
3521
+ tags: [...match.tags]
3522
+ };
3120
3523
  }
3121
- var SupabaseFileTokenSource = class {
3122
- constructor(filePath) {
3123
- this.filePath = filePath;
3124
- }
3125
- async loadAccessToken() {
3126
- return getAccessTokenFromSupabaseContents(await readFile(this.filePath, "utf8"));
3127
- }
3128
- };
3129
- var SupabaseFileSessionSource = class {
3130
- constructor(filePath) {
3131
- this.filePath = filePath;
3132
- }
3133
- async loadSession() {
3134
- return getSessionFromSupabaseContents(await readFile(this.filePath, "utf8"));
3135
- }
3136
- };
3137
- var NoopTokenStore = class {
3138
- async clearToken() {}
3139
- async readToken() {}
3140
- async writeToken(_token) {}
3141
- };
3142
- var FileSessionStore = class {
3143
- constructor(filePath = defaultSessionFilePath()) {
3524
+ var FileAutomationMatchStore = class {
3525
+ constructor(filePath = defaultAutomationMatchesFilePath()) {
3144
3526
  this.filePath = filePath;
3145
3527
  }
3146
- async clearSession() {
3147
- try {
3148
- await unlink(this.filePath);
3149
- } catch {}
3150
- }
3151
- async readSession() {
3152
- try {
3153
- const parsed = parseJsonString(await readFile(this.filePath, "utf8"));
3154
- return parsed?.accessToken ? parsed : void 0;
3155
- } catch {
3156
- return;
3157
- }
3158
- }
3159
- async writeSession(session) {
3528
+ async appendMatches(matches) {
3529
+ if (matches.length === 0) return;
3160
3530
  await mkdir(dirname(this.filePath), { recursive: true });
3161
- await writeFile(this.filePath, `${JSON.stringify(session, null, 2)}\n`, {
3531
+ const payload = matches.map((match) => JSON.stringify(match)).join("\n");
3532
+ await appendFile(this.filePath, `${payload}\n`, {
3162
3533
  encoding: "utf8",
3163
3534
  mode: 384
3164
3535
  });
3165
3536
  }
3166
- };
3167
- var FileApiKeyStore = class {
3168
- constructor(filePath = defaultApiKeyFilePath()) {
3169
- this.filePath = filePath;
3170
- }
3171
- async clearApiKey() {
3172
- try {
3173
- await unlink(this.filePath);
3174
- } catch {}
3175
- }
3176
- async readApiKey() {
3537
+ async readMatches(limit = 50) {
3177
3538
  try {
3178
- return (await readFile(this.filePath, "utf8")).trim() || void 0;
3539
+ const matches = (await readFile(this.filePath, "utf8")).split("\n").map((line) => line.trim()).filter(Boolean).map((line) => parseJsonString(line)).filter((match) => Boolean(match)).map(cloneMatch);
3540
+ return (limit > 0 ? matches.slice(-limit) : matches).reverse();
3179
3541
  } catch {
3180
- return;
3542
+ return [];
3181
3543
  }
3182
3544
  }
3183
- async writeApiKey(apiKey) {
3184
- const trimmed = apiKey.trim();
3185
- if (!trimmed) throw new Error("Granola API key is required");
3186
- await mkdir(dirname(this.filePath), { recursive: true });
3187
- await writeFile(this.filePath, `${trimmed}\n`, {
3188
- encoding: "utf8",
3189
- mode: 384
3190
- });
3191
- }
3192
3545
  };
3193
- var KeychainSessionStore = class {
3194
- async clearSession() {
3195
- try {
3196
- await execFileAsync$1("security", [
3197
- "delete-generic-password",
3198
- "-s",
3199
- KEYCHAIN_SERVICE_NAME,
3200
- "-a",
3201
- KEYCHAIN_ACCOUNT_NAME
3202
- ]);
3203
- } catch {}
3204
- }
3205
- async readSession() {
3206
- try {
3207
- const { stdout } = await execFileAsync$1("security", [
3208
- "find-generic-password",
3209
- "-s",
3210
- KEYCHAIN_SERVICE_NAME,
3211
- "-a",
3212
- KEYCHAIN_ACCOUNT_NAME,
3213
- "-w"
3214
- ]);
3215
- const parsed = parseJsonString(stdout.trim());
3216
- return parsed?.accessToken ? parsed : void 0;
3217
- } catch {
3218
- return;
3219
- }
3546
+ function defaultAutomationMatchesFilePath() {
3547
+ return defaultGranolaToolkitPersistenceLayout().automationMatchesFile;
3548
+ }
3549
+ function createDefaultAutomationMatchStore(filePath) {
3550
+ return new FileAutomationMatchStore(filePath);
3551
+ }
3552
+ //#endregion
3553
+ //#region src/automation-runs.ts
3554
+ function cloneRun(run) {
3555
+ return {
3556
+ ...run,
3557
+ folders: run.folders.map((folder) => ({ ...folder })),
3558
+ meta: run.meta ? structuredClone(run.meta) : void 0,
3559
+ tags: [...run.tags]
3560
+ };
3561
+ }
3562
+ function sortRuns(runs) {
3563
+ return runs.slice().sort((left, right) => (right.finishedAt ?? right.startedAt).localeCompare(left.finishedAt ?? left.startedAt));
3564
+ }
3565
+ function mergeRuns(runs) {
3566
+ const byId = /* @__PURE__ */ new Map();
3567
+ for (const run of runs) byId.set(run.id, cloneRun(run));
3568
+ return sortRuns([...byId.values()]);
3569
+ }
3570
+ var FileAutomationRunStore = class {
3571
+ constructor(filePath = defaultAutomationRunsFilePath()) {
3572
+ this.filePath = filePath;
3220
3573
  }
3221
- async writeSession(session) {
3222
- await execFileAsync$1("security", [
3223
- "add-generic-password",
3224
- "-U",
3225
- "-s",
3226
- KEYCHAIN_SERVICE_NAME,
3227
- "-a",
3228
- KEYCHAIN_ACCOUNT_NAME,
3229
- "-w",
3230
- JSON.stringify(session)
3231
- ]);
3574
+ async appendRuns(runs) {
3575
+ if (runs.length === 0) return;
3576
+ await mkdir(dirname(this.filePath), { recursive: true });
3577
+ const payload = runs.map((run) => JSON.stringify(run)).join("\n");
3578
+ await appendFile(this.filePath, `${payload}\n`, {
3579
+ encoding: "utf8",
3580
+ mode: 384
3581
+ });
3232
3582
  }
3233
- };
3234
- var KeychainApiKeyStore = class {
3235
- async clearApiKey() {
3236
- try {
3237
- await execFileAsync$1("security", [
3238
- "delete-generic-password",
3239
- "-s",
3240
- KEYCHAIN_SERVICE_NAME,
3241
- "-a",
3242
- KEYCHAIN_ACCOUNT_NAME_API_KEY
3243
- ]);
3244
- } catch {}
3583
+ async readRun(id) {
3584
+ return (await this.readRuns({ limit: 0 })).find((run) => run.id === id);
3245
3585
  }
3246
- async readApiKey() {
3586
+ async readRuns(options = {}) {
3247
3587
  try {
3248
- const { stdout } = await execFileAsync$1("security", [
3249
- "find-generic-password",
3250
- "-s",
3251
- KEYCHAIN_SERVICE_NAME,
3252
- "-a",
3253
- KEYCHAIN_ACCOUNT_NAME_API_KEY,
3254
- "-w"
3255
- ]);
3256
- return stdout.trim() || void 0;
3588
+ const runs = mergeRuns((await readFile(this.filePath, "utf8")).split("\n").map((line) => line.trim()).filter(Boolean).map((line) => parseJsonString(line)).filter((run) => Boolean(run))).filter((run) => options.status ? run.status === options.status : true);
3589
+ return (options.limit && options.limit > 0 ? runs.slice(0, options.limit) : runs).map(cloneRun);
3257
3590
  } catch {
3258
- return;
3591
+ return [];
3259
3592
  }
3260
3593
  }
3261
- async writeApiKey(apiKey) {
3262
- const trimmed = apiKey.trim();
3263
- if (!trimmed) throw new Error("Granola API key is required");
3264
- await execFileAsync$1("security", [
3265
- "add-generic-password",
3266
- "-U",
3267
- "-s",
3268
- KEYCHAIN_SERVICE_NAME,
3269
- "-a",
3270
- KEYCHAIN_ACCOUNT_NAME_API_KEY,
3271
- "-w",
3272
- trimmed
3273
- ]);
3274
- }
3275
3594
  };
3276
- var CachedTokenProvider = class {
3277
- #token;
3278
- constructor(source, store = new NoopTokenStore()) {
3279
- this.source = source;
3280
- this.store = store;
3281
- }
3282
- async getAccessToken() {
3283
- if (this.#token) return this.#token;
3284
- const storedToken = await this.store.readToken();
3285
- if (storedToken?.trim()) {
3286
- this.#token = storedToken;
3287
- return storedToken;
3595
+ function defaultAutomationRunsFilePath() {
3596
+ return defaultGranolaToolkitPersistenceLayout().automationRunsFile;
3597
+ }
3598
+ function createDefaultAutomationRunStore(filePath) {
3599
+ return new FileAutomationRunStore(filePath);
3600
+ }
3601
+ //#endregion
3602
+ //#region src/automation-rules.ts
3603
+ function cloneRule(rule) {
3604
+ return {
3605
+ ...rule,
3606
+ actions: rule.actions?.map((action) => cloneAction(action)),
3607
+ when: {
3608
+ ...rule.when,
3609
+ eventKinds: rule.when.eventKinds ? [...rule.when.eventKinds] : void 0,
3610
+ folderIds: rule.when.folderIds ? [...rule.when.folderIds] : void 0,
3611
+ folderNames: rule.when.folderNames ? [...rule.when.folderNames] : void 0,
3612
+ meetingIds: rule.when.meetingIds ? [...rule.when.meetingIds] : void 0,
3613
+ tags: rule.when.tags ? [...rule.when.tags] : void 0,
3614
+ titleIncludes: rule.when.titleIncludes ? [...rule.when.titleIncludes] : void 0
3288
3615
  }
3289
- const token = await this.source.loadAccessToken();
3290
- this.#token = token;
3291
- await this.store.writeToken(token);
3292
- return token;
3293
- }
3294
- async invalidate() {
3295
- this.#token = void 0;
3296
- await this.store.clearToken();
3297
- }
3298
- };
3299
- var StoredSessionTokenProvider = class {
3300
- #session;
3301
- constructor(store, options = {}) {
3302
- this.store = store;
3303
- this.options = options;
3616
+ };
3617
+ }
3618
+ function cloneAction(action) {
3619
+ switch (action.kind) {
3620
+ case "agent": return { ...action };
3621
+ case "ask-user": return { ...action };
3622
+ case "command": return {
3623
+ ...action,
3624
+ args: action.args ? [...action.args] : void 0,
3625
+ env: action.env ? { ...action.env } : void 0
3626
+ };
3627
+ case "export-notes":
3628
+ case "export-transcript": return { ...action };
3304
3629
  }
3305
- async loadSession() {
3306
- if (this.#session) return this.#session;
3307
- const storedSession = await this.store.readSession();
3308
- if (storedSession?.accessToken.trim()) {
3309
- this.#session = storedSession;
3310
- return storedSession;
3630
+ }
3631
+ function stringArray(value) {
3632
+ if (!Array.isArray(value)) return;
3633
+ const values = value.filter((item) => typeof item === "string" && item.trim().length > 0);
3634
+ return values.length > 0 ? [...new Set(values.map((item) => item.trim()))] : void 0;
3635
+ }
3636
+ function stringRecord(value) {
3637
+ if (!value || typeof value !== "object" || Array.isArray(value)) return;
3638
+ const entries = Object.entries(value).filter(([key, item]) => {
3639
+ return typeof key === "string" && key.trim().length > 0 && typeof item === "string" && item.trim().length > 0;
3640
+ });
3641
+ if (entries.length === 0) return;
3642
+ return Object.fromEntries(entries.map(([key, item]) => [key.trim(), item.trim()]));
3643
+ }
3644
+ function parseAction(value, index) {
3645
+ if (!value || typeof value !== "object" || Array.isArray(value)) return;
3646
+ const record = value;
3647
+ const kind = typeof record.kind === "string" && record.kind.trim() ? record.kind.trim() : void 0;
3648
+ const id = typeof record.id === "string" && record.id.trim() ? record.id.trim() : kind ? `${kind}-${index + 1}` : void 0;
3649
+ const name = typeof record.name === "string" && record.name.trim() ? record.name.trim() : void 0;
3650
+ const enabled = typeof record.enabled === "boolean" ? record.enabled : void 0;
3651
+ switch (kind) {
3652
+ case "agent": {
3653
+ if (!id) return;
3654
+ const provider = record.provider === "codex" || record.provider === "openai" || record.provider === "openrouter" ? record.provider : void 0;
3655
+ const prompt = typeof record.prompt === "string" && record.prompt.trim() ? record.prompt.trim() : void 0;
3656
+ const promptFile = typeof record.promptFile === "string" && record.promptFile.trim() ? record.promptFile.trim() : void 0;
3657
+ const harnessId = typeof record.harnessId === "string" && record.harnessId.trim() ? record.harnessId.trim() : void 0;
3658
+ const systemPrompt = typeof record.systemPrompt === "string" && record.systemPrompt.trim() ? record.systemPrompt.trim() : void 0;
3659
+ const systemPromptFile = typeof record.systemPromptFile === "string" && record.systemPromptFile.trim() ? record.systemPromptFile.trim() : void 0;
3660
+ if (!prompt && !promptFile && !harnessId) return;
3661
+ return {
3662
+ cwd: typeof record.cwd === "string" && record.cwd.trim() ? record.cwd.trim() : void 0,
3663
+ dryRun: typeof record.dryRun === "boolean" ? record.dryRun : void 0,
3664
+ enabled,
3665
+ harnessId,
3666
+ id,
3667
+ kind,
3668
+ model: typeof record.model === "string" && record.model.trim() ? record.model.trim() : void 0,
3669
+ name,
3670
+ prompt,
3671
+ promptFile,
3672
+ provider,
3673
+ retries: typeof record.retries === "number" && Number.isFinite(record.retries) ? record.retries : typeof record.retries === "string" && /^\d+$/.test(record.retries) ? Number(record.retries) : void 0,
3674
+ systemPrompt,
3675
+ systemPromptFile,
3676
+ timeoutMs: typeof record.timeoutMs === "number" && Number.isFinite(record.timeoutMs) ? record.timeoutMs : typeof record.timeoutMs === "string" && /^\d+$/.test(record.timeoutMs) ? Number(record.timeoutMs) : void 0
3677
+ };
3311
3678
  }
3312
- if (!this.options.source) throw new Error("no stored Granola session found");
3313
- const sourcedSession = await this.options.source.loadSession();
3314
- this.#session = sourcedSession;
3315
- return sourcedSession;
3316
- }
3317
- async getAccessToken() {
3318
- return (await this.loadSession()).accessToken;
3679
+ case "ask-user": {
3680
+ const prompt = typeof record.prompt === "string" && record.prompt.trim() ? record.prompt.trim() : void 0;
3681
+ if (!id || !prompt) return;
3682
+ return {
3683
+ details: typeof record.details === "string" && record.details.trim() ? record.details.trim() : void 0,
3684
+ enabled,
3685
+ id,
3686
+ kind,
3687
+ name,
3688
+ prompt
3689
+ };
3690
+ }
3691
+ case "command": {
3692
+ const command = typeof record.command === "string" && record.command.trim() ? record.command.trim() : void 0;
3693
+ if (!id || !command) return;
3694
+ return {
3695
+ args: stringArray(record.args),
3696
+ command,
3697
+ cwd: typeof record.cwd === "string" && record.cwd.trim() ? record.cwd.trim() : void 0,
3698
+ enabled,
3699
+ env: stringRecord(record.env),
3700
+ id,
3701
+ kind,
3702
+ name,
3703
+ stdin: record.stdin === "json" || record.stdin === "none" ? record.stdin : void 0,
3704
+ timeoutMs: typeof record.timeoutMs === "number" && Number.isFinite(record.timeoutMs) ? record.timeoutMs : typeof record.timeoutMs === "string" && /^\d+$/.test(record.timeoutMs) ? Number(record.timeoutMs) : void 0
3705
+ };
3706
+ }
3707
+ case "export-notes":
3708
+ if (!id) return;
3709
+ return {
3710
+ enabled,
3711
+ format: record.format === "json" || record.format === "markdown" || record.format === "raw" || record.format === "yaml" ? record.format : void 0,
3712
+ id,
3713
+ kind,
3714
+ name,
3715
+ outputDir: typeof record.outputDir === "string" && record.outputDir.trim() ? record.outputDir.trim() : void 0,
3716
+ scopedOutput: typeof record.scopedOutput === "boolean" ? record.scopedOutput : void 0
3717
+ };
3718
+ case "export-transcript":
3719
+ if (!id) return;
3720
+ return {
3721
+ enabled,
3722
+ format: record.format === "json" || record.format === "raw" || record.format === "text" || record.format === "yaml" ? record.format : void 0,
3723
+ id,
3724
+ kind,
3725
+ name,
3726
+ outputDir: typeof record.outputDir === "string" && record.outputDir.trim() ? record.outputDir.trim() : void 0,
3727
+ scopedOutput: typeof record.scopedOutput === "boolean" ? record.scopedOutput : void 0
3728
+ };
3729
+ default: return;
3319
3730
  }
3320
- async invalidate() {
3321
- const session = await this.loadSession().catch(() => void 0);
3322
- if (session?.refreshToken && session.clientId) try {
3323
- const refreshedSession = await refreshGranolaSession(session, this.options.fetchImpl);
3324
- this.#session = refreshedSession;
3325
- await this.store.writeSession(refreshedSession);
3326
- return;
3327
- } catch {
3328
- if (!this.options.source) {
3329
- this.#session = void 0;
3330
- await this.store.clearSession();
3331
- throw new Error("failed to refresh stored Granola session");
3332
- }
3731
+ }
3732
+ function parseRule(value) {
3733
+ if (!value || typeof value !== "object" || Array.isArray(value)) return;
3734
+ const record = value;
3735
+ const id = typeof record.id === "string" && record.id.trim() ? record.id.trim() : void 0;
3736
+ const name = typeof record.name === "string" && record.name.trim() ? record.name.trim() : void 0;
3737
+ const whenValue = record.when && typeof record.when === "object" && !Array.isArray(record.when) ? record.when : void 0;
3738
+ if (!id || !name || !whenValue) return;
3739
+ return {
3740
+ actions: Array.isArray(record.actions) ? record.actions.map((action, index) => parseAction(action, index)).filter((action) => Boolean(action)) : void 0,
3741
+ enabled: typeof record.enabled === "boolean" ? record.enabled : void 0,
3742
+ id,
3743
+ name,
3744
+ when: {
3745
+ eventKinds: stringArray(whenValue.eventKinds),
3746
+ folderIds: stringArray(whenValue.folderIds),
3747
+ folderNames: stringArray(whenValue.folderNames),
3748
+ meetingIds: stringArray(whenValue.meetingIds),
3749
+ tags: stringArray(whenValue.tags),
3750
+ titleIncludes: stringArray(whenValue.titleIncludes),
3751
+ titleMatches: typeof whenValue.titleMatches === "string" && whenValue.titleMatches.trim() ? whenValue.titleMatches.trim() : void 0,
3752
+ transcriptLoaded: typeof whenValue.transcriptLoaded === "boolean" ? whenValue.transcriptLoaded : void 0
3333
3753
  }
3334
- if (this.options.source) {
3335
- const sourcedSession = await this.options.source.loadSession();
3336
- this.#session = sourcedSession;
3337
- await this.store.writeSession(sourcedSession);
3338
- return;
3754
+ };
3755
+ }
3756
+ var FileAutomationRuleStore = class {
3757
+ constructor(filePath = defaultAutomationRulesFilePath()) {
3758
+ this.filePath = filePath;
3759
+ }
3760
+ async readRules() {
3761
+ try {
3762
+ const parsed = parseJsonString(await readFile(this.filePath, "utf8"));
3763
+ return (Array.isArray(parsed) ? parsed : parsed && typeof parsed === "object" && Array.isArray(parsed.rules) ? parsed.rules : []).map((rule) => parseRule(rule)).filter((rule) => Boolean(rule)).map(cloneRule);
3764
+ } catch {
3765
+ return [];
3339
3766
  }
3340
- this.#session = void 0;
3341
- await this.store.clearSession();
3342
3767
  }
3343
3768
  };
3344
- async function refreshGranolaSession(session, fetchImpl = fetch) {
3345
- if (!session.refreshToken?.trim()) throw new Error("refresh token not available");
3346
- const response = await fetchImpl(WORKOS_AUTH_URL, {
3347
- body: JSON.stringify({
3348
- client_id: session.clientId,
3349
- grant_type: "refresh_token",
3350
- refresh_token: session.refreshToken
3351
- }),
3352
- headers: { "Content-Type": "application/json" },
3353
- method: "POST"
3354
- });
3355
- if (!response.ok) throw new Error(`failed to refresh session: ${response.status} ${response.statusText}`);
3356
- const refreshed = parseSessionRecord(await response.json());
3357
- if (!refreshed) throw new Error("failed to parse refreshed session");
3358
- return {
3359
- ...session,
3360
- ...refreshed,
3361
- clientId: refreshed.clientId || session.clientId,
3362
- obtainedAt: refreshed.obtainedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
3363
- refreshToken: refreshed.refreshToken ?? session.refreshToken
3364
- };
3769
+ function defaultAutomationRulesFilePath() {
3770
+ return defaultGranolaToolkitPersistenceLayout().automationRulesFile;
3365
3771
  }
3366
- function defaultSessionFilePath() {
3367
- return defaultGranolaToolkitPersistenceLayout().sessionFile;
3772
+ function includesIgnoreCase(candidate, values) {
3773
+ const lowerCandidate = candidate.toLowerCase();
3774
+ return values.some((value) => lowerCandidate.includes(value.toLowerCase()));
3368
3775
  }
3369
- function defaultApiKeyFilePath() {
3370
- return defaultGranolaToolkitPersistenceLayout().apiKeyFile;
3776
+ function matchesRule(rule, event) {
3777
+ if (rule.enabled === false) return false;
3778
+ const { when } = rule;
3779
+ if (when.eventKinds?.length && !when.eventKinds.includes(event.kind)) return false;
3780
+ if (when.meetingIds?.length && !when.meetingIds.includes(event.meetingId)) return false;
3781
+ if (when.folderIds?.length && !event.folders.some((folder) => when.folderIds?.includes(folder.id))) return false;
3782
+ if (when.folderNames?.length && !event.folders.some((folder) => when.folderNames?.includes(folder.name))) return false;
3783
+ if (when.tags?.length && !event.tags.some((tag) => when.tags?.includes(tag))) return false;
3784
+ if (when.titleIncludes?.length && !includesIgnoreCase(event.title, when.titleIncludes)) return false;
3785
+ if (when.titleMatches) try {
3786
+ if (!new RegExp(when.titleMatches, "i").test(event.title)) return false;
3787
+ } catch {
3788
+ return false;
3789
+ }
3790
+ if (typeof when.transcriptLoaded === "boolean" && when.transcriptLoaded !== event.transcriptLoaded) return false;
3791
+ return true;
3371
3792
  }
3372
- function createDefaultSessionStore() {
3373
- return defaultGranolaToolkitPersistenceLayout().sessionStoreKind === "keychain" ? new KeychainSessionStore() : new FileSessionStore();
3793
+ function matchAutomationRules(rules, events, matchedAt) {
3794
+ const matches = [];
3795
+ for (const event of events) for (const rule of rules) {
3796
+ if (!matchesRule(rule, event)) continue;
3797
+ matches.push({
3798
+ eventId: event.id,
3799
+ eventKind: event.kind,
3800
+ folders: event.folders.map((folder) => ({ ...folder })),
3801
+ id: `${event.id}:${rule.id}`,
3802
+ matchedAt,
3803
+ meetingId: event.meetingId,
3804
+ ruleId: rule.id,
3805
+ ruleName: rule.name,
3806
+ tags: [...event.tags],
3807
+ title: event.title,
3808
+ transcriptLoaded: event.transcriptLoaded
3809
+ });
3810
+ }
3811
+ return matches;
3374
3812
  }
3375
- function createDefaultApiKeyStore() {
3376
- return defaultGranolaToolkitPersistenceLayout().sessionStoreKind === "keychain" ? new KeychainApiKeyStore() : new FileApiKeyStore();
3813
+ function createDefaultAutomationRuleStore(filePath) {
3814
+ return new FileAutomationRuleStore(filePath);
3815
+ }
3816
+ //#endregion
3817
+ //#region src/cache.ts
3818
+ function parseCacheDocument(id, value) {
3819
+ const record = asRecord(value);
3820
+ if (!record) return;
3821
+ return {
3822
+ createdAt: stringValue(record.created_at),
3823
+ id,
3824
+ title: stringValue(record.title),
3825
+ updatedAt: stringValue(record.updated_at)
3826
+ };
3827
+ }
3828
+ function parseTranscriptSegments(value) {
3829
+ if (!Array.isArray(value)) return;
3830
+ return value.flatMap((segment) => {
3831
+ const record = asRecord(segment);
3832
+ if (!record) return [];
3833
+ return [{
3834
+ documentId: stringValue(record.document_id),
3835
+ endTimestamp: stringValue(record.end_timestamp),
3836
+ id: stringValue(record.id),
3837
+ isFinal: Boolean(record.is_final),
3838
+ source: stringValue(record.source),
3839
+ startTimestamp: stringValue(record.start_timestamp),
3840
+ text: stringValue(record.text)
3841
+ }];
3842
+ });
3843
+ }
3844
+ function parseCacheContents(contents) {
3845
+ const outer = parseJsonString(contents);
3846
+ if (!outer) throw new Error("failed to parse cache JSON");
3847
+ const rawCache = outer.cache;
3848
+ let cachePayload;
3849
+ if (typeof rawCache === "string") cachePayload = parseJsonString(rawCache);
3850
+ else cachePayload = asRecord(rawCache);
3851
+ const state = cachePayload ? asRecord(cachePayload.state) : void 0;
3852
+ if (!state) throw new Error("failed to parse cache state");
3853
+ const rawDocuments = asRecord(state.documents) ?? {};
3854
+ const rawTranscripts = asRecord(state.transcripts) ?? {};
3855
+ const documents = {};
3856
+ for (const [id, rawDocument] of Object.entries(rawDocuments)) {
3857
+ const document = parseCacheDocument(id, rawDocument);
3858
+ if (document) documents[id] = document;
3859
+ }
3860
+ const transcripts = {};
3861
+ for (const [id, rawTranscript] of Object.entries(rawTranscripts)) {
3862
+ const segments = parseTranscriptSegments(rawTranscript);
3863
+ if (segments) transcripts[id] = segments;
3864
+ }
3865
+ return {
3866
+ documents,
3867
+ transcripts
3868
+ };
3377
3869
  }
3378
3870
  //#endregion
3379
3871
  //#region src/client/default-auth.ts
@@ -3562,17 +4054,39 @@ function parseLastViewedPanel(value) {
3562
4054
  updatedAt: stringValue(panel.updated_at)
3563
4055
  };
3564
4056
  }
4057
+ function parseCalendarEvent(value) {
4058
+ const record = asRecord(value);
4059
+ if (!record) return;
4060
+ const id = stringValue(record.id);
4061
+ const recurringEventId = stringValue(record.recurring_event_id) || stringValue(record.recurringEventId);
4062
+ const calendarId = stringValue(record.calendar_id) || stringValue(record.calendarId);
4063
+ const url = stringValue(record.url);
4064
+ const htmlLink = stringValue(record.html_link) || stringValue(record.htmlLink);
4065
+ const startTime = stringValue(record.start_time) || stringValue(record.startTime);
4066
+ const endTime = stringValue(record.end_time) || stringValue(record.endTime);
4067
+ if (!id && !recurringEventId && !calendarId && !url && !htmlLink && !startTime && !endTime) return;
4068
+ return {
4069
+ calendarId: calendarId || void 0,
4070
+ endTime: endTime || void 0,
4071
+ htmlLink: htmlLink || void 0,
4072
+ id: id || void 0,
4073
+ recurringEventId: recurringEventId || void 0,
4074
+ startTime: startTime || void 0,
4075
+ url: url || void 0
4076
+ };
4077
+ }
3565
4078
  function parseDocument(value) {
3566
4079
  const record = asRecord(value);
3567
4080
  if (!record) throw new Error("document payload is not an object");
3568
4081
  return {
4082
+ calendarEvent: parseCalendarEvent(record.google_calendar_event),
3569
4083
  content: stringValue(record.content),
3570
4084
  createdAt: stringValue(record.created_at),
3571
4085
  id: stringValue(record.id),
3572
4086
  lastViewedPanel: parseLastViewedPanel(record.last_viewed_panel),
3573
4087
  notes: parseProseMirrorDoc(record.notes),
3574
4088
  notesPlain: stringValue(record.notes_plain),
3575
- tags: stringArray$1(record.tags),
4089
+ tags: stringArray$2(record.tags),
3576
4090
  title: stringValue(record.title),
3577
4091
  updatedAt: stringValue(record.updated_at)
3578
4092
  };
@@ -3623,6 +4137,7 @@ function parsePublicNote(value) {
3623
4137
  const summaryMarkdown = stringValue(record.summary_markdown);
3624
4138
  const summaryText = stringValue(record.summary_text);
3625
4139
  return {
4140
+ calendarEvent: parseCalendarEvent(record.google_calendar_event),
3626
4141
  content: summaryMarkdown || summaryText,
3627
4142
  createdAt: stringValue(record.created_at),
3628
4143
  folderMemberships: Array.isArray(record.folder_membership) ? record.folder_membership.map(parseFolderMembership).filter((membership) => Boolean(membership)) : [],
@@ -3829,97 +4344,6 @@ var GranolaPublicApiClient = class {
3829
4344
  }
3830
4345
  };
3831
4346
  //#endregion
3832
- //#region src/client/http.ts
3833
- const RETRYABLE_STATUS_CODES = new Set([
3834
- 429,
3835
- 500,
3836
- 502,
3837
- 503,
3838
- 504
3839
- ]);
3840
- function sleep(delayMs) {
3841
- return new Promise((resolve) => {
3842
- setTimeout(resolve, delayMs);
3843
- });
3844
- }
3845
- function parseRetryAfter(headerValue) {
3846
- if (!headerValue?.trim()) return;
3847
- if (/^\d+$/.test(headerValue.trim())) return Number(headerValue.trim()) * 1e3;
3848
- const retryAt = Date.parse(headerValue);
3849
- if (Number.isNaN(retryAt)) return;
3850
- return Math.max(0, retryAt - Date.now());
3851
- }
3852
- var AuthenticatedHttpClient = class {
3853
- fetchImpl;
3854
- constructor(options) {
3855
- this.fetchImpl = options.fetchImpl ?? fetch;
3856
- this.logger = options.logger;
3857
- this.maxRetries = options.maxRetries ?? 2;
3858
- this.retryBaseDelayMs = options.retryBaseDelayMs ?? 500;
3859
- this.retryMaxDelayMs = options.retryMaxDelayMs ?? 5e3;
3860
- this.sleepImpl = options.sleepImpl ?? sleep;
3861
- this.tokenProvider = options.tokenProvider;
3862
- }
3863
- logger;
3864
- maxRetries;
3865
- retryBaseDelayMs;
3866
- retryMaxDelayMs;
3867
- sleepImpl;
3868
- tokenProvider;
3869
- async retry(options, attempt, reason, response) {
3870
- const retryAfterMs = parseRetryAfter(response?.headers.get("retry-after") ?? null);
3871
- const delayMs = Math.min(retryAfterMs ?? this.retryBaseDelayMs * 2 ** attempt, this.retryMaxDelayMs);
3872
- this.logger?.warn?.(`${reason}; retrying in ${delayMs}ms (${attempt + 1}/${this.maxRetries})`);
3873
- await this.sleepImpl(delayMs);
3874
- return this.request(options, attempt + 1);
3875
- }
3876
- async request(options, attempt = 0) {
3877
- const { retryOnUnauthorized = true, timeoutMs, url } = options;
3878
- const accessToken = await this.tokenProvider.getAccessToken();
3879
- let response;
3880
- try {
3881
- response = await this.fetchImpl(url, {
3882
- body: options.body,
3883
- headers: {
3884
- ...options.headers,
3885
- Authorization: `Bearer ${accessToken}`
3886
- },
3887
- method: options.method ?? "GET",
3888
- signal: AbortSignal.timeout(timeoutMs)
3889
- });
3890
- } catch (error) {
3891
- if (attempt < this.maxRetries) {
3892
- const message = error instanceof Error ? error.message : String(error);
3893
- return this.retry(options, attempt, `request failed: ${message}`);
3894
- }
3895
- throw error;
3896
- }
3897
- if (response.status === 401 && retryOnUnauthorized) {
3898
- this.logger?.warn?.("request returned 401; invalidating token provider and retrying once");
3899
- await this.tokenProvider.invalidate();
3900
- return this.request({
3901
- ...options,
3902
- retryOnUnauthorized: false
3903
- }, attempt);
3904
- }
3905
- if (RETRYABLE_STATUS_CODES.has(response.status) && attempt < this.maxRetries) return this.retry(options, attempt, `request returned ${response.status} ${response.statusText || ""}`.trim(), response);
3906
- return response;
3907
- }
3908
- async postJson(url, body, options = { timeoutMs: 3e4 }) {
3909
- return this.request({
3910
- ...options,
3911
- body: JSON.stringify(body),
3912
- headers: {
3913
- Accept: "*/*",
3914
- "Content-Type": "application/json",
3915
- ...options.headers
3916
- },
3917
- method: "POST",
3918
- url
3919
- });
3920
- }
3921
- };
3922
- //#endregion
3923
4347
  //#region src/client/default.ts
3924
4348
  async function createDefaultGranolaRuntime(config, logger = console, options = {}) {
3925
4349
  const auth = await inspectDefaultGranolaAuth(config, { preferredMode: options.preferredMode });
@@ -4628,6 +5052,62 @@ function cloneMeetingSummary(meeting) {
4628
5052
  tags: [...meeting.tags]
4629
5053
  };
4630
5054
  }
5055
+ function resolveActionFilePath(filePath, cwd) {
5056
+ return cwd ? resolve(cwd, filePath) : resolve(filePath);
5057
+ }
5058
+ async function readOptionalActionFile(filePath, cwd) {
5059
+ if (!filePath) return;
5060
+ return await readFile(resolveActionFilePath(filePath, cwd), "utf8");
5061
+ }
5062
+ function combinePromptSections(...values) {
5063
+ const sections = values.map((value) => value?.trim()).filter((value) => Boolean(value));
5064
+ return sections.length > 0 ? sections.join("\n\n") : void 0;
5065
+ }
5066
+ function meetingTranscriptText(bundle) {
5067
+ const segments = bundle.document.transcriptSegments ?? (bundle.cacheData ? bundle.cacheData.transcripts[bundle.document.id] : void 0);
5068
+ if (!segments?.length) return;
5069
+ return segments.slice().sort((left, right) => left.startTimestamp.localeCompare(right.startTimestamp)).map((segment) => segment.text.trim()).filter(Boolean).join("\n");
5070
+ }
5071
+ function buildAutomationAgentPrompt(match, rule, instructions, bundle) {
5072
+ const transcriptText = bundle ? meetingTranscriptText(bundle)?.trim() : void 0;
5073
+ const context = {
5074
+ event: {
5075
+ id: match.eventId,
5076
+ kind: match.eventKind,
5077
+ matchedAt: match.matchedAt,
5078
+ meetingId: match.meetingId,
5079
+ transcriptLoaded: match.transcriptLoaded
5080
+ },
5081
+ folders: match.folders.map((folder) => ({
5082
+ id: folder.id,
5083
+ name: folder.name
5084
+ })),
5085
+ meeting: bundle ? {
5086
+ id: bundle.document.id,
5087
+ notesPlain: bundle.document.notesPlain,
5088
+ tags: [...bundle.document.tags],
5089
+ title: bundle.document.title,
5090
+ updatedAt: bundle.document.updatedAt
5091
+ } : {
5092
+ id: match.meetingId,
5093
+ tags: [...match.tags],
5094
+ title: match.title
5095
+ },
5096
+ rule: {
5097
+ id: rule.id,
5098
+ name: rule.name
5099
+ }
5100
+ };
5101
+ return [
5102
+ instructions.trim(),
5103
+ "Meeting context (JSON):",
5104
+ "```json",
5105
+ JSON.stringify(context, null, 2),
5106
+ "```",
5107
+ bundle?.document.notesPlain?.trim() ? `Existing notes:\n${bundle.document.notesPlain.trim()}` : "",
5108
+ transcriptText ? `Transcript:\n${transcriptText}` : ""
5109
+ ].filter(Boolean).join("\n\n");
5110
+ }
4631
5111
  function cloneState(state) {
4632
5112
  return {
4633
5113
  auth: { ...state.auth },
@@ -4636,6 +5116,7 @@ function cloneState(state) {
4636
5116
  config: {
4637
5117
  ...state.config,
4638
5118
  automation: state.config.automation ? { ...state.config.automation } : void 0,
5119
+ agents: state.config.agents ? { ...state.config.agents } : void 0,
4639
5120
  notes: { ...state.config.notes },
4640
5121
  transcripts: { ...state.config.transcripts }
4641
5122
  },
@@ -4673,6 +5154,7 @@ function defaultState(config, auth, surface) {
4673
5154
  },
4674
5155
  config: {
4675
5156
  ...config,
5157
+ agents: config.agents ? { ...config.agents } : void 0,
4676
5158
  notes: { ...config.notes },
4677
5159
  transcripts: { ...config.transcripts }
4678
5160
  },
@@ -4825,6 +5307,7 @@ var GranolaApp = class {
4825
5307
  ...rule,
4826
5308
  actions: rule.actions?.map((action) => {
4827
5309
  switch (action.kind) {
5310
+ case "agent": return { ...action };
4828
5311
  case "ask-user": return { ...action };
4829
5312
  case "command": return {
4830
5313
  ...action,
@@ -5373,6 +5856,31 @@ var GranolaApp = class {
5373
5856
  child.stdin.end();
5374
5857
  });
5375
5858
  }
5859
+ async runAutomationAgent(match, rule, action) {
5860
+ const bundle = match.eventKind === "meeting.removed" ? void 0 : await this.maybeReadMeetingBundleById(match.meetingId, { requireCache: false });
5861
+ const harness = resolveAgentHarness(this.deps.agentHarnessStore ? await this.deps.agentHarnessStore.readHarnesses() : [], {
5862
+ bundle,
5863
+ match
5864
+ }, action.harnessId);
5865
+ const harnessCwd = harness?.cwd;
5866
+ const promptFile = await readOptionalActionFile(action.promptFile, action.cwd ?? harnessCwd);
5867
+ const harnessPromptFile = await readOptionalActionFile(harness?.promptFile, harnessCwd);
5868
+ const systemPromptFile = await readOptionalActionFile(action.systemPromptFile, action.cwd ?? harnessCwd);
5869
+ const harnessSystemPromptFile = await readOptionalActionFile(harness?.systemPromptFile, harnessCwd);
5870
+ const instructions = combinePromptSections(harnessPromptFile, harness?.prompt, promptFile, action.prompt);
5871
+ if (!instructions) throw new Error(`automation agent action ${action.id} is missing prompt instructions`);
5872
+ const request = {
5873
+ cwd: action.cwd ?? harnessCwd,
5874
+ dryRun: action.dryRun,
5875
+ model: action.model ?? harness?.model,
5876
+ prompt: buildAutomationAgentPrompt(match, rule, instructions, bundle),
5877
+ provider: action.provider ?? harness?.provider,
5878
+ retries: action.retries,
5879
+ systemPrompt: combinePromptSections(harnessSystemPromptFile, harness?.systemPrompt, systemPromptFile, action.systemPrompt),
5880
+ timeoutMs: action.timeoutMs
5881
+ };
5882
+ return await (this.deps.agentRunner ?? createDefaultAutomationAgentRunner(this.config)).run(request);
5883
+ }
5376
5884
  async runAutomationActions(rules, matches) {
5377
5885
  const rulesById = new Map(rules.map((rule) => [rule.id, rule]));
5378
5886
  const existingRunIds = new Set(this.#automationActionRuns.map((run) => run.id));
@@ -5388,6 +5896,7 @@ var GranolaApp = class {
5388
5896
  exportNotes: async (nextMatch, nextAction) => await this.runAutomationNotesAction(nextMatch, nextAction),
5389
5897
  exportTranscripts: async (nextMatch, nextAction) => await this.runAutomationTranscriptAction(nextMatch, nextAction),
5390
5898
  nowIso: () => this.nowIso(),
5899
+ runAgent: async (nextMatch, nextRule, nextAction) => await this.runAutomationAgent(nextMatch, nextRule, nextAction),
5391
5900
  runCommand: async (nextMatch, nextRule, nextAction) => await this.runAutomationCommand(nextMatch, nextRule, nextAction)
5392
5901
  }));
5393
5902
  }
@@ -5814,6 +6323,7 @@ async function createGranolaApp(config, options = {}) {
5814
6323
  const automationRuns = await automationRunStore.readRuns({ limit: 0 });
5815
6324
  const automationRuleStore = createDefaultAutomationRuleStore(config.automation?.rulesFile ?? defaultAutomationRulesFilePath());
5816
6325
  const automationRules = await automationRuleStore.readRules();
6326
+ const agentHarnessStore = createDefaultAgentHarnessStore(config.agents?.harnessesFile);
5817
6327
  const authController = createDefaultGranolaAuthController(config);
5818
6328
  const exportJobStore = createDefaultExportJobStore();
5819
6329
  const exportJobs = await exportJobStore.readJobs();
@@ -5826,6 +6336,8 @@ async function createGranolaApp(config, options = {}) {
5826
6336
  const syncState = await syncStateStore.readState();
5827
6337
  return new GranolaApp(config, {
5828
6338
  auth,
6339
+ agentRunner: createDefaultAutomationAgentRunner(config),
6340
+ agentHarnessStore,
5829
6341
  authController,
5830
6342
  automationMatches,
5831
6343
  automationMatchStore,
@@ -5855,6 +6367,10 @@ function pickString(value) {
5855
6367
  function pickBoolean(value) {
5856
6368
  return typeof value === "boolean" ? value : void 0;
5857
6369
  }
6370
+ function pickNumber(value) {
6371
+ if (typeof value === "number" && Number.isFinite(value)) return value;
6372
+ if (typeof value === "string" && /^-?\d+$/.test(value.trim())) return Number(value.trim());
6373
+ }
5858
6374
  function parseTomlScalar(rawValue) {
5859
6375
  const value = rawValue.trim();
5860
6376
  if (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'")) {
@@ -5907,9 +6423,24 @@ async function loadConfig(options) {
5907
6423
  const configValues = config.values;
5908
6424
  const defaultSupabase = firstExistingPath(granolaSupabaseCandidates());
5909
6425
  const defaultCache = firstExistingPath(granolaCacheCandidates());
6426
+ const agentTimeoutValue = pickString(env.GRANOLA_AGENT_TIMEOUT) ?? pickString(configValues["agent-timeout"]) ?? pickString(configValues.agentTimeout) ?? "5m";
5910
6427
  const timeoutValue = pickString(options.subcommandFlags.timeout) ?? pickString(env.TIMEOUT) ?? pickString(configValues.timeout) ?? "2m";
5911
6428
  return {
5912
6429
  automation: { rulesFile: pickString(options.globalFlags.rules) ?? pickString(env.GRANOLA_AUTOMATION_RULES_FILE) ?? pickString(configValues["automation-rules-file"]) ?? pickString(configValues.automationRulesFile) ?? defaultGranolaToolkitPersistenceLayout().automationRulesFile },
6430
+ agents: {
6431
+ codexCommand: pickString(env.GRANOLA_CODEX_COMMAND) ?? pickString(configValues["codex-command"]) ?? pickString(configValues.codexCommand) ?? "codex",
6432
+ defaultModel: pickString(env.GRANOLA_AGENT_MODEL) ?? pickString(configValues["agent-model"]) ?? pickString(configValues.agentModel),
6433
+ defaultProvider: (() => {
6434
+ const value = pickString(env.GRANOLA_AGENT_PROVIDER) ?? pickString(configValues["agent-provider"]) ?? pickString(configValues.agentProvider);
6435
+ return value === "codex" || value === "openai" || value === "openrouter" ? value : void 0;
6436
+ })(),
6437
+ dryRun: envFlag(env.GRANOLA_AGENT_DRY_RUN) ?? pickBoolean(configValues["agent-dry-run"]) ?? pickBoolean(configValues.agentDryRun) ?? false,
6438
+ harnessesFile: pickString(env.GRANOLA_AGENT_HARNESSES_FILE) ?? pickString(configValues["agent-harnesses-file"]) ?? pickString(configValues.agentHarnessesFile) ?? defaultGranolaToolkitPersistenceLayout().agentHarnessesFile,
6439
+ maxRetries: pickNumber(env.GRANOLA_AGENT_MAX_RETRIES) ?? pickNumber(configValues["agent-max-retries"]) ?? pickNumber(configValues.agentMaxRetries) ?? 2,
6440
+ openaiBaseUrl: pickString(env.GRANOLA_OPENAI_BASE_URL) ?? pickString(env.OPENAI_BASE_URL) ?? pickString(configValues["openai-base-url"]) ?? pickString(configValues.openaiBaseUrl) ?? "https://api.openai.com/v1",
6441
+ openrouterBaseUrl: pickString(env.GRANOLA_OPENROUTER_BASE_URL) ?? pickString(env.OPENROUTER_BASE_URL) ?? pickString(configValues["openrouter-base-url"]) ?? pickString(configValues.openrouterBaseUrl) ?? "https://openrouter.ai/api/v1",
6442
+ timeoutMs: parseDuration(agentTimeoutValue)
6443
+ },
5913
6444
  apiKey: pickString(options.globalFlags["api-key"]) ?? pickString(env.GRANOLA_API_KEY) ?? pickString(configValues["api-key"]) ?? pickString(configValues.apiKey),
5914
6445
  configFileUsed: config.path,
5915
6446
  debug: pickBoolean(options.globalFlags.debug) ?? envFlag(env.DEBUG_MODE) ?? pickBoolean(configValues.debug) ?? false,