granola-toolkit 0.46.1 → 0.47.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 +1128 -776
  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 = {}) {
@@ -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");
@@ -2748,632 +2627,1103 @@ function defaultGranolaToolkitPersistenceLayout(options = {}) {
2748
2627
  };
2749
2628
  }
2750
2629
  //#endregion
2751
- //#region src/automation-matches.ts
2752
- function cloneMatch(match) {
2630
+ //#region src/client/auth.ts
2631
+ const execFileAsync$1 = promisify(execFile);
2632
+ const DEFAULT_CLIENT_ID = "client_GranolaMac";
2633
+ const KEYCHAIN_SERVICE_NAME = "com.granola.toolkit";
2634
+ const KEYCHAIN_ACCOUNT_NAME_API_KEY = "api-key";
2635
+ const KEYCHAIN_ACCOUNT_NAME = "session";
2636
+ const WORKOS_AUTH_URL = "https://api.workos.com/user_management/authenticate";
2637
+ function numberValue(value) {
2638
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
2639
+ }
2640
+ function parseSessionRecord(record) {
2641
+ const accessToken = stringValue(record.access_token);
2642
+ if (!accessToken.trim()) return;
2753
2643
  return {
2754
- ...match,
2755
- folders: match.folders.map((folder) => ({ ...folder })),
2756
- tags: [...match.tags]
2644
+ accessToken,
2645
+ clientId: stringValue(record.client_id) || DEFAULT_CLIENT_ID,
2646
+ expiresIn: numberValue(record.expires_in),
2647
+ externalId: stringValue(record.external_id) || void 0,
2648
+ obtainedAt: stringValue(record.obtained_at) || void 0,
2649
+ refreshToken: stringValue(record.refresh_token) || void 0,
2650
+ sessionId: stringValue(record.session_id) || void 0,
2651
+ signInMethod: stringValue(record.sign_in_method) || void 0,
2652
+ tokenType: stringValue(record.token_type) || void 0
2757
2653
  };
2758
2654
  }
2759
- var FileAutomationMatchStore = class {
2760
- constructor(filePath = defaultAutomationMatchesFilePath()) {
2655
+ function parseNestedRecord(value) {
2656
+ if (typeof value === "string") return parseJsonString(value);
2657
+ return asRecord(value);
2658
+ }
2659
+ function getSessionFromSupabaseContents(supabaseContents) {
2660
+ const wrapper = parseJsonString(supabaseContents);
2661
+ if (!wrapper) throw new Error("failed to parse supabase.json");
2662
+ const workOsSession = parseSessionRecord(parseNestedRecord(wrapper.workos_tokens) ?? {});
2663
+ if (workOsSession) return workOsSession;
2664
+ const cognitoSession = parseSessionRecord(parseNestedRecord(wrapper.cognito_tokens) ?? {});
2665
+ if (cognitoSession) return cognitoSession;
2666
+ const legacySession = parseSessionRecord(wrapper);
2667
+ if (legacySession) return legacySession;
2668
+ throw new Error("access token not found in supabase.json");
2669
+ }
2670
+ function getAccessTokenFromSupabaseContents(supabaseContents) {
2671
+ return getSessionFromSupabaseContents(supabaseContents).accessToken;
2672
+ }
2673
+ var SupabaseFileTokenSource = class {
2674
+ constructor(filePath) {
2761
2675
  this.filePath = filePath;
2762
2676
  }
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
- });
2677
+ async loadAccessToken() {
2678
+ return getAccessTokenFromSupabaseContents(await readFile(this.filePath, "utf8"));
2771
2679
  }
2772
- async readMatches(limit = 50) {
2773
- 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();
2776
- } catch {
2777
- return [];
2778
- }
2680
+ };
2681
+ var SupabaseFileSessionSource = class {
2682
+ constructor(filePath) {
2683
+ this.filePath = filePath;
2684
+ }
2685
+ async loadSession() {
2686
+ return getSessionFromSupabaseContents(await readFile(this.filePath, "utf8"));
2779
2687
  }
2780
2688
  };
2781
- function defaultAutomationMatchesFilePath() {
2782
- return defaultGranolaToolkitPersistenceLayout().automationMatchesFile;
2783
- }
2784
- function createDefaultAutomationMatchStore(filePath) {
2785
- return new FileAutomationMatchStore(filePath);
2786
- }
2787
- //#endregion
2788
- //#region src/automation-runs.ts
2789
- function cloneRun(run) {
2790
- return {
2791
- ...run,
2792
- folders: run.folders.map((folder) => ({ ...folder })),
2793
- meta: run.meta ? structuredClone(run.meta) : void 0,
2794
- tags: [...run.tags]
2795
- };
2796
- }
2797
- function sortRuns(runs) {
2798
- return runs.slice().sort((left, right) => (right.finishedAt ?? right.startedAt).localeCompare(left.finishedAt ?? left.startedAt));
2799
- }
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()]);
2804
- }
2805
- var FileAutomationRunStore = class {
2806
- constructor(filePath = defaultAutomationRunsFilePath()) {
2689
+ var NoopTokenStore = class {
2690
+ async clearToken() {}
2691
+ async readToken() {}
2692
+ async writeToken(_token) {}
2693
+ };
2694
+ var FileSessionStore = class {
2695
+ constructor(filePath = defaultSessionFilePath()) {
2807
2696
  this.filePath = filePath;
2808
2697
  }
2809
- async appendRuns(runs) {
2810
- if (runs.length === 0) return;
2698
+ async clearSession() {
2699
+ try {
2700
+ await unlink(this.filePath);
2701
+ } catch {}
2702
+ }
2703
+ async readSession() {
2704
+ try {
2705
+ const parsed = parseJsonString(await readFile(this.filePath, "utf8"));
2706
+ return parsed?.accessToken ? parsed : void 0;
2707
+ } catch {
2708
+ return;
2709
+ }
2710
+ }
2711
+ async writeSession(session) {
2811
2712
  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`, {
2713
+ await writeFile(this.filePath, `${JSON.stringify(session, null, 2)}\n`, {
2814
2714
  encoding: "utf8",
2815
2715
  mode: 384
2816
2716
  });
2817
2717
  }
2818
- async readRun(id) {
2819
- return (await this.readRuns({ limit: 0 })).find((run) => run.id === id);
2718
+ };
2719
+ var FileApiKeyStore = class {
2720
+ constructor(filePath = defaultApiKeyFilePath()) {
2721
+ this.filePath = filePath;
2820
2722
  }
2821
- async readRuns(options = {}) {
2723
+ async clearApiKey() {
2822
2724
  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);
2725
+ await unlink(this.filePath);
2726
+ } catch {}
2727
+ }
2728
+ async readApiKey() {
2729
+ try {
2730
+ return (await readFile(this.filePath, "utf8")).trim() || void 0;
2825
2731
  } catch {
2826
- return [];
2732
+ return;
2827
2733
  }
2828
2734
  }
2735
+ async writeApiKey(apiKey) {
2736
+ const trimmed = apiKey.trim();
2737
+ if (!trimmed) throw new Error("Granola API key is required");
2738
+ await mkdir(dirname(this.filePath), { recursive: true });
2739
+ await writeFile(this.filePath, `${trimmed}\n`, {
2740
+ encoding: "utf8",
2741
+ mode: 384
2742
+ });
2743
+ }
2829
2744
  };
2830
- function defaultAutomationRunsFilePath() {
2831
- return defaultGranolaToolkitPersistenceLayout().automationRunsFile;
2832
- }
2833
- function createDefaultAutomationRunStore(filePath) {
2834
- return new FileAutomationRunStore(filePath);
2835
- }
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
2850
- }
2851
- };
2852
- }
2853
- function cloneAction(action) {
2854
- switch (action.kind) {
2855
- case "ask-user": return { ...action };
2856
- case "command": return {
2857
- ...action,
2858
- args: action.args ? [...action.args] : void 0,
2859
- env: action.env ? { ...action.env } : void 0
2860
- };
2861
- case "export-notes":
2862
- case "export-transcript": return { ...action };
2745
+ var KeychainSessionStore = class {
2746
+ async clearSession() {
2747
+ try {
2748
+ await execFileAsync$1("security", [
2749
+ "delete-generic-password",
2750
+ "-s",
2751
+ KEYCHAIN_SERVICE_NAME,
2752
+ "-a",
2753
+ KEYCHAIN_ACCOUNT_NAME
2754
+ ]);
2755
+ } catch {}
2863
2756
  }
2864
- }
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
- };
2757
+ async readSession() {
2758
+ try {
2759
+ const { stdout } = await execFileAsync$1("security", [
2760
+ "find-generic-password",
2761
+ "-s",
2762
+ KEYCHAIN_SERVICE_NAME,
2763
+ "-a",
2764
+ KEYCHAIN_ACCOUNT_NAME,
2765
+ "-w"
2766
+ ]);
2767
+ const parsed = parseJsonString(stdout.trim());
2768
+ return parsed?.accessToken ? parsed : void 0;
2769
+ } catch {
2770
+ return;
2913
2771
  }
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
2772
  }
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;
2773
+ async writeSession(session) {
2774
+ await execFileAsync$1("security", [
2775
+ "add-generic-password",
2776
+ "-U",
2777
+ "-s",
2778
+ KEYCHAIN_SERVICE_NAME,
2779
+ "-a",
2780
+ KEYCHAIN_ACCOUNT_NAME,
2781
+ "-w",
2782
+ JSON.stringify(session)
2783
+ ]);
2966
2784
  }
2967
- async readRules() {
2785
+ };
2786
+ var KeychainApiKeyStore = class {
2787
+ async clearApiKey() {
2968
2788
  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);
2789
+ await execFileAsync$1("security", [
2790
+ "delete-generic-password",
2791
+ "-s",
2792
+ KEYCHAIN_SERVICE_NAME,
2793
+ "-a",
2794
+ KEYCHAIN_ACCOUNT_NAME_API_KEY
2795
+ ]);
2796
+ } catch {}
2797
+ }
2798
+ async readApiKey() {
2799
+ try {
2800
+ const { stdout } = await execFileAsync$1("security", [
2801
+ "find-generic-password",
2802
+ "-s",
2803
+ KEYCHAIN_SERVICE_NAME,
2804
+ "-a",
2805
+ KEYCHAIN_ACCOUNT_NAME_API_KEY,
2806
+ "-w"
2807
+ ]);
2808
+ return stdout.trim() || void 0;
2971
2809
  } catch {
2972
- return [];
2810
+ return;
2973
2811
  }
2974
2812
  }
2813
+ async writeApiKey(apiKey) {
2814
+ const trimmed = apiKey.trim();
2815
+ if (!trimmed) throw new Error("Granola API key is required");
2816
+ await execFileAsync$1("security", [
2817
+ "add-generic-password",
2818
+ "-U",
2819
+ "-s",
2820
+ KEYCHAIN_SERVICE_NAME,
2821
+ "-a",
2822
+ KEYCHAIN_ACCOUNT_NAME_API_KEY,
2823
+ "-w",
2824
+ trimmed
2825
+ ]);
2826
+ }
2975
2827
  };
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;
2828
+ var CachedTokenProvider = class {
2829
+ #token;
2830
+ constructor(source, store = new NoopTokenStore()) {
2831
+ this.source = source;
2832
+ this.store = store;
2996
2833
  }
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);
3022
- }
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
- };
3034
- }
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
- });
3050
- }
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
- }
3072
- return {
3073
- documents,
3074
- transcripts
3075
- };
3076
- }
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;
3087
- }
3088
- function parseSessionRecord(record) {
3089
- const accessToken = stringValue(record.access_token);
3090
- if (!accessToken.trim()) return;
3091
- 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
3101
- };
3102
- }
3103
- function parseNestedRecord(value) {
3104
- if (typeof value === "string") return parseJsonString(value);
3105
- return asRecord(value);
3106
- }
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");
3117
- }
3118
- function getAccessTokenFromSupabaseContents(supabaseContents) {
3119
- return getSessionFromSupabaseContents(supabaseContents).accessToken;
3120
- }
3121
- var SupabaseFileTokenSource = class {
3122
- constructor(filePath) {
3123
- this.filePath = filePath;
2834
+ async getAccessToken() {
2835
+ if (this.#token) return this.#token;
2836
+ const storedToken = await this.store.readToken();
2837
+ if (storedToken?.trim()) {
2838
+ this.#token = storedToken;
2839
+ return storedToken;
2840
+ }
2841
+ const token = await this.source.loadAccessToken();
2842
+ this.#token = token;
2843
+ await this.store.writeToken(token);
2844
+ return token;
3124
2845
  }
3125
- async loadAccessToken() {
3126
- return getAccessTokenFromSupabaseContents(await readFile(this.filePath, "utf8"));
2846
+ async invalidate() {
2847
+ this.#token = void 0;
2848
+ await this.store.clearToken();
3127
2849
  }
3128
2850
  };
3129
- var SupabaseFileSessionSource = class {
3130
- constructor(filePath) {
3131
- this.filePath = filePath;
2851
+ var StoredSessionTokenProvider = class {
2852
+ #session;
2853
+ constructor(store, options = {}) {
2854
+ this.store = store;
2855
+ this.options = options;
3132
2856
  }
3133
2857
  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()) {
3144
- this.filePath = filePath;
3145
- }
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;
2858
+ if (this.#session) return this.#session;
2859
+ const storedSession = await this.store.readSession();
2860
+ if (storedSession?.accessToken.trim()) {
2861
+ this.#session = storedSession;
2862
+ return storedSession;
3157
2863
  }
2864
+ if (!this.options.source) throw new Error("no stored Granola session found");
2865
+ const sourcedSession = await this.options.source.loadSession();
2866
+ this.#session = sourcedSession;
2867
+ return sourcedSession;
3158
2868
  }
3159
- async writeSession(session) {
3160
- await mkdir(dirname(this.filePath), { recursive: true });
3161
- await writeFile(this.filePath, `${JSON.stringify(session, null, 2)}\n`, {
3162
- encoding: "utf8",
3163
- mode: 384
3164
- });
3165
- }
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 {}
2869
+ async getAccessToken() {
2870
+ return (await this.loadSession()).accessToken;
3175
2871
  }
3176
- async readApiKey() {
3177
- try {
3178
- return (await readFile(this.filePath, "utf8")).trim() || void 0;
3179
- } catch {
2872
+ async invalidate() {
2873
+ const session = await this.loadSession().catch(() => void 0);
2874
+ if (session?.refreshToken && session.clientId) try {
2875
+ const refreshedSession = await refreshGranolaSession(session, this.options.fetchImpl);
2876
+ this.#session = refreshedSession;
2877
+ await this.store.writeSession(refreshedSession);
3180
2878
  return;
3181
- }
3182
- }
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
- };
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
2879
  } catch {
2880
+ if (!this.options.source) {
2881
+ this.#session = void 0;
2882
+ await this.store.clearSession();
2883
+ throw new Error("failed to refresh stored Granola session");
2884
+ }
2885
+ }
2886
+ if (this.options.source) {
2887
+ const sourcedSession = await this.options.source.loadSession();
2888
+ this.#session = sourcedSession;
2889
+ await this.store.writeSession(sourcedSession);
3218
2890
  return;
3219
2891
  }
3220
- }
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
- ]);
2892
+ this.#session = void 0;
2893
+ await this.store.clearSession();
3232
2894
  }
3233
2895
  };
3234
- var KeychainApiKeyStore = class {
3235
- async clearApiKey() {
2896
+ async function refreshGranolaSession(session, fetchImpl = fetch) {
2897
+ if (!session.refreshToken?.trim()) throw new Error("refresh token not available");
2898
+ const response = await fetchImpl(WORKOS_AUTH_URL, {
2899
+ body: JSON.stringify({
2900
+ client_id: session.clientId,
2901
+ grant_type: "refresh_token",
2902
+ refresh_token: session.refreshToken
2903
+ }),
2904
+ headers: { "Content-Type": "application/json" },
2905
+ method: "POST"
2906
+ });
2907
+ if (!response.ok) throw new Error(`failed to refresh session: ${response.status} ${response.statusText}`);
2908
+ const refreshed = parseSessionRecord(await response.json());
2909
+ if (!refreshed) throw new Error("failed to parse refreshed session");
2910
+ return {
2911
+ ...session,
2912
+ ...refreshed,
2913
+ clientId: refreshed.clientId || session.clientId,
2914
+ obtainedAt: refreshed.obtainedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
2915
+ refreshToken: refreshed.refreshToken ?? session.refreshToken
2916
+ };
2917
+ }
2918
+ function defaultSessionFilePath() {
2919
+ return defaultGranolaToolkitPersistenceLayout().sessionFile;
2920
+ }
2921
+ function defaultApiKeyFilePath() {
2922
+ return defaultGranolaToolkitPersistenceLayout().apiKeyFile;
2923
+ }
2924
+ function createDefaultSessionStore() {
2925
+ return defaultGranolaToolkitPersistenceLayout().sessionStoreKind === "keychain" ? new KeychainSessionStore() : new FileSessionStore();
2926
+ }
2927
+ function createDefaultApiKeyStore() {
2928
+ return defaultGranolaToolkitPersistenceLayout().sessionStoreKind === "keychain" ? new KeychainApiKeyStore() : new FileApiKeyStore();
2929
+ }
2930
+ //#endregion
2931
+ //#region src/client/http.ts
2932
+ const RETRYABLE_STATUS_CODES = new Set([
2933
+ 429,
2934
+ 500,
2935
+ 502,
2936
+ 503,
2937
+ 504
2938
+ ]);
2939
+ function sleep(delayMs) {
2940
+ return new Promise((resolve) => {
2941
+ setTimeout(resolve, delayMs);
2942
+ });
2943
+ }
2944
+ function parseRetryAfter(headerValue) {
2945
+ if (!headerValue?.trim()) return;
2946
+ if (/^\d+$/.test(headerValue.trim())) return Number(headerValue.trim()) * 1e3;
2947
+ const retryAt = Date.parse(headerValue);
2948
+ if (Number.isNaN(retryAt)) return;
2949
+ return Math.max(0, retryAt - Date.now());
2950
+ }
2951
+ var AuthenticatedHttpClient = class {
2952
+ fetchImpl;
2953
+ constructor(options) {
2954
+ this.fetchImpl = options.fetchImpl ?? fetch;
2955
+ this.logger = options.logger;
2956
+ this.maxRetries = options.maxRetries ?? 2;
2957
+ this.retryBaseDelayMs = options.retryBaseDelayMs ?? 500;
2958
+ this.retryMaxDelayMs = options.retryMaxDelayMs ?? 5e3;
2959
+ this.sleepImpl = options.sleepImpl ?? sleep;
2960
+ this.tokenProvider = options.tokenProvider;
2961
+ }
2962
+ logger;
2963
+ maxRetries;
2964
+ retryBaseDelayMs;
2965
+ retryMaxDelayMs;
2966
+ sleepImpl;
2967
+ tokenProvider;
2968
+ async retry(options, attempt, reason, response) {
2969
+ const retryAfterMs = parseRetryAfter(response?.headers.get("retry-after") ?? null);
2970
+ const delayMs = Math.min(retryAfterMs ?? this.retryBaseDelayMs * 2 ** attempt, this.retryMaxDelayMs);
2971
+ this.logger?.warn?.(`${reason}; retrying in ${delayMs}ms (${attempt + 1}/${this.maxRetries})`);
2972
+ await this.sleepImpl(delayMs);
2973
+ return this.request(options, attempt + 1);
2974
+ }
2975
+ async request(options, attempt = 0) {
2976
+ const { retryOnUnauthorized = true, timeoutMs, url } = options;
2977
+ const accessToken = await this.tokenProvider.getAccessToken();
2978
+ let response;
3236
2979
  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 {}
2980
+ response = await this.fetchImpl(url, {
2981
+ body: options.body,
2982
+ headers: {
2983
+ ...options.headers,
2984
+ Authorization: `Bearer ${accessToken}`
2985
+ },
2986
+ method: options.method ?? "GET",
2987
+ signal: AbortSignal.timeout(timeoutMs)
2988
+ });
2989
+ } catch (error) {
2990
+ if (attempt < this.maxRetries) {
2991
+ const message = error instanceof Error ? error.message : String(error);
2992
+ return this.retry(options, attempt, `request failed: ${message}`);
2993
+ }
2994
+ throw error;
2995
+ }
2996
+ if (response.status === 401 && retryOnUnauthorized) {
2997
+ this.logger?.warn?.("request returned 401; invalidating token provider and retrying once");
2998
+ await this.tokenProvider.invalidate();
2999
+ return this.request({
3000
+ ...options,
3001
+ retryOnUnauthorized: false
3002
+ }, attempt);
3003
+ }
3004
+ if (RETRYABLE_STATUS_CODES.has(response.status) && attempt < this.maxRetries) return this.retry(options, attempt, `request returned ${response.status} ${response.statusText || ""}`.trim(), response);
3005
+ return response;
3006
+ }
3007
+ async postJson(url, body, options = { timeoutMs: 3e4 }) {
3008
+ return this.request({
3009
+ ...options,
3010
+ body: JSON.stringify(body),
3011
+ headers: {
3012
+ Accept: "*/*",
3013
+ "Content-Type": "application/json",
3014
+ ...options.headers
3015
+ },
3016
+ method: "POST",
3017
+ url
3018
+ });
3019
+ }
3020
+ };
3021
+ //#endregion
3022
+ //#region src/agents.ts
3023
+ const DEFAULT_CODEX_MODEL = "gpt-5-codex";
3024
+ const DEFAULT_OPENAI_MODEL = "gpt-5-mini";
3025
+ const DEFAULT_OPENROUTER_MODEL = "openai/gpt-5-mini";
3026
+ const OPENROUTER_REFERER = "https://github.com/kkarimi/granola-toolkit";
3027
+ const OPENROUTER_TITLE = "granola-toolkit";
3028
+ function trimString(value) {
3029
+ return value?.trim() ? value.trim() : void 0;
3030
+ }
3031
+ function resolveProvider(request, config, env) {
3032
+ if (request.provider) return request.provider;
3033
+ if (config.agents?.defaultProvider) return config.agents.defaultProvider;
3034
+ if (trimString(env.OPENROUTER_API_KEY) || trimString(env.GRANOLA_OPENROUTER_API_KEY)) return "openrouter";
3035
+ if (trimString(env.OPENAI_API_KEY) || trimString(env.GRANOLA_OPENAI_API_KEY)) return "openai";
3036
+ return "codex";
3037
+ }
3038
+ function resolveModel(provider, request, config) {
3039
+ return trimString(request.model) ?? trimString(config.agents?.defaultModel) ?? (provider === "openrouter" ? DEFAULT_OPENROUTER_MODEL : provider === "openai" ? DEFAULT_OPENAI_MODEL : DEFAULT_CODEX_MODEL);
3040
+ }
3041
+ function resolveTimeoutMs(request, config) {
3042
+ return request.timeoutMs ?? config.agents?.timeoutMs ?? 3e5;
3043
+ }
3044
+ function resolveRetries(request, config) {
3045
+ return request.retries ?? config.agents?.maxRetries ?? 2;
3046
+ }
3047
+ function resolveDryRun(request, config) {
3048
+ return request.dryRun ?? config.agents?.dryRun ?? false;
3049
+ }
3050
+ function openaiApiKey(env) {
3051
+ return trimString(env.OPENAI_API_KEY) ?? trimString(env.GRANOLA_OPENAI_API_KEY);
3052
+ }
3053
+ function openrouterApiKey(env) {
3054
+ return trimString(env.OPENROUTER_API_KEY) ?? trimString(env.GRANOLA_OPENROUTER_API_KEY);
3055
+ }
3056
+ async function responseError(response, label) {
3057
+ let details = `${response.status} ${response.statusText}`.trim();
3058
+ try {
3059
+ const payload = await response.json();
3060
+ if (typeof payload.error === "string" && payload.error.trim()) details = payload.error;
3061
+ else if (payload.error && typeof payload.error === "object" && typeof payload.error.message === "string" && payload.error.message.trim()) details = payload.error.message;
3062
+ else if (typeof payload.message === "string" && payload.message.trim()) details = payload.message;
3063
+ } catch {
3064
+ const text = (await response.text()).trim();
3065
+ if (text) details = text;
3066
+ }
3067
+ return /* @__PURE__ */ new Error(`${label}: ${details}`);
3068
+ }
3069
+ function messageText(content) {
3070
+ if (typeof content === "string") return content.trim();
3071
+ if (!Array.isArray(content)) return "";
3072
+ return content.map((part) => {
3073
+ if (part && typeof part === "object" && "type" in part && part.type === "text" && "text" in part && typeof part.text === "string") return part.text;
3074
+ return "";
3075
+ }).join("").trim();
3076
+ }
3077
+ async function runCodexCliCommand(request) {
3078
+ const tempDirectory = await mkdtemp(join(tmpdir(), "granola-toolkit-codex-"));
3079
+ const outputFile = join(tempDirectory, "last-message.txt");
3080
+ const args = [
3081
+ "exec",
3082
+ "--skip-git-repo-check",
3083
+ "--color",
3084
+ "never"
3085
+ ];
3086
+ if (request.cwd) args.push("-C", request.cwd);
3087
+ if (request.model) args.push("-m", request.model);
3088
+ args.push("--output-last-message", outputFile, "-");
3089
+ const commandText = [request.command, ...args].join(" ");
3090
+ try {
3091
+ return {
3092
+ command: commandText,
3093
+ output: await new Promise((resolve$1, reject) => {
3094
+ const child = spawn(request.command, args, {
3095
+ cwd: request.cwd ? resolve(request.cwd) : process.cwd(),
3096
+ env: process.env,
3097
+ stdio: [
3098
+ "pipe",
3099
+ "pipe",
3100
+ "pipe"
3101
+ ]
3102
+ });
3103
+ const stdoutChunks = [];
3104
+ const stderrChunks = [];
3105
+ let timedOut = false;
3106
+ const timeout = setTimeout(() => {
3107
+ timedOut = true;
3108
+ child.kill("SIGTERM");
3109
+ }, request.timeoutMs);
3110
+ child.stdout.on("data", (chunk) => {
3111
+ stdoutChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
3112
+ });
3113
+ child.stderr.on("data", (chunk) => {
3114
+ stderrChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
3115
+ });
3116
+ child.on("error", (error) => {
3117
+ clearTimeout(timeout);
3118
+ reject(error);
3119
+ });
3120
+ child.on("close", async (code) => {
3121
+ clearTimeout(timeout);
3122
+ const stdout = Buffer.concat(stdoutChunks).toString("utf8").trim();
3123
+ const stderr = Buffer.concat(stderrChunks).toString("utf8").trim();
3124
+ if (timedOut) {
3125
+ reject(/* @__PURE__ */ new Error(`codex provider timed out after ${request.timeoutMs}ms`));
3126
+ return;
3127
+ }
3128
+ if (code !== 0) {
3129
+ reject(new Error(stderr || stdout || `codex exited with status ${String(code)}`));
3130
+ return;
3131
+ }
3132
+ try {
3133
+ resolve$1((await readFile(outputFile, "utf8")).trim() || stdout || void 0);
3134
+ } catch {
3135
+ resolve$1(stdout || void 0);
3136
+ }
3137
+ });
3138
+ child.stdin.write(request.prompt);
3139
+ child.stdin.end();
3140
+ })
3141
+ };
3142
+ } finally {
3143
+ await rm(tempDirectory, {
3144
+ force: true,
3145
+ recursive: true
3146
+ }).catch(() => void 0);
3147
+ }
3148
+ }
3149
+ async function runOpenAiCompatibleRequest(options) {
3150
+ const response = await new AuthenticatedHttpClient({
3151
+ fetchImpl: options.fetchImpl,
3152
+ maxRetries: options.maxRetries,
3153
+ tokenProvider: new CachedTokenProvider({ async loadAccessToken() {
3154
+ return options.token;
3155
+ } })
3156
+ }).postJson(`${options.baseUrl.replace(/\/$/, "")}/chat/completions`, {
3157
+ messages: [...options.systemPrompt ? [{
3158
+ content: options.systemPrompt,
3159
+ role: "system"
3160
+ }] : [], {
3161
+ content: options.prompt,
3162
+ role: "user"
3163
+ }],
3164
+ model: options.model
3165
+ }, {
3166
+ headers: {
3167
+ Accept: "application/json",
3168
+ ...options.headers
3169
+ },
3170
+ timeoutMs: options.timeoutMs
3171
+ });
3172
+ if (!response.ok) throw await responseError(response, options.label);
3173
+ const content = (await response.json()).choices?.[0]?.message?.content;
3174
+ return messageText(content) || void 0;
3175
+ }
3176
+ function createDefaultAutomationAgentRunner(config, options = {}) {
3177
+ const env = options.env ?? process.env;
3178
+ const runCodexCommand = options.runCodexCommand ?? runCodexCliCommand;
3179
+ return { async run(request) {
3180
+ const provider = resolveProvider(request, config, env);
3181
+ const model = resolveModel(provider, request, config);
3182
+ const timeoutMs = resolveTimeoutMs(request, config);
3183
+ const retries = resolveRetries(request, config);
3184
+ const dryRun = resolveDryRun(request, config);
3185
+ if (dryRun) return {
3186
+ dryRun,
3187
+ model,
3188
+ output: void 0,
3189
+ prompt: request.prompt,
3190
+ provider,
3191
+ systemPrompt: request.systemPrompt
3192
+ };
3193
+ if (provider === "codex") {
3194
+ const result = await runCodexCommand({
3195
+ command: config.agents?.codexCommand ?? "codex",
3196
+ cwd: request.cwd,
3197
+ model,
3198
+ prompt: request.systemPrompt ? `${request.systemPrompt.trim()}\n\n${request.prompt}` : request.prompt,
3199
+ timeoutMs
3200
+ });
3201
+ return {
3202
+ command: result.command,
3203
+ dryRun,
3204
+ model,
3205
+ output: result.output,
3206
+ prompt: request.prompt,
3207
+ provider,
3208
+ systemPrompt: request.systemPrompt
3209
+ };
3210
+ }
3211
+ const token = provider === "openrouter" ? openrouterApiKey(env) : openaiApiKey(env);
3212
+ 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.");
3213
+ return {
3214
+ dryRun,
3215
+ model,
3216
+ output: await runOpenAiCompatibleRequest({
3217
+ baseUrl: provider === "openrouter" ? config.agents?.openrouterBaseUrl ?? "https://openrouter.ai/api/v1" : config.agents?.openaiBaseUrl ?? "https://api.openai.com/v1",
3218
+ fetchImpl: options.fetchImpl,
3219
+ headers: provider === "openrouter" ? {
3220
+ "HTTP-Referer": OPENROUTER_REFERER,
3221
+ "X-Title": OPENROUTER_TITLE
3222
+ } : void 0,
3223
+ label: provider === "openrouter" ? "OpenRouter request failed" : "OpenAI request failed",
3224
+ maxRetries: retries,
3225
+ model,
3226
+ prompt: request.prompt,
3227
+ systemPrompt: request.systemPrompt,
3228
+ timeoutMs,
3229
+ token
3230
+ }),
3231
+ prompt: request.prompt,
3232
+ provider,
3233
+ systemPrompt: request.systemPrompt
3234
+ };
3235
+ } };
3236
+ }
3237
+ //#endregion
3238
+ //#region src/automation-actions.ts
3239
+ function cloneAction$1(action) {
3240
+ switch (action.kind) {
3241
+ case "agent": return { ...action };
3242
+ case "ask-user": return { ...action };
3243
+ case "command": return {
3244
+ ...action,
3245
+ args: action.args ? [...action.args] : void 0,
3246
+ env: action.env ? { ...action.env } : void 0
3247
+ };
3248
+ case "export-notes":
3249
+ case "export-transcript": return { ...action };
3250
+ }
3251
+ }
3252
+ function automationActionName(action) {
3253
+ return action.name || action.id;
3254
+ }
3255
+ function buildAutomationActionRunId(match, actionId) {
3256
+ return `${match.id}:${actionId}`;
3257
+ }
3258
+ function enabledAutomationActions(rule) {
3259
+ return (rule.actions ?? []).filter((action) => action.enabled !== false).map((action) => cloneAction$1(action));
3260
+ }
3261
+ function baseRun(match, rule, action, startedAt) {
3262
+ return {
3263
+ actionId: action.id,
3264
+ actionKind: action.kind,
3265
+ actionName: automationActionName(action),
3266
+ eventId: match.eventId,
3267
+ eventKind: match.eventKind,
3268
+ folders: match.folders.map((folder) => ({ ...folder })),
3269
+ id: buildAutomationActionRunId(match, action.id),
3270
+ matchedAt: match.matchedAt,
3271
+ meetingId: match.meetingId,
3272
+ ruleId: rule.id,
3273
+ ruleName: rule.name,
3274
+ startedAt,
3275
+ status: "completed",
3276
+ tags: [...match.tags],
3277
+ title: match.title,
3278
+ transcriptLoaded: match.transcriptLoaded
3279
+ };
3280
+ }
3281
+ function completedRun(run, finishedAt, patch = {}) {
3282
+ return {
3283
+ ...run,
3284
+ ...patch,
3285
+ finishedAt,
3286
+ status: "completed"
3287
+ };
3288
+ }
3289
+ function failedRun(run, finishedAt, error) {
3290
+ return {
3291
+ ...run,
3292
+ error: error instanceof Error ? error.message : String(error),
3293
+ finishedAt,
3294
+ status: "failed"
3295
+ };
3296
+ }
3297
+ function skippedRun(run, finishedAt, reason) {
3298
+ return {
3299
+ ...run,
3300
+ finishedAt,
3301
+ result: reason,
3302
+ status: "skipped"
3303
+ };
3304
+ }
3305
+ async function executeAutomationAction(match, rule, action, handlers) {
3306
+ const run = baseRun(match, rule, action, handlers.nowIso());
3307
+ switch (action.kind) {
3308
+ case "agent": try {
3309
+ const result = await handlers.runAgent(match, rule, action);
3310
+ return completedRun(run, handlers.nowIso(), {
3311
+ meta: {
3312
+ command: result.command,
3313
+ dryRun: result.dryRun,
3314
+ model: result.model,
3315
+ provider: result.provider,
3316
+ systemPrompt: result.systemPrompt
3317
+ },
3318
+ prompt: result.prompt,
3319
+ result: result.output ?? (result.dryRun ? "Dry run: provider request not executed" : "")
3320
+ });
3321
+ } catch (error) {
3322
+ return failedRun(run, handlers.nowIso(), error);
3323
+ }
3324
+ case "ask-user": return {
3325
+ ...run,
3326
+ meta: action.details ? { details: action.details } : void 0,
3327
+ prompt: action.prompt,
3328
+ result: "Pending user decision",
3329
+ status: "pending"
3330
+ };
3331
+ case "command": try {
3332
+ const result = await handlers.runCommand(match, rule, action);
3333
+ return completedRun(run, handlers.nowIso(), {
3334
+ meta: {
3335
+ command: result.command,
3336
+ cwd: result.cwd
3337
+ },
3338
+ result: result.output
3339
+ });
3340
+ } catch (error) {
3341
+ return failedRun(run, handlers.nowIso(), error);
3342
+ }
3343
+ case "export-notes": try {
3344
+ const result = await handlers.exportNotes(match, action);
3345
+ if (!result) return skippedRun(run, handlers.nowIso(), "Meeting notes were unavailable for export");
3346
+ return completedRun(run, handlers.nowIso(), {
3347
+ meta: {
3348
+ format: result.format,
3349
+ outputDir: result.outputDir,
3350
+ scope: result.scope,
3351
+ written: result.written
3352
+ },
3353
+ result: `Exported notes to ${result.outputDir}`
3354
+ });
3355
+ } catch (error) {
3356
+ return failedRun(run, handlers.nowIso(), error);
3357
+ }
3358
+ case "export-transcript": try {
3359
+ const result = await handlers.exportTranscripts(match, action);
3360
+ if (!result) return skippedRun(run, handlers.nowIso(), "Transcript data was unavailable for export");
3361
+ return completedRun(run, handlers.nowIso(), {
3362
+ meta: {
3363
+ format: result.format,
3364
+ outputDir: result.outputDir,
3365
+ scope: result.scope,
3366
+ written: result.written
3367
+ },
3368
+ result: `Exported transcript to ${result.outputDir}`
3369
+ });
3370
+ } catch (error) {
3371
+ return failedRun(run, handlers.nowIso(), error);
3372
+ }
3373
+ }
3374
+ }
3375
+ //#endregion
3376
+ //#region src/automation-matches.ts
3377
+ function cloneMatch(match) {
3378
+ return {
3379
+ ...match,
3380
+ folders: match.folders.map((folder) => ({ ...folder })),
3381
+ tags: [...match.tags]
3382
+ };
3383
+ }
3384
+ var FileAutomationMatchStore = class {
3385
+ constructor(filePath = defaultAutomationMatchesFilePath()) {
3386
+ this.filePath = filePath;
3387
+ }
3388
+ async appendMatches(matches) {
3389
+ if (matches.length === 0) return;
3390
+ await mkdir(dirname(this.filePath), { recursive: true });
3391
+ const payload = matches.map((match) => JSON.stringify(match)).join("\n");
3392
+ await appendFile(this.filePath, `${payload}\n`, {
3393
+ encoding: "utf8",
3394
+ mode: 384
3395
+ });
3245
3396
  }
3246
- async readApiKey() {
3397
+ async readMatches(limit = 50) {
3247
3398
  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;
3399
+ 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);
3400
+ return (limit > 0 ? matches.slice(-limit) : matches).reverse();
3257
3401
  } catch {
3258
- return;
3402
+ return [];
3259
3403
  }
3260
3404
  }
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
3405
  };
3276
- var CachedTokenProvider = class {
3277
- #token;
3278
- constructor(source, store = new NoopTokenStore()) {
3279
- this.source = source;
3280
- this.store = store;
3406
+ function defaultAutomationMatchesFilePath() {
3407
+ return defaultGranolaToolkitPersistenceLayout().automationMatchesFile;
3408
+ }
3409
+ function createDefaultAutomationMatchStore(filePath) {
3410
+ return new FileAutomationMatchStore(filePath);
3411
+ }
3412
+ //#endregion
3413
+ //#region src/automation-runs.ts
3414
+ function cloneRun(run) {
3415
+ return {
3416
+ ...run,
3417
+ folders: run.folders.map((folder) => ({ ...folder })),
3418
+ meta: run.meta ? structuredClone(run.meta) : void 0,
3419
+ tags: [...run.tags]
3420
+ };
3421
+ }
3422
+ function sortRuns(runs) {
3423
+ return runs.slice().sort((left, right) => (right.finishedAt ?? right.startedAt).localeCompare(left.finishedAt ?? left.startedAt));
3424
+ }
3425
+ function mergeRuns(runs) {
3426
+ const byId = /* @__PURE__ */ new Map();
3427
+ for (const run of runs) byId.set(run.id, cloneRun(run));
3428
+ return sortRuns([...byId.values()]);
3429
+ }
3430
+ var FileAutomationRunStore = class {
3431
+ constructor(filePath = defaultAutomationRunsFilePath()) {
3432
+ this.filePath = filePath;
3281
3433
  }
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;
3288
- }
3289
- const token = await this.source.loadAccessToken();
3290
- this.#token = token;
3291
- await this.store.writeToken(token);
3292
- return token;
3434
+ async appendRuns(runs) {
3435
+ if (runs.length === 0) return;
3436
+ await mkdir(dirname(this.filePath), { recursive: true });
3437
+ const payload = runs.map((run) => JSON.stringify(run)).join("\n");
3438
+ await appendFile(this.filePath, `${payload}\n`, {
3439
+ encoding: "utf8",
3440
+ mode: 384
3441
+ });
3293
3442
  }
3294
- async invalidate() {
3295
- this.#token = void 0;
3296
- await this.store.clearToken();
3443
+ async readRun(id) {
3444
+ return (await this.readRuns({ limit: 0 })).find((run) => run.id === id);
3445
+ }
3446
+ async readRuns(options = {}) {
3447
+ try {
3448
+ 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);
3449
+ return (options.limit && options.limit > 0 ? runs.slice(0, options.limit) : runs).map(cloneRun);
3450
+ } catch {
3451
+ return [];
3452
+ }
3297
3453
  }
3298
3454
  };
3299
- var StoredSessionTokenProvider = class {
3300
- #session;
3301
- constructor(store, options = {}) {
3302
- this.store = store;
3303
- this.options = options;
3455
+ function defaultAutomationRunsFilePath() {
3456
+ return defaultGranolaToolkitPersistenceLayout().automationRunsFile;
3457
+ }
3458
+ function createDefaultAutomationRunStore(filePath) {
3459
+ return new FileAutomationRunStore(filePath);
3460
+ }
3461
+ //#endregion
3462
+ //#region src/automation-rules.ts
3463
+ function cloneRule(rule) {
3464
+ return {
3465
+ ...rule,
3466
+ actions: rule.actions?.map((action) => cloneAction(action)),
3467
+ when: {
3468
+ ...rule.when,
3469
+ eventKinds: rule.when.eventKinds ? [...rule.when.eventKinds] : void 0,
3470
+ folderIds: rule.when.folderIds ? [...rule.when.folderIds] : void 0,
3471
+ folderNames: rule.when.folderNames ? [...rule.when.folderNames] : void 0,
3472
+ meetingIds: rule.when.meetingIds ? [...rule.when.meetingIds] : void 0,
3473
+ tags: rule.when.tags ? [...rule.when.tags] : void 0,
3474
+ titleIncludes: rule.when.titleIncludes ? [...rule.when.titleIncludes] : void 0
3475
+ }
3476
+ };
3477
+ }
3478
+ function cloneAction(action) {
3479
+ switch (action.kind) {
3480
+ case "agent": return { ...action };
3481
+ case "ask-user": return { ...action };
3482
+ case "command": return {
3483
+ ...action,
3484
+ args: action.args ? [...action.args] : void 0,
3485
+ env: action.env ? { ...action.env } : void 0
3486
+ };
3487
+ case "export-notes":
3488
+ case "export-transcript": return { ...action };
3304
3489
  }
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;
3490
+ }
3491
+ function stringArray(value) {
3492
+ if (!Array.isArray(value)) return;
3493
+ const values = value.filter((item) => typeof item === "string" && item.trim().length > 0);
3494
+ return values.length > 0 ? [...new Set(values.map((item) => item.trim()))] : void 0;
3495
+ }
3496
+ function stringRecord(value) {
3497
+ if (!value || typeof value !== "object" || Array.isArray(value)) return;
3498
+ const entries = Object.entries(value).filter(([key, item]) => {
3499
+ return typeof key === "string" && key.trim().length > 0 && typeof item === "string" && item.trim().length > 0;
3500
+ });
3501
+ if (entries.length === 0) return;
3502
+ return Object.fromEntries(entries.map(([key, item]) => [key.trim(), item.trim()]));
3503
+ }
3504
+ function parseAction(value, index) {
3505
+ if (!value || typeof value !== "object" || Array.isArray(value)) return;
3506
+ const record = value;
3507
+ const kind = typeof record.kind === "string" && record.kind.trim() ? record.kind.trim() : void 0;
3508
+ const id = typeof record.id === "string" && record.id.trim() ? record.id.trim() : kind ? `${kind}-${index + 1}` : void 0;
3509
+ const name = typeof record.name === "string" && record.name.trim() ? record.name.trim() : void 0;
3510
+ const enabled = typeof record.enabled === "boolean" ? record.enabled : void 0;
3511
+ switch (kind) {
3512
+ case "agent": {
3513
+ if (!id) return;
3514
+ const provider = record.provider === "codex" || record.provider === "openai" || record.provider === "openrouter" ? record.provider : void 0;
3515
+ const prompt = typeof record.prompt === "string" && record.prompt.trim() ? record.prompt.trim() : void 0;
3516
+ const promptFile = typeof record.promptFile === "string" && record.promptFile.trim() ? record.promptFile.trim() : void 0;
3517
+ const systemPrompt = typeof record.systemPrompt === "string" && record.systemPrompt.trim() ? record.systemPrompt.trim() : void 0;
3518
+ const systemPromptFile = typeof record.systemPromptFile === "string" && record.systemPromptFile.trim() ? record.systemPromptFile.trim() : void 0;
3519
+ if (!prompt && !promptFile) return;
3520
+ return {
3521
+ cwd: typeof record.cwd === "string" && record.cwd.trim() ? record.cwd.trim() : void 0,
3522
+ dryRun: typeof record.dryRun === "boolean" ? record.dryRun : void 0,
3523
+ enabled,
3524
+ id,
3525
+ kind,
3526
+ model: typeof record.model === "string" && record.model.trim() ? record.model.trim() : void 0,
3527
+ name,
3528
+ prompt,
3529
+ promptFile,
3530
+ provider,
3531
+ retries: typeof record.retries === "number" && Number.isFinite(record.retries) ? record.retries : typeof record.retries === "string" && /^\d+$/.test(record.retries) ? Number(record.retries) : void 0,
3532
+ systemPrompt,
3533
+ systemPromptFile,
3534
+ timeoutMs: typeof record.timeoutMs === "number" && Number.isFinite(record.timeoutMs) ? record.timeoutMs : typeof record.timeoutMs === "string" && /^\d+$/.test(record.timeoutMs) ? Number(record.timeoutMs) : void 0
3535
+ };
3311
3536
  }
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;
3537
+ case "ask-user": {
3538
+ const prompt = typeof record.prompt === "string" && record.prompt.trim() ? record.prompt.trim() : void 0;
3539
+ if (!id || !prompt) return;
3540
+ return {
3541
+ details: typeof record.details === "string" && record.details.trim() ? record.details.trim() : void 0,
3542
+ enabled,
3543
+ id,
3544
+ kind,
3545
+ name,
3546
+ prompt
3547
+ };
3548
+ }
3549
+ case "command": {
3550
+ const command = typeof record.command === "string" && record.command.trim() ? record.command.trim() : void 0;
3551
+ if (!id || !command) return;
3552
+ return {
3553
+ args: stringArray(record.args),
3554
+ command,
3555
+ cwd: typeof record.cwd === "string" && record.cwd.trim() ? record.cwd.trim() : void 0,
3556
+ enabled,
3557
+ env: stringRecord(record.env),
3558
+ id,
3559
+ kind,
3560
+ name,
3561
+ stdin: record.stdin === "json" || record.stdin === "none" ? record.stdin : void 0,
3562
+ timeoutMs: typeof record.timeoutMs === "number" && Number.isFinite(record.timeoutMs) ? record.timeoutMs : typeof record.timeoutMs === "string" && /^\d+$/.test(record.timeoutMs) ? Number(record.timeoutMs) : void 0
3563
+ };
3564
+ }
3565
+ case "export-notes":
3566
+ if (!id) return;
3567
+ return {
3568
+ enabled,
3569
+ format: record.format === "json" || record.format === "markdown" || record.format === "raw" || record.format === "yaml" ? record.format : void 0,
3570
+ id,
3571
+ kind,
3572
+ name,
3573
+ outputDir: typeof record.outputDir === "string" && record.outputDir.trim() ? record.outputDir.trim() : void 0,
3574
+ scopedOutput: typeof record.scopedOutput === "boolean" ? record.scopedOutput : void 0
3575
+ };
3576
+ case "export-transcript":
3577
+ if (!id) return;
3578
+ return {
3579
+ enabled,
3580
+ format: record.format === "json" || record.format === "raw" || record.format === "text" || record.format === "yaml" ? record.format : void 0,
3581
+ id,
3582
+ kind,
3583
+ name,
3584
+ outputDir: typeof record.outputDir === "string" && record.outputDir.trim() ? record.outputDir.trim() : void 0,
3585
+ scopedOutput: typeof record.scopedOutput === "boolean" ? record.scopedOutput : void 0
3586
+ };
3587
+ default: return;
3316
3588
  }
3317
- async getAccessToken() {
3318
- return (await this.loadSession()).accessToken;
3589
+ }
3590
+ function parseRule(value) {
3591
+ if (!value || typeof value !== "object" || Array.isArray(value)) return;
3592
+ const record = value;
3593
+ const id = typeof record.id === "string" && record.id.trim() ? record.id.trim() : void 0;
3594
+ const name = typeof record.name === "string" && record.name.trim() ? record.name.trim() : void 0;
3595
+ const whenValue = record.when && typeof record.when === "object" && !Array.isArray(record.when) ? record.when : void 0;
3596
+ if (!id || !name || !whenValue) return;
3597
+ return {
3598
+ actions: Array.isArray(record.actions) ? record.actions.map((action, index) => parseAction(action, index)).filter((action) => Boolean(action)) : void 0,
3599
+ enabled: typeof record.enabled === "boolean" ? record.enabled : void 0,
3600
+ id,
3601
+ name,
3602
+ when: {
3603
+ eventKinds: stringArray(whenValue.eventKinds),
3604
+ folderIds: stringArray(whenValue.folderIds),
3605
+ folderNames: stringArray(whenValue.folderNames),
3606
+ meetingIds: stringArray(whenValue.meetingIds),
3607
+ tags: stringArray(whenValue.tags),
3608
+ titleIncludes: stringArray(whenValue.titleIncludes),
3609
+ titleMatches: typeof whenValue.titleMatches === "string" && whenValue.titleMatches.trim() ? whenValue.titleMatches.trim() : void 0,
3610
+ transcriptLoaded: typeof whenValue.transcriptLoaded === "boolean" ? whenValue.transcriptLoaded : void 0
3611
+ }
3612
+ };
3613
+ }
3614
+ var FileAutomationRuleStore = class {
3615
+ constructor(filePath = defaultAutomationRulesFilePath()) {
3616
+ this.filePath = filePath;
3319
3617
  }
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;
3618
+ async readRules() {
3619
+ try {
3620
+ const parsed = parseJsonString(await readFile(this.filePath, "utf8"));
3621
+ 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);
3327
3622
  } 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
- }
3333
- }
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;
3623
+ return [];
3339
3624
  }
3340
- this.#session = void 0;
3341
- await this.store.clearSession();
3342
3625
  }
3343
3626
  };
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
- };
3627
+ function defaultAutomationRulesFilePath() {
3628
+ return defaultGranolaToolkitPersistenceLayout().automationRulesFile;
3365
3629
  }
3366
- function defaultSessionFilePath() {
3367
- return defaultGranolaToolkitPersistenceLayout().sessionFile;
3630
+ function includesIgnoreCase(candidate, values) {
3631
+ const lowerCandidate = candidate.toLowerCase();
3632
+ return values.some((value) => lowerCandidate.includes(value.toLowerCase()));
3368
3633
  }
3369
- function defaultApiKeyFilePath() {
3370
- return defaultGranolaToolkitPersistenceLayout().apiKeyFile;
3634
+ function matchesRule(rule, event) {
3635
+ if (rule.enabled === false) return false;
3636
+ const { when } = rule;
3637
+ if (when.eventKinds?.length && !when.eventKinds.includes(event.kind)) return false;
3638
+ if (when.meetingIds?.length && !when.meetingIds.includes(event.meetingId)) return false;
3639
+ if (when.folderIds?.length && !event.folders.some((folder) => when.folderIds?.includes(folder.id))) return false;
3640
+ if (when.folderNames?.length && !event.folders.some((folder) => when.folderNames?.includes(folder.name))) return false;
3641
+ if (when.tags?.length && !event.tags.some((tag) => when.tags?.includes(tag))) return false;
3642
+ if (when.titleIncludes?.length && !includesIgnoreCase(event.title, when.titleIncludes)) return false;
3643
+ if (when.titleMatches) try {
3644
+ if (!new RegExp(when.titleMatches, "i").test(event.title)) return false;
3645
+ } catch {
3646
+ return false;
3647
+ }
3648
+ if (typeof when.transcriptLoaded === "boolean" && when.transcriptLoaded !== event.transcriptLoaded) return false;
3649
+ return true;
3371
3650
  }
3372
- function createDefaultSessionStore() {
3373
- return defaultGranolaToolkitPersistenceLayout().sessionStoreKind === "keychain" ? new KeychainSessionStore() : new FileSessionStore();
3651
+ function matchAutomationRules(rules, events, matchedAt) {
3652
+ const matches = [];
3653
+ for (const event of events) for (const rule of rules) {
3654
+ if (!matchesRule(rule, event)) continue;
3655
+ matches.push({
3656
+ eventId: event.id,
3657
+ eventKind: event.kind,
3658
+ folders: event.folders.map((folder) => ({ ...folder })),
3659
+ id: `${event.id}:${rule.id}`,
3660
+ matchedAt,
3661
+ meetingId: event.meetingId,
3662
+ ruleId: rule.id,
3663
+ ruleName: rule.name,
3664
+ tags: [...event.tags],
3665
+ title: event.title,
3666
+ transcriptLoaded: event.transcriptLoaded
3667
+ });
3668
+ }
3669
+ return matches;
3374
3670
  }
3375
- function createDefaultApiKeyStore() {
3376
- return defaultGranolaToolkitPersistenceLayout().sessionStoreKind === "keychain" ? new KeychainApiKeyStore() : new FileApiKeyStore();
3671
+ function createDefaultAutomationRuleStore(filePath) {
3672
+ return new FileAutomationRuleStore(filePath);
3673
+ }
3674
+ //#endregion
3675
+ //#region src/cache.ts
3676
+ function parseCacheDocument(id, value) {
3677
+ const record = asRecord(value);
3678
+ if (!record) return;
3679
+ return {
3680
+ createdAt: stringValue(record.created_at),
3681
+ id,
3682
+ title: stringValue(record.title),
3683
+ updatedAt: stringValue(record.updated_at)
3684
+ };
3685
+ }
3686
+ function parseTranscriptSegments(value) {
3687
+ if (!Array.isArray(value)) return;
3688
+ return value.flatMap((segment) => {
3689
+ const record = asRecord(segment);
3690
+ if (!record) return [];
3691
+ return [{
3692
+ documentId: stringValue(record.document_id),
3693
+ endTimestamp: stringValue(record.end_timestamp),
3694
+ id: stringValue(record.id),
3695
+ isFinal: Boolean(record.is_final),
3696
+ source: stringValue(record.source),
3697
+ startTimestamp: stringValue(record.start_timestamp),
3698
+ text: stringValue(record.text)
3699
+ }];
3700
+ });
3701
+ }
3702
+ function parseCacheContents(contents) {
3703
+ const outer = parseJsonString(contents);
3704
+ if (!outer) throw new Error("failed to parse cache JSON");
3705
+ const rawCache = outer.cache;
3706
+ let cachePayload;
3707
+ if (typeof rawCache === "string") cachePayload = parseJsonString(rawCache);
3708
+ else cachePayload = asRecord(rawCache);
3709
+ const state = cachePayload ? asRecord(cachePayload.state) : void 0;
3710
+ if (!state) throw new Error("failed to parse cache state");
3711
+ const rawDocuments = asRecord(state.documents) ?? {};
3712
+ const rawTranscripts = asRecord(state.transcripts) ?? {};
3713
+ const documents = {};
3714
+ for (const [id, rawDocument] of Object.entries(rawDocuments)) {
3715
+ const document = parseCacheDocument(id, rawDocument);
3716
+ if (document) documents[id] = document;
3717
+ }
3718
+ const transcripts = {};
3719
+ for (const [id, rawTranscript] of Object.entries(rawTranscripts)) {
3720
+ const segments = parseTranscriptSegments(rawTranscript);
3721
+ if (segments) transcripts[id] = segments;
3722
+ }
3723
+ return {
3724
+ documents,
3725
+ transcripts
3726
+ };
3377
3727
  }
3378
3728
  //#endregion
3379
3729
  //#region src/client/default-auth.ts
@@ -3829,97 +4179,6 @@ var GranolaPublicApiClient = class {
3829
4179
  }
3830
4180
  };
3831
4181
  //#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
4182
  //#region src/client/default.ts
3924
4183
  async function createDefaultGranolaRuntime(config, logger = console, options = {}) {
3925
4184
  const auth = await inspectDefaultGranolaAuth(config, { preferredMode: options.preferredMode });
@@ -4628,6 +4887,58 @@ function cloneMeetingSummary(meeting) {
4628
4887
  tags: [...meeting.tags]
4629
4888
  };
4630
4889
  }
4890
+ function resolveActionFilePath(filePath, cwd) {
4891
+ return cwd ? resolve(cwd, filePath) : resolve(filePath);
4892
+ }
4893
+ function combinePromptSections(...values) {
4894
+ const sections = values.map((value) => value?.trim()).filter((value) => Boolean(value));
4895
+ return sections.length > 0 ? sections.join("\n\n") : void 0;
4896
+ }
4897
+ function meetingTranscriptText(bundle) {
4898
+ const segments = bundle.document.transcriptSegments ?? (bundle.cacheData ? bundle.cacheData.transcripts[bundle.document.id] : void 0);
4899
+ if (!segments?.length) return;
4900
+ return segments.slice().sort((left, right) => left.startTimestamp.localeCompare(right.startTimestamp)).map((segment) => segment.text.trim()).filter(Boolean).join("\n");
4901
+ }
4902
+ function buildAutomationAgentPrompt(match, rule, instructions, bundle) {
4903
+ const transcriptText = bundle ? meetingTranscriptText(bundle)?.trim() : void 0;
4904
+ const context = {
4905
+ event: {
4906
+ id: match.eventId,
4907
+ kind: match.eventKind,
4908
+ matchedAt: match.matchedAt,
4909
+ meetingId: match.meetingId,
4910
+ transcriptLoaded: match.transcriptLoaded
4911
+ },
4912
+ folders: match.folders.map((folder) => ({
4913
+ id: folder.id,
4914
+ name: folder.name
4915
+ })),
4916
+ meeting: bundle ? {
4917
+ id: bundle.document.id,
4918
+ notesPlain: bundle.document.notesPlain,
4919
+ tags: [...bundle.document.tags],
4920
+ title: bundle.document.title,
4921
+ updatedAt: bundle.document.updatedAt
4922
+ } : {
4923
+ id: match.meetingId,
4924
+ tags: [...match.tags],
4925
+ title: match.title
4926
+ },
4927
+ rule: {
4928
+ id: rule.id,
4929
+ name: rule.name
4930
+ }
4931
+ };
4932
+ return [
4933
+ instructions.trim(),
4934
+ "Meeting context (JSON):",
4935
+ "```json",
4936
+ JSON.stringify(context, null, 2),
4937
+ "```",
4938
+ bundle?.document.notesPlain?.trim() ? `Existing notes:\n${bundle.document.notesPlain.trim()}` : "",
4939
+ transcriptText ? `Transcript:\n${transcriptText}` : ""
4940
+ ].filter(Boolean).join("\n\n");
4941
+ }
4631
4942
  function cloneState(state) {
4632
4943
  return {
4633
4944
  auth: { ...state.auth },
@@ -4636,6 +4947,7 @@ function cloneState(state) {
4636
4947
  config: {
4637
4948
  ...state.config,
4638
4949
  automation: state.config.automation ? { ...state.config.automation } : void 0,
4950
+ agents: state.config.agents ? { ...state.config.agents } : void 0,
4639
4951
  notes: { ...state.config.notes },
4640
4952
  transcripts: { ...state.config.transcripts }
4641
4953
  },
@@ -4673,6 +4985,7 @@ function defaultState(config, auth, surface) {
4673
4985
  },
4674
4986
  config: {
4675
4987
  ...config,
4988
+ agents: config.agents ? { ...config.agents } : void 0,
4676
4989
  notes: { ...config.notes },
4677
4990
  transcripts: { ...config.transcripts }
4678
4991
  },
@@ -4825,6 +5138,7 @@ var GranolaApp = class {
4825
5138
  ...rule,
4826
5139
  actions: rule.actions?.map((action) => {
4827
5140
  switch (action.kind) {
5141
+ case "agent": return { ...action };
4828
5142
  case "ask-user": return { ...action };
4829
5143
  case "command": return {
4830
5144
  ...action,
@@ -5373,6 +5687,24 @@ var GranolaApp = class {
5373
5687
  child.stdin.end();
5374
5688
  });
5375
5689
  }
5690
+ async runAutomationAgent(match, rule, action) {
5691
+ const bundle = match.eventKind === "meeting.removed" ? void 0 : await this.maybeReadMeetingBundleById(match.meetingId, { requireCache: false });
5692
+ const promptFile = action.promptFile ? await readFile(resolveActionFilePath(action.promptFile, action.cwd), "utf8") : void 0;
5693
+ const systemPromptFile = action.systemPromptFile ? await readFile(resolveActionFilePath(action.systemPromptFile, action.cwd), "utf8") : void 0;
5694
+ const instructions = combinePromptSections(promptFile, action.prompt);
5695
+ if (!instructions) throw new Error(`automation agent action ${action.id} is missing prompt instructions`);
5696
+ const request = {
5697
+ cwd: action.cwd,
5698
+ dryRun: action.dryRun,
5699
+ model: action.model,
5700
+ prompt: buildAutomationAgentPrompt(match, rule, instructions, bundle),
5701
+ provider: action.provider,
5702
+ retries: action.retries,
5703
+ systemPrompt: combinePromptSections(systemPromptFile, action.systemPrompt),
5704
+ timeoutMs: action.timeoutMs
5705
+ };
5706
+ return await (this.deps.agentRunner ?? createDefaultAutomationAgentRunner(this.config)).run(request);
5707
+ }
5376
5708
  async runAutomationActions(rules, matches) {
5377
5709
  const rulesById = new Map(rules.map((rule) => [rule.id, rule]));
5378
5710
  const existingRunIds = new Set(this.#automationActionRuns.map((run) => run.id));
@@ -5388,6 +5720,7 @@ var GranolaApp = class {
5388
5720
  exportNotes: async (nextMatch, nextAction) => await this.runAutomationNotesAction(nextMatch, nextAction),
5389
5721
  exportTranscripts: async (nextMatch, nextAction) => await this.runAutomationTranscriptAction(nextMatch, nextAction),
5390
5722
  nowIso: () => this.nowIso(),
5723
+ runAgent: async (nextMatch, nextRule, nextAction) => await this.runAutomationAgent(nextMatch, nextRule, nextAction),
5391
5724
  runCommand: async (nextMatch, nextRule, nextAction) => await this.runAutomationCommand(nextMatch, nextRule, nextAction)
5392
5725
  }));
5393
5726
  }
@@ -5826,6 +6159,7 @@ async function createGranolaApp(config, options = {}) {
5826
6159
  const syncState = await syncStateStore.readState();
5827
6160
  return new GranolaApp(config, {
5828
6161
  auth,
6162
+ agentRunner: createDefaultAutomationAgentRunner(config),
5829
6163
  authController,
5830
6164
  automationMatches,
5831
6165
  automationMatchStore,
@@ -5855,6 +6189,10 @@ function pickString(value) {
5855
6189
  function pickBoolean(value) {
5856
6190
  return typeof value === "boolean" ? value : void 0;
5857
6191
  }
6192
+ function pickNumber(value) {
6193
+ if (typeof value === "number" && Number.isFinite(value)) return value;
6194
+ if (typeof value === "string" && /^-?\d+$/.test(value.trim())) return Number(value.trim());
6195
+ }
5858
6196
  function parseTomlScalar(rawValue) {
5859
6197
  const value = rawValue.trim();
5860
6198
  if (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'")) {
@@ -5907,9 +6245,23 @@ async function loadConfig(options) {
5907
6245
  const configValues = config.values;
5908
6246
  const defaultSupabase = firstExistingPath(granolaSupabaseCandidates());
5909
6247
  const defaultCache = firstExistingPath(granolaCacheCandidates());
6248
+ const agentTimeoutValue = pickString(env.GRANOLA_AGENT_TIMEOUT) ?? pickString(configValues["agent-timeout"]) ?? pickString(configValues.agentTimeout) ?? "5m";
5910
6249
  const timeoutValue = pickString(options.subcommandFlags.timeout) ?? pickString(env.TIMEOUT) ?? pickString(configValues.timeout) ?? "2m";
5911
6250
  return {
5912
6251
  automation: { rulesFile: pickString(options.globalFlags.rules) ?? pickString(env.GRANOLA_AUTOMATION_RULES_FILE) ?? pickString(configValues["automation-rules-file"]) ?? pickString(configValues.automationRulesFile) ?? defaultGranolaToolkitPersistenceLayout().automationRulesFile },
6252
+ agents: {
6253
+ codexCommand: pickString(env.GRANOLA_CODEX_COMMAND) ?? pickString(configValues["codex-command"]) ?? pickString(configValues.codexCommand) ?? "codex",
6254
+ defaultModel: pickString(env.GRANOLA_AGENT_MODEL) ?? pickString(configValues["agent-model"]) ?? pickString(configValues.agentModel),
6255
+ defaultProvider: (() => {
6256
+ const value = pickString(env.GRANOLA_AGENT_PROVIDER) ?? pickString(configValues["agent-provider"]) ?? pickString(configValues.agentProvider);
6257
+ return value === "codex" || value === "openai" || value === "openrouter" ? value : void 0;
6258
+ })(),
6259
+ dryRun: envFlag(env.GRANOLA_AGENT_DRY_RUN) ?? pickBoolean(configValues["agent-dry-run"]) ?? pickBoolean(configValues.agentDryRun) ?? false,
6260
+ maxRetries: pickNumber(env.GRANOLA_AGENT_MAX_RETRIES) ?? pickNumber(configValues["agent-max-retries"]) ?? pickNumber(configValues.agentMaxRetries) ?? 2,
6261
+ 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",
6262
+ 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",
6263
+ timeoutMs: parseDuration(agentTimeoutValue)
6264
+ },
5913
6265
  apiKey: pickString(options.globalFlags["api-key"]) ?? pickString(env.GRANOLA_API_KEY) ?? pickString(configValues["api-key"]) ?? pickString(configValues.apiKey),
5914
6266
  configFileUsed: config.path,
5915
6267
  debug: pickBoolean(options.globalFlags.debug) ?? envFlag(env.DEBUG_MODE) ?? pickBoolean(configValues.debug) ?? false,