@joe-broadhead/opencode-granola 0.0.0 → 0.0.1

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
4
4
 
5
+ ## [0.0.1] - 2026-11-03
6
+
7
+ ### Added
8
+
9
+ - The tools RPC, `granola.meetings.tools`: a script can call Granola's ten read tools with no model, through
10
+ `opencode api`, as a session and its agent. It gets exactly the access that agent's model has: a tool its rules hide
11
+ is refused, OpenCode checks each call against the agent's and the session's rules, and an ask is refused at once
12
+ because no one is there to answer it. `describe` lists the methods with each tool's live input schema. Granola's
13
+ writes, `sign_out`, `export_markdown` and any tool Granola adds later are never methods. An account read records the
14
+ workspace for status, and a server that is not connected gets the same codes and next steps as for the model.
15
+ `docs/scripting.md` explains it.
16
+
5
17
  ## [0.0.0] - 2026-11-01
6
18
 
7
19
  ### Added
package/README.md CHANGED
@@ -60,6 +60,19 @@ To see the connection, the workspace, the tools and the next step:
60
60
  The workspace name comes from the most recent Granola account read since OpenCode started, and is kept in memory
61
61
  only. Until then, status says the workspace is unknown; ask the agent to check your Granola account.
62
62
 
63
+ ### Scripts
64
+
65
+ A script or a cron job can call Granola's ten read tools with no model, through `opencode api`. It runs as a session
66
+ and its agent, under that agent's permission rules and the session's own. Writes, `sign_out` and `export_markdown` are
67
+ never available to scripts:
68
+
69
+ ```sh
70
+ opencode api post /api/rpc/granola.meetings.tools/list_meetings \
71
+ -d '{"input":{"sessionID":"ses_…","input":{"limit":10}}}'
72
+ ```
73
+
74
+ See [Scripting](https://github.com/joe-broadhead/opencode-granola/blob/HEAD/docs/scripting.md).
75
+
63
76
  ## Permissions
64
77
 
65
78
  | Kind | Tools | Default |
@@ -147,6 +160,9 @@ overwrites it. An entry for Granola's official URL is used as it is.
147
160
 
148
161
  **Status lists Agents without default rules.** See [Permissions](#permissions).
149
162
 
163
+ **A script gets an error.** The tools RPC has its own codes, listed in
164
+ [Scripting](https://github.com/joe-broadhead/opencode-granola/blob/HEAD/docs/scripting.md#errors).
165
+
150
166
  Granola uses the workspace that is active in your Granola app; meetings in other workspaces do not appear. The plugin
151
167
  supports one Granola account.
152
168
 
@@ -163,6 +179,7 @@ Restart OpenCode afterwards. Files you exported stay in your export folder.
163
179
 
164
180
  - [Configuration](https://github.com/joe-broadhead/opencode-granola/blob/HEAD/docs/configuration.md)
165
181
  - [Architecture](https://github.com/joe-broadhead/opencode-granola/blob/HEAD/docs/architecture.md)
182
+ - [Scripting](https://github.com/joe-broadhead/opencode-granola/blob/HEAD/docs/scripting.md)
166
183
  - [Contributing](https://github.com/joe-broadhead/opencode-granola/blob/HEAD/CONTRIBUTING.md)
167
184
  - [Security](https://github.com/joe-broadhead/opencode-granola/blob/HEAD/SECURITY.md)
168
185
  - [Changelog](https://github.com/joe-broadhead/opencode-granola/blob/HEAD/CHANGELOG.md)
package/dist/index.js CHANGED
@@ -1,8 +1,9 @@
1
1
  // src/index.ts
2
2
  import { Plugin } from "@opencode/plugin";
3
3
 
4
- // src/meta.ts
5
- import { readFileSync } from "node:fs";
4
+ // src/host/tools-rpc.ts
5
+ import { randomUUID } from "node:crypto";
6
+ import { Rpc as Rpc2 } from "@opencode/plugin/rpc";
6
7
 
7
8
  // src/errors.ts
8
9
  import { Error as ToolError } from "@opencode/plugin/promise/tool";
@@ -93,17 +94,6 @@ function packageInvalid(problem) {
93
94
  return { code: "PACKAGE_INVALID", message: `The Granola plugin's package.json ${problem}.`, next: "Reinstall the plugin." };
94
95
  }
95
96
 
96
- // src/meta.ts
97
- var PLUGIN_ID = "granola.meetings";
98
- function readPackageInfo(url = new URL("../package.json", import.meta.url)) {
99
- const raw = JSON.parse(readFileSync(url, "utf8"));
100
- const record3 = (value) => typeof value === "object" && value !== null;
101
- if (!record3(raw) || typeof raw.version !== "string" || !record3(raw.opencode) || typeof raw.opencode.host !== "string") {
102
- throw new GranolaError(packageInvalid("needs a version and an opencode.host range"));
103
- }
104
- return { version: raw.version, testedHost: raw.opencode.host };
105
- }
106
-
107
97
  // src/known-tools.ts
108
98
  var SERVER = "granola";
109
99
  var READ_TOOLS = [
@@ -143,6 +133,18 @@ function classify(id) {
143
133
  return "unknown";
144
134
  }
145
135
 
136
+ // src/meta.ts
137
+ import { readFileSync } from "node:fs";
138
+ var PLUGIN_ID = "granola.meetings";
139
+ function readPackageInfo(url = new URL("../package.json", import.meta.url)) {
140
+ const raw = JSON.parse(readFileSync(url, "utf8"));
141
+ const record4 = (value) => typeof value === "object" && value !== null;
142
+ if (!record4(raw) || typeof raw.version !== "string" || !record4(raw.opencode) || typeof raw.opencode.host !== "string") {
143
+ throw new GranolaError(packageInvalid("needs a version and an opencode.host range"));
144
+ }
145
+ return { version: raw.version, testedHost: raw.opencode.host };
146
+ }
147
+
146
148
  // src/permissions.ts
147
149
  var DEFAULT_RULES = [
148
150
  { action: "granola_*", resource: "*", effect: "ask" },
@@ -250,7 +252,7 @@ var TONES = {
250
252
  };
251
253
  function statusRows(status) {
252
254
  const connected = status.state === "connected";
253
- const connection = status.conflict ? `conflict: ${status.conflict}` : `${STATE_LABELS[status.state]}${status.title ? ` (${status.title})` : ""}${status.problem ? `: ${status.problem.message} (${status.problem.code})` : ""}`;
255
+ const connection2 = status.conflict ? `conflict: ${status.conflict}` : `${STATE_LABELS[status.state]}${status.title ? ` (${status.title})` : ""}${status.problem ? `: ${status.problem.message} (${status.problem.code})` : ""}`;
254
256
  const tools = [`${status.tools.visible} available`];
255
257
  if (status.tools.hidden) tools.push(`${status.tools.hidden} hidden`);
256
258
  if (status.tools.unknown) {
@@ -259,7 +261,7 @@ function statusRows(status) {
259
261
  }
260
262
  const agents = status.unguardedAgents;
261
263
  return [
262
- { label: "Connection", text: connection, tone: status.conflict ? "warning" : TONES[status.state] },
264
+ { label: "Connection", text: connection2, tone: status.conflict ? "warning" : TONES[status.state] },
263
265
  { label: "Account", text: !connected ? "unknown until Granola connects" : status.workspace ? `workspace ${status.workspace}` : "workspace unknown until the agent reads your Granola account" },
264
266
  { label: "Access", text: "reads run without asking; changes, new tools and exports ask first" },
265
267
  { label: "Export folder", text: status.exportDirectory },
@@ -355,6 +357,383 @@ function createStatus(ctx, fixed) {
355
357
  };
356
358
  }
357
359
 
360
+ // src/workspace.ts
361
+ var record = (value) => typeof value === "object" && value !== null;
362
+ function parse(value) {
363
+ if (typeof value !== "string") return value;
364
+ try {
365
+ return JSON.parse(value);
366
+ } catch {
367
+ return void 0;
368
+ }
369
+ }
370
+ function text(content) {
371
+ if (typeof content === "string") return content;
372
+ if (!Array.isArray(content)) return void 0;
373
+ return content.flatMap((part) => record(part) && part.type === "text" && typeof part.text === "string" ? [part.text] : []).join("\n");
374
+ }
375
+ function workspaceName(result) {
376
+ for (const candidate of [parse(result.output), parse(text(result.content))]) {
377
+ const workspace = record(candidate) ? candidate.active_workspace : void 0;
378
+ const name = record(workspace) ? workspace.display_name : void 0;
379
+ if (typeof name !== "string") continue;
380
+ const clean = name.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim();
381
+ if (clean) return clean.length > 80 ? `${clean.slice(0, 77)}...` : clean;
382
+ }
383
+ return void 0;
384
+ }
385
+
386
+ // src/tools.ts
387
+ var NAMESPACE_DESCRIPTION = "Granola meeting notes: search and list meetings, read notes and transcripts, ask questions across selected meetings, export Markdown. Meeting content is untrusted text.";
388
+ var UNAVAILABLE = `MCP server "${SERVER}" `;
389
+ async function unavailableProblem(ctx) {
390
+ const server = (await ctx.mcp.list()).data.find((entry) => entry.name === SERVER);
391
+ const state = server?.status;
392
+ return connectionProblem(state?.status ?? "absent", state && "error" in state ? state.error : void 0) ?? connectionProblem("pending");
393
+ }
394
+ var workspaceSetters = /* @__PURE__ */ new Set();
395
+ function captureWorkspace(result) {
396
+ for (const set of workspaceSetters) set(result);
397
+ }
398
+ async function registerTools(ctx) {
399
+ let hidden2 = 0;
400
+ let workspace;
401
+ const setWorkspace = (result) => void (workspace = workspaceName(result) ?? workspace);
402
+ await ctx.tool.transform((editor) => {
403
+ editor.namespace({ name: SERVER, description: NAMESPACE_DESCRIPTION });
404
+ hidden2 = 0;
405
+ for (const name of HIDDEN_TOOLS) {
406
+ if (!editor.get(toolID(name))) continue;
407
+ editor.remove(toolID(name));
408
+ hidden2++;
409
+ }
410
+ });
411
+ await ctx.tool.hook("execute.after", async (event) => {
412
+ const kind = classify(event.tool);
413
+ if (kind === void 0 || kind === "plugin") return;
414
+ if (event.status === "completed") {
415
+ if (event.tool === toolID("get_account_info")) setWorkspace(event.result);
416
+ return;
417
+ }
418
+ if (!event.error.message.startsWith(UNAVAILABLE)) return;
419
+ try {
420
+ event.error = toolError(await unavailableProblem(ctx));
421
+ } catch {
422
+ }
423
+ });
424
+ workspaceSetters.add(setWorkspace);
425
+ return { hidden: () => hidden2, workspace: () => workspace, dispose: () => void workspaceSetters.delete(setWorkspace) };
426
+ }
427
+
428
+ // src/host/tools-rpc.ts
429
+ var PLUGIN_ID2 = PLUGIN_ID;
430
+ var KNOWN_READS = READ_TOOLS;
431
+ function shapeResult(method, result) {
432
+ if (method === "get_account_info") captureWorkspace({ output: result.output, content: result.text });
433
+ return result;
434
+ }
435
+ var connectionNext = (state) => connectionProblem(state)?.next ?? "";
436
+ var NEXT_STEPS = {
437
+ SESSION_UNKNOWN: `Create a session that names an agent, such as POST /api/session with {"agent":"build"}, and pass its id.`,
438
+ SESSION_AGENT_REQUIRED: `Create a session that names an agent, such as POST /api/session with {"agent":"build"}, and pass its id.`,
439
+ PERMISSION_DENIED: "Add an allow rule for the tool to the session's agent or to the session, or use a session whose agent allows it.",
440
+ SERVER_CONFLICT: CONFLICT_NEXT,
441
+ AUTH_REQUIRED: connectionNext("needs_auth"),
442
+ CONNECTING: connectionNext("pending"),
443
+ CONNECTION_FAILED: connectionNext("failed"),
444
+ DISABLED: connectionNext("disabled"),
445
+ NOT_REGISTERED: connectionNext("absent"),
446
+ TOOL_UNAVAILABLE: toolUnavailable("the tool").next,
447
+ UPSTREAM_ERROR: "Check the input against the method's schema from describe, and run /granola-status if it keeps failing.",
448
+ CANCELLED: "Call the method again if you still need the result."
449
+ };
450
+ function pluginError(_error) {
451
+ return void 0;
452
+ }
453
+ var TOOLS_RPC_ID = `${PLUGIN_ID2}.tools`;
454
+ var UNATTENDED = "A script cannot answer an approval prompt; this needs an allow rule for the session's agent.";
455
+ var MESSAGE_ID = "msg_script";
456
+ var UPSTREAM_LIMIT = 300;
457
+ var object = (properties, required) => ({
458
+ type: "object",
459
+ properties,
460
+ required,
461
+ additionalProperties: false
462
+ });
463
+ var STRING = { type: "string" };
464
+ var ANY_OBJECT = { type: "object" };
465
+ var NO_INPUT = object({}, []);
466
+ var FAILED = object({ code: STRING, next: STRING }, ["code", "next"]);
467
+ var mcpOutput = (output = {}) => object({ output, text: STRING }, ["text"]);
468
+ var DESCRIBED = object(
469
+ {
470
+ name: STRING,
471
+ tool: STRING,
472
+ kind: { type: "string", enum: ["mcp", "plugin"] },
473
+ action: STRING,
474
+ description: STRING,
475
+ available: { type: "boolean" },
476
+ reason: STRING,
477
+ input: ANY_OBJECT,
478
+ output: ANY_OBJECT
479
+ },
480
+ ["name", "tool", "kind", "action", "description", "available", "input", "output"]
481
+ );
482
+ var DESCRIPTION = object({ id: STRING, call: STRING, location: STRING, methods: { type: "array", items: DESCRIBED } }, [
483
+ "id",
484
+ "call",
485
+ "location",
486
+ "methods"
487
+ ]);
488
+ var plainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
489
+ function strict(schema, issues) {
490
+ const keys = Object.keys(schema["properties"]);
491
+ const validate = (value) => {
492
+ if (!plainObject(value)) return { issues: [{ message: "Expected an object" }] };
493
+ const found = [...Object.keys(value).filter((key) => !keys.includes(key)).map((key) => `"${key}" is not allowed`), ...issues(value)];
494
+ return found.length === 0 ? { value } : { issues: found.map((message) => ({ message })) };
495
+ };
496
+ return Object.defineProperty({ ...schema }, "~standard", { value: { version: 1, vendor: "tools-rpc", validate }, enumerable: false });
497
+ }
498
+ var ScriptError = class extends Error {
499
+ failure;
500
+ constructor(failure) {
501
+ super(`${failure.code}: ${failure.what.replace(/[.\s]+$/, "")}. Next step: ${failure.next}`);
502
+ this.failure = failure;
503
+ }
504
+ };
505
+ var fail = (code2, what) => new ScriptError({ code: code2, what, next: NEXT_STEPS[code2] });
506
+ var record2 = (value) => typeof value === "object" && value !== null;
507
+ function notFound(error, tags) {
508
+ if (!record2(error)) return false;
509
+ const tag = error["_tag"] ?? error["name"];
510
+ return typeof tag === "string" && tags.includes(tag) || error["status"] === 404 || error["statusCode"] === 404;
511
+ }
512
+ var toolError2 = (error) => record2(error) && error["_tag"] === "Tool.Error";
513
+ var wildcard = (pattern, value) => new RegExp(`^${pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".")}$`).test(value);
514
+ var hidden = (rules, action) => rules.findLast((rule) => wildcard(rule.action, action) && wildcard(rule.resource, "*"))?.effect === "deny";
515
+ var SECRETS = [
516
+ [/\b(bearer|basic|token)\s+[\w.~+/=-]{8,}/gi, "$1 [redacted]"],
517
+ [/\b([\w-]*(?:api[_-]?key|access[_-]?key|private[_-]?key|token|secret|password|signature|credential)["']?\s*[:=]\s*)["']?[^\s"'&,;]+["']?/gi, "$1[redacted]"],
518
+ [/\beyJ[\w-]{4,}\.[\w-]{4,}\.[\w-]{4,}/g, "[redacted]"],
519
+ [/\b(?:tvly|sk|pk|rk|ghp|gho|ghu|ghs|ghr|github_pat|glpat|xox[abposr]|lin_api|lin_oauth)[-_][\w-]{8,}/gi, "[redacted]"],
520
+ [/[\w+=-]{40,}/g, "[redacted]"]
521
+ ];
522
+ var withoutQuery = (text3) => text3.replace(/\bhttps?:\/\/[^\s"'<>()]+/gi, (url) => {
523
+ const cut = url.search(/[?#]/);
524
+ return cut === -1 ? url : `${url.slice(0, cut)}?[redacted]`;
525
+ });
526
+ function upstreamText(text3) {
527
+ const line = text3.split(/\r?\n/).find((part) => part.trim() !== "")?.trim() ?? "no message";
528
+ const redacted = SECRETS.reduce((value, [pattern, replacement]) => value.replace(pattern, replacement), withoutQuery(line));
529
+ return redacted.length <= UPSTREAM_LIMIT ? redacted : `${redacted.slice(0, UPSTREAM_LIMIT - 1)}\u2026`;
530
+ }
531
+ var jsonSchema = (schema) => record2(schema) && !("ast" in schema) && !("~standard" in schema) ? schema : void 0;
532
+ function normalise(result) {
533
+ const text3 = typeof result.content === "string" ? result.content : (result.content ?? []).flatMap((part) => part.type === "text" ? [part.text] : []).join("\n");
534
+ return result.output === void 0 ? { text: text3 } : { output: result.output, text: text3 };
535
+ }
536
+ function connection(status) {
537
+ switch (status) {
538
+ case "connected":
539
+ return void 0;
540
+ case "pending":
541
+ return "CONNECTING";
542
+ case "needs_auth":
543
+ return "AUTH_REQUIRED";
544
+ case "failed":
545
+ return "CONNECTION_FAILED";
546
+ case "disabled":
547
+ return "DISABLED";
548
+ default:
549
+ return "NOT_REGISTERED";
550
+ }
551
+ }
552
+ var STATUS_TEXT = {
553
+ CONNECTING: "is still connecting",
554
+ AUTH_REQUIRED: "needs a sign-in",
555
+ CONNECTION_FAILED: "could not connect",
556
+ DISABLED: "is disabled",
557
+ NOT_REGISTERED: "is not registered"
558
+ };
559
+ async function registerToolsRpc(ctx, options) {
560
+ const { mcp, owned = [] } = options;
561
+ const accounts = typeof mcp?.server === "function";
562
+ if (KNOWN_READS.length > 0 && !mcp) throw new Error("registerToolsRpc: KNOWN_READS needs the mcp option");
563
+ if (accounts && NEXT_STEPS.ACCOUNT_UNKNOWN === void 0) throw new Error("registerToolsRpc: per-account servers need NEXT_STEPS.ACCOUNT_UNKNOWN");
564
+ const names = [...KNOWN_READS, ...owned.map((method) => method.name)];
565
+ const duplicate = names.find((name, index) => name === "describe" || names.indexOf(name) !== index);
566
+ if (duplicate) throw new Error(`registerToolsRpc: the method name "${duplicate}" is taken`);
567
+ const live = /* @__PURE__ */ new Set();
568
+ const serverOf = (account) => {
569
+ const server = typeof mcp.server === "function" ? mcp.server(account) : mcp.server;
570
+ if (server !== void 0) return server;
571
+ throw new ScriptError({ code: "ACCOUNT_UNKNOWN", what: `there is no account '${account}'`, next: NEXT_STEPS.ACCOUNT_UNKNOWN });
572
+ };
573
+ const session = async (sessionID) => {
574
+ let info;
575
+ try {
576
+ info = await ctx.session.get({ sessionID });
577
+ } catch (error) {
578
+ if (notFound(error, ["Session.NotFoundError", "SessionNotFoundError"])) throw fail("SESSION_UNKNOWN", `there is no session '${sessionID}'`);
579
+ throw error;
580
+ }
581
+ if (!info.agent) throw fail("SESSION_AGENT_REQUIRED", `session '${sessionID}' names no agent`);
582
+ let agent;
583
+ try {
584
+ agent = await ctx.agent.get({ agentID: info.agent, location: info.location });
585
+ } catch (error) {
586
+ if (notFound(error, ["Agent.NotFoundError", "AgentNotFoundError"])) throw fail("SESSION_AGENT_REQUIRED", `session '${sessionID}' names the agent '${info.agent}', which does not exist`);
587
+ throw error;
588
+ }
589
+ const rules = [...agent.data.permissions, ...info.permissions ?? []];
590
+ return { agent: info.agent, rules };
591
+ };
592
+ const unavailable = async (server, tool, connected) => {
593
+ const status = (await ctx.mcp.list()).data.find((entry) => entry.name === server)?.status.status;
594
+ const code2 = connection(status) ?? connected;
595
+ if (code2 === "TOOL_UNAVAILABLE") return fail(code2, `OpenCode does not list ${tool} on the connected "${server}" server`);
596
+ return fail(code2, `the "${server}" server ${STATUS_TEXT[code2] ?? "is unavailable"}`);
597
+ };
598
+ const denied = (action, error) => {
599
+ const reason = record2(error) && typeof error["message"] === "string" ? error["message"] : String(error);
600
+ return fail("PERMISSION_DENIED", `${action} was refused for this session's agent: ${upstreamText(reason)}`);
601
+ };
602
+ const mcpTarget = (name, envelope2) => {
603
+ const server = serverOf(envelope2.account);
604
+ const action = `${server}_${name}`;
605
+ return {
606
+ action,
607
+ mcp: true,
608
+ async run(call) {
609
+ if (!mcp.managed(server)) throw fail("SERVER_CONFLICT", `the "${server}" server in your config is not the one this plugin manages`);
610
+ const tool = (await ctx.tool.list()).find((entry) => entry.id === action);
611
+ if (!tool) throw await unavailable(server, action, "TOOL_UNAVAILABLE");
612
+ let result;
613
+ try {
614
+ result = await tool.execute(envelope2.input, call);
615
+ } catch (error) {
616
+ if (error instanceof Error && error.message.startsWith(`MCP server "${server}" `)) throw await unavailable(server, action, "CONNECTION_FAILED");
617
+ throw error;
618
+ }
619
+ return shapeResult(name, normalise(result));
620
+ }
621
+ };
622
+ };
623
+ const ownedTarget = (spec, envelope2) => ({
624
+ action: spec.action,
625
+ mcp: false,
626
+ run: async (call) => await spec.run(envelope2.input, call) ?? null
627
+ });
628
+ const invoke = async (target, raw, context) => {
629
+ const envelope2 = raw;
630
+ let method;
631
+ try {
632
+ if (context.signal.aborted) throw fail("CANCELLED", "the call was cancelled");
633
+ method = target(envelope2);
634
+ const { agent, rules } = await session(envelope2.sessionID);
635
+ if (hidden(rules, method.action)) throw fail("PERMISSION_DENIED", `${method.action} is hidden from this session's agent by a deny rule`);
636
+ const call = {
637
+ sessionID: envelope2.sessionID,
638
+ agent,
639
+ messageID: MESSAGE_ID,
640
+ id: `script:${randomUUID()}`,
641
+ signal: context.signal,
642
+ progress: async () => {
643
+ }
644
+ };
645
+ live.add(call.id);
646
+ try {
647
+ return JSON.parse(JSON.stringify(await method.run(call)));
648
+ } finally {
649
+ live.delete(call.id);
650
+ }
651
+ } catch (error) {
652
+ const failure = refusal(error, method, context.signal);
653
+ if (!failure) throw error;
654
+ return context.error("failed", new ScriptError(failure).message, { code: failure.code, next: failure.next });
655
+ }
656
+ };
657
+ const refusal = (error, method, signal) => {
658
+ if (signal.aborted) return fail("CANCELLED", "the call was cancelled").failure;
659
+ if (error instanceof ScriptError) return error.failure;
660
+ const own = pluginError(error);
661
+ if (own || !method || !toolError2(error)) return own;
662
+ if (error.error !== void 0) return denied(method.action, error).failure;
663
+ if (method.mcp) return fail("UPSTREAM_ERROR", `${method.action} failed: ${upstreamText(error.message)}`).failure;
664
+ return void 0;
665
+ };
666
+ const describe2 = async () => {
667
+ const tools = mcp ? await ctx.tool.list() : [];
668
+ const methods = [];
669
+ if (mcp) {
670
+ const server = serverOf(void 0);
671
+ const managed = mcp.managed(server);
672
+ const status = managed ? (await ctx.mcp.list()).data.find((entry) => entry.name === server)?.status.status : void 0;
673
+ for (const name of KNOWN_READS) {
674
+ const id = `${server}_${name}`;
675
+ const tool = managed ? tools.find((entry) => entry.id === id) : void 0;
676
+ const reason = !managed ? "SERVER_CONFLICT" : tool ? void 0 : connection(status) ?? "TOOL_UNAVAILABLE";
677
+ const input = jsonSchema(tool?.input) ?? ANY_OBJECT;
678
+ const output = jsonSchema(tool?.output);
679
+ methods.push({
680
+ name,
681
+ tool: id,
682
+ kind: "mcp",
683
+ action: id,
684
+ description: tool?.description ?? "",
685
+ available: reason === void 0,
686
+ ...reason ? { reason } : {},
687
+ input,
688
+ output: mcpOutput(output)
689
+ });
690
+ }
691
+ }
692
+ for (const spec of owned)
693
+ methods.push({ name: spec.name, tool: spec.name, kind: "plugin", action: spec.action, description: spec.description, available: true, input: spec.input, output: spec.output });
694
+ return JSON.parse(
695
+ JSON.stringify({
696
+ id: TOOLS_RPC_ID,
697
+ call: `POST /api/rpc/${TOOLS_RPC_ID}/<method> with {"input":{"sessionID":"<session>",${accounts ? `"account":"<account>",` : ""}"input":<the tool's input>}}`,
698
+ location: ctx.location.directory,
699
+ methods
700
+ })
701
+ );
702
+ };
703
+ const envelope = (input, objectInput) => strict(
704
+ object({ sessionID: { type: "string", minLength: 1 }, ...accounts ? { account: { type: "string", minLength: 1 } } : {}, input }, ["sessionID", "input"]),
705
+ (value) => [
706
+ ...typeof value["sessionID"] === "string" && value["sessionID"] !== "" ? [] : [`"sessionID" must be a non-empty string`],
707
+ ...value["account"] === void 0 || typeof value["account"] === "string" && value["account"] !== "" ? [] : [`"account" must be a non-empty string`],
708
+ ...!("input" in value) ? [`"input" is missing`] : objectInput && !plainObject(value["input"]) ? [`"input" must be an object`] : []
709
+ ]
710
+ );
711
+ const contract = Rpc2.define({
712
+ id: TOOLS_RPC_ID,
713
+ methods: {
714
+ describe: { input: NO_INPUT, output: DESCRIPTION },
715
+ ...Object.fromEntries(KNOWN_READS.map((name) => [name, { input: envelope(ANY_OBJECT, true), output: mcpOutput(), errors: { failed: FAILED } }])),
716
+ ...Object.fromEntries(owned.map((spec) => [spec.name, { input: envelope(spec.input, false), output: spec.output, errors: { failed: FAILED } }]))
717
+ },
718
+ events: {}
719
+ });
720
+ const handlers = {
721
+ describe: () => describe2(),
722
+ ...Object.fromEntries(KNOWN_READS.map((name) => [name, (raw, context) => invoke((envelope2) => mcpTarget(name, envelope2), raw, context)])),
723
+ ...Object.fromEntries(owned.map((spec) => [spec.name, (raw, context) => invoke((envelope2) => ownedTarget(spec, envelope2), raw, context)]))
724
+ };
725
+ const registration = await ctx.rpc.register(contract, handlers);
726
+ const hook = await ctx.permission.hook("evaluate", (evaluation) => {
727
+ if (evaluation.effect !== "ask" || evaluation.source === void 0 || !live.has(evaluation.source.id)) return;
728
+ evaluation.effect = "deny";
729
+ evaluation.message = UNATTENDED;
730
+ });
731
+ return async () => {
732
+ await hook.dispose().catch(() => void 0);
733
+ await registration.dispose().catch(() => void 0);
734
+ };
735
+ }
736
+
358
737
  // src/commands.ts
359
738
  var STATUS_COMMAND = "granola-status";
360
739
  async function registerCommands(ctx, status) {
@@ -681,7 +1060,7 @@ function decode(text3) {
681
1060
  function isMarkup(text3) {
682
1061
  return new RegExp(`<${NAME}[\\s/>]`).test(text3);
683
1062
  }
684
- function parse(text3) {
1063
+ function parse2(text3) {
685
1064
  const root = { name: "", attributes: {}, children: [] };
686
1065
  const stack = [root];
687
1066
  const append = (value) => {
@@ -731,7 +1110,7 @@ var HEADINGS = {
731
1110
  transcript: "Granola meeting transcripts",
732
1111
  both: "Granola meeting notes and transcripts"
733
1112
  };
734
- var UNAVAILABLE = {
1113
+ var UNAVAILABLE2 = {
735
1114
  /** Not one of Granola's reasons: the notes read found no meeting and the transcript read failed. */
736
1115
  meeting_not_found: "Granola returned no meeting for this ID",
737
1116
  transcript_pending: "Granola is still processing it; export again in a few minutes",
@@ -809,7 +1188,7 @@ function blocks(parent) {
809
1188
  return result;
810
1189
  }
811
1190
  function fromMarkup(text3) {
812
- const root = parse(text3);
1191
+ const root = parse2(text3);
813
1192
  const meetings = findAll(root, "meeting");
814
1193
  if (!meetings.length) {
815
1194
  const hints = findAll(root, "not_found_hint");
@@ -901,7 +1280,7 @@ function transcriptEnd(end, maxBytes, empty2) {
901
1280
  case "complete":
902
1281
  return empty2 ? "_The transcript is empty._" : void 0;
903
1282
  case "unavailable":
904
- return `_No transcript: ${UNAVAILABLE[end.reason] ?? oneLine(end.reason, 200)}._`;
1283
+ return `_No transcript: ${UNAVAILABLE2[end.reason] ?? oneLine(end.reason, 200)}._`;
905
1284
  case "size-limit":
906
1285
  return empty2 ? `_Transcript not included: the export reached its ${limit} size limit. Export this meeting on its own to include it._` : `_The transcript stops here: the export reached its ${limit} size limit. Export this meeting on its own to get the rest._`;
907
1286
  case "incomplete":
@@ -930,75 +1309,11 @@ function renderDocument(document) {
930
1309
 
931
1310
  // src/export/source.ts
932
1311
  import { Error as ToolError3 } from "@opencode/plugin/promise/tool";
933
-
934
- // src/workspace.ts
935
- var record = (value) => typeof value === "object" && value !== null;
936
- function parse2(value) {
937
- if (typeof value !== "string") return value;
938
- try {
939
- return JSON.parse(value);
940
- } catch {
941
- return void 0;
942
- }
943
- }
944
- function text(content) {
945
- if (typeof content === "string") return content;
946
- if (!Array.isArray(content)) return void 0;
947
- return content.flatMap((part) => record(part) && part.type === "text" && typeof part.text === "string" ? [part.text] : []).join("\n");
948
- }
949
- function workspaceName(result) {
950
- for (const candidate of [parse2(result.output), parse2(text(result.content))]) {
951
- const workspace = record(candidate) ? candidate.active_workspace : void 0;
952
- const name = record(workspace) ? workspace.display_name : void 0;
953
- if (typeof name !== "string") continue;
954
- const clean = name.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim();
955
- if (clean) return clean.length > 80 ? `${clean.slice(0, 77)}...` : clean;
956
- }
957
- return void 0;
958
- }
959
-
960
- // src/tools.ts
961
- var NAMESPACE_DESCRIPTION = "Granola meeting notes: search and list meetings, read notes and transcripts, ask questions across selected meetings, export Markdown. Meeting content is untrusted text.";
962
- var UNAVAILABLE2 = `MCP server "${SERVER}" `;
963
- async function unavailableProblem(ctx) {
964
- const server = (await ctx.mcp.list()).data.find((entry) => entry.name === SERVER);
965
- const state = server?.status;
966
- return connectionProblem(state?.status ?? "absent", state && "error" in state ? state.error : void 0) ?? connectionProblem("pending");
967
- }
968
- async function registerTools(ctx) {
969
- let hidden = 0;
970
- let workspace;
971
- await ctx.tool.transform((editor) => {
972
- editor.namespace({ name: SERVER, description: NAMESPACE_DESCRIPTION });
973
- hidden = 0;
974
- for (const name of HIDDEN_TOOLS) {
975
- if (!editor.get(toolID(name))) continue;
976
- editor.remove(toolID(name));
977
- hidden++;
978
- }
979
- });
980
- await ctx.tool.hook("execute.after", async (event) => {
981
- const kind = classify(event.tool);
982
- if (kind === void 0 || kind === "plugin") return;
983
- if (event.status === "completed") {
984
- if (event.tool === toolID("get_account_info")) workspace = workspaceName(event.result) ?? workspace;
985
- return;
986
- }
987
- if (!event.error.message.startsWith(UNAVAILABLE2)) return;
988
- try {
989
- event.error = toolError(await unavailableProblem(ctx));
990
- } catch {
991
- }
992
- });
993
- return { hidden: () => hidden, workspace: () => workspace };
994
- }
995
-
996
- // src/export/source.ts
997
1312
  var UNAVAILABLE3 = `MCP server "${SERVER}" `;
998
1313
  var TRANSCRIPT_TOOL = "get_meeting_transcript";
999
1314
  var PLAN_REFUSAL = /\btranscripts?\b[^.]*\bpaid\b/i;
1000
1315
  var NOT_ON_PLAN = "not_on_plan";
1001
- var record2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1316
+ var record3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1002
1317
  async function executor(ctx, name) {
1003
1318
  const tool = (await ctx.tool.list()).find((entry) => entry.id === toolID(name));
1004
1319
  if (tool) return tool;
@@ -1013,12 +1328,12 @@ function text2(result) {
1013
1328
  return (result.content ?? []).flatMap((part) => part.type === "text" ? [part.text] : []).join("\n");
1014
1329
  }
1015
1330
  function structured(result) {
1016
- if (record2(result.output)) return result.output;
1331
+ if (record3(result.output)) return result.output;
1017
1332
  const body = text2(result).trim();
1018
1333
  if (!body.startsWith("{")) return void 0;
1019
1334
  try {
1020
1335
  const value = JSON.parse(body);
1021
- return record2(value) ? value : void 0;
1336
+ return record3(value) ? value : void 0;
1022
1337
  } catch {
1023
1338
  return void 0;
1024
1339
  }
@@ -1027,7 +1342,7 @@ function readPage(result) {
1027
1342
  const value = structured(result);
1028
1343
  if (!value || !("transcript" in value)) return { text: text2(result), endsMidUtterance: false };
1029
1344
  const transcript = typeof value.transcript === "string" ? value.transcript : null;
1030
- const unavailable = record2(value.transcript_unavailable) ? value.transcript_unavailable.reason : value.transcript_unavailable;
1345
+ const unavailable = record3(value.transcript_unavailable) ? value.transcript_unavailable.reason : value.transcript_unavailable;
1031
1346
  const next = value.truncated === true && Number.isSafeInteger(value.next_offset) ? value.next_offset : void 0;
1032
1347
  return {
1033
1348
  text: transcript,
@@ -1397,13 +1712,13 @@ async function registerIntegration(ctx, conflict) {
1397
1712
  const id = server?.integrationID;
1398
1713
  if (!server || !id || stopped.signal.aborted) return;
1399
1714
  if (id !== attached || (await ctx.integration.get({ integrationID: id })).data.name !== DISPLAY_NAME) await attach(id);
1400
- const connection = await ctx.integration.connection.active(id);
1401
- if (connection?.type !== "credential") return;
1715
+ const connection2 = await ctx.integration.connection.active(id);
1716
+ if (connection2?.type !== "credential") return;
1402
1717
  const state = server.status.status;
1403
- if (state === "needs_auth" && connection.status?.status !== "needs_auth") {
1404
- await ctx.integration.connection.status({ integrationID: id, connection, status: { status: "needs_auth", message: SIGN_IN_MESSAGE } });
1405
- } else if (state === "connected" && connection.status) {
1406
- await ctx.integration.connection.status({ integrationID: id, connection, status: void 0 });
1718
+ if (state === "needs_auth" && connection2.status?.status !== "needs_auth") {
1719
+ await ctx.integration.connection.status({ integrationID: id, connection: connection2, status: { status: "needs_auth", message: SIGN_IN_MESSAGE } });
1720
+ } else if (state === "connected" && connection2.status) {
1721
+ await ctx.integration.connection.status({ integrationID: id, connection: connection2, status: void 0 });
1407
1722
  }
1408
1723
  };
1409
1724
  let checking;
@@ -1560,7 +1875,7 @@ async function registerSkills(ctx, skills = readSkills()) {
1560
1875
  }
1561
1876
 
1562
1877
  // src/setup.ts
1563
- async function setup(ctx) {
1878
+ async function setup(ctx, toolsRpc) {
1564
1879
  const info = readPackageInfo();
1565
1880
  const options = parseOptions(ctx.options);
1566
1881
  const host = checkHost(ctx.app.version, info.testedHost, options.allowUntestedHost);
@@ -1581,11 +1896,16 @@ async function setup(ctx) {
1581
1896
  await registerSkills(ctx);
1582
1897
  await registerExport(ctx, options);
1583
1898
  await registerRpc(ctx, status);
1584
- return () => integration.dispose();
1899
+ const stopTools = await toolsRpc({ mcp: { server: SERVER, managed: () => server.conflict() === void 0 } });
1900
+ return async () => {
1901
+ tools?.dispose();
1902
+ await stopTools();
1903
+ await integration.dispose();
1904
+ };
1585
1905
  }
1586
1906
 
1587
1907
  // src/index.ts
1588
- var src_default = Plugin.define({ id: PLUGIN_ID, setup });
1908
+ var src_default = Plugin.define({ id: PLUGIN_ID, setup: (ctx) => setup(ctx, (options) => registerToolsRpc(ctx, options)) });
1589
1909
  export {
1590
1910
  src_default as default
1591
1911
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joe-broadhead/opencode-granola",
3
- "version": "0.0.0",
3
+ "version": "0.0.1",
4
4
  "description": "Unofficial OpenCode plugin for Granola meeting notes, through Granola's official MCP server",
5
5
  "keywords": [
6
6
  "opencode",
@@ -60,6 +60,10 @@ Reads run without a prompt. OpenCode asks the user before each of these, unless
60
60
 
61
61
  Make a change only when the user asked for it, and say first what it will do. A folder with workspace visibility shares every note in it with the whole workspace. If the user declines or the permission is denied, do not retry or find another way. In `opencode run` with nobody present to answer, these calls are denied unless the user's OpenCode permissions allow them. If `/granola-status` warns that an agent did not get Granola's permission defaults, pass the warning and its fix to the user.
62
62
 
63
+ ## Scripts
64
+
65
+ When the user wants a job that runs without you, such as a cron job or a shell script, it can call Granola's read tools through the OpenCode server, with no model. The RPC `granola.meetings.tools` has one method per read, such as `list_meetings`, `get_meetings` and `get_meeting_transcript`, with the tools' own input, and `describe`, which lists them with their input schemas. Each call is `opencode api post /api/rpc/granola.meetings.tools/<method>` with the body `{"input":{"sessionID":"<session>","input":<the tool's input>}}`. The script runs as that session's agent, under its permission rules, so have the script create its session once with `opencode api post /api/session` and a body such as `{"title":"weekly meetings","agent":"build"}`. An ask is refused, so the agent needs the reads allowed; the default rules allow them. Scripts cannot make changes, sign out or export files. The details are in the plugin's `docs/scripting.md`.
66
+
63
67
  ## Save meetings as Markdown
64
68
 
65
69
  `export_markdown` writes the notes, the transcript, or both, for 1 to 20 meetings into one Markdown file in the user's export folder; `/granola-status` shows the folder. The content goes straight to the file without passing through the conversation, so prefer it to reading a long transcript just to save it.