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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Read the relevant docs before starting work on a subsystem.
- `/api/v1/auth/*` is owned end-to-end by **better-auth** (social sign-in, OAuth 2.1 authorize/token, sessions/sign-out) — see `AUTH_DESIGN.md`. The **web UI is served at root `/`** (any non-`/api` GET → static assets / SPA fallback), including the `/login` page the CLI authorize flow redirects to.
- **Auth**: humans authenticate via **OAuth (GitHub/Google)** → a **better-auth session** — an httpOnly cookie for the web UI, and for the CLI an OAuth 2.1 authorization-code + PKCE loopback flow (RFC 8252) yielding access/refresh tokens. Agents — and a user, headlessly — use an **api key** (`me.<lookupId>.<secret>`, a user PAT or agent key). Api keys are **global** per-principal credentials, not space-bound: the same key works in any space the principal has been admitted to (the space comes from `X-Me-Space`, gated by `build_tree_access`). better-auth owns session + OAuth-token storage (OAuth access/refresh tokens hashed sha256 at rest); api-key secrets are **core** sha256 (compared by equality in SQL), not argon2. Full design: `AUTH_DESIGN.md`.
- **Embedding**: Vercel AI SDK; OpenAI `text-embedding-3-small` (1536-dim) in production; Ollama supported for local dev.
- **CLI**: `me` binary — `login`, `logout`, `whoami`, `space`, `group`, `access`, `agent`, `apikey`, `memory` (+ top-level aliases like `me search`, `me create` — except `import`), `import` (the source group: `memories`/`claude`/`codex`/`opencode`/`git`; `me memory import` and `me <tool> import` remain as aliases), `mcp`, `claude`/`codex`/`gemini`/`opencode`, `serve`, `pack`.
- **CLI**: `me` binary — `login`, `logout`, `whoami`, `space`, `group`, `access`, `agent`, `apikey`, `memory` (+ top-level aliases like `me search`, `me create` — except `import`), `import` (the source group: `memories`/`claude`/`codex`/`opencode`/`granola`/`git`; `me memory import` and `me <tool> import` remain as aliases), `mcp`, `claude`/`codex`/`gemini`/`opencode`, `serve`, `pack`.

## Principals, members, spaces (terminology)

Expand Down
78 changes: 78 additions & 0 deletions docs/cli/me-import.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Get data into Memory Engine — one subcommand per source.
- [me import claude](#me-import-claude--codex--opencode) -- import Claude Code sessions
- [me import codex](#me-import-claude--codex--opencode) -- import Codex sessions
- [me import opencode](#me-import-claude--codex--opencode) -- import OpenCode sessions
- [me import granola](#me-import-granola) -- import Granola meeting notes and transcripts
- [me import git](#me-import-git) -- import a repo's git commit history
- [me import git-hook](#me-import-git-hook) -- install a post-commit hook that keeps git history memories current

Expand Down Expand Up @@ -41,6 +42,83 @@ See [agent session imports](agent-session-imports.md) for the shared option refe

---

## me import granola

Import meetings from [Granola](https://granola.ai) — one memory per meeting, holding the AI summary notes and (by default) the full transcript. Past meetings become searchable agent context ("what did we decide about X", "who was in the Y review").

```
me import granola [options]
```

| Option | Description |
|--------|-------------|
| `--tree-root <path>` | Tree root under which `<document_id>` leaves are placed. Default: `~/granola`. |
| `--since <iso>` | Only import meetings started at or after this ISO 8601 timestamp. |
| `--until <iso>` | Only import meetings started at or before this ISO 8601 timestamp. |
| `--no-transcript` | Import notes only, skipping the full meeting transcript (and its per-meeting API call). |
| `--include-invalid` | Include notes Granola did not flag as a valid meeting (ad-hoc notes, calendar stubs). |
| `--granola-dir <dir>` | Override the Granola application-support directory (default: the standard macOS path). |
| `--dry-run` | Fetch and report what would be imported without writing. |

### Authentication (no separate login)

The import reuses the **Granola desktop app's** existing session — there is no separate `me`-side Granola login. It reads Granola's locally-stored, `safeStorage`-encrypted WorkOS tokens (decrypting them via the macOS login keychain), refreshes the short-lived access token through Granola's API, then pulls your meetings. Requirements:

- The Granola desktop app is **installed and signed in** on this machine.
- **macOS only** for now (the credential read uses the login keychain). On other platforms the command exits with an actionable error.

If the token refresh fails (e.g. Granola has been signed out), open the Granola app to refresh its session and re-run.

### Tree layout

Each meeting is a named leaf (its Granola `document_id`) under the tree root:

```
<tree-root>/<document_id>
```

The default root is your personal home (`~/granola`), so meetings are private to you. Pass `--tree-root /share/meetings` (or similar) to import into a shared space instead.

### Content shape

Each memory's content is a self-contained Markdown document: a title heading, a metadata line (date, attendees), the AI **summary notes**, and — unless `--no-transcript` — the full **transcript**. Notes are sourced, in order of preference, from the meeting's own `notes_markdown`, then an AI summary panel (its structured content, else its HTML). Transcript segments are grouped into speaker turns labelled `Me` (your microphone) and `Them` (everyone else); Granola does not attribute remote speakers by name.

Meetings Granola flagged as not a valid meeting are skipped by default (`--include-invalid` keeps them), as are meetings with neither notes nor a transcript.

### Idempotency and re-runs

Idempotency is keyed on `(tree, document_id)` — each meeting is named by its Granola document id. The id is a timestamp-prefixed UUIDv7 (meeting start in the prefix, random tail), so meetings sort by date on the id. Re-imports reconcile in place via the server's content-aware upsert: an unchanged meeting is a no-op, a meeting whose notes/transcript changed (or an importer-version bump) is rewritten, and nothing is ever duplicated. Run it on a schedule to keep your meeting memory current.

### Metadata

| Key | Description |
|-----|-------------|
| `type` | Always `"granola_meeting"`. |
| `source_tool` | Always `"granola"`. |
| `source_document_id` | Granola document id (also the leaf name). |
| `display_name` | Human label for the web tree (`"Title — YYYY-MM-DD"`); the leaf `name` stays the document id so re-imports stay idempotent. |
| `source_workspace_id` | Granola workspace id (when present). |
| `source_calendar_event_id` | Google Calendar event id (when the meeting has one). |
| `attendees` | Calendar attendee emails (when present). |
| `content_mode` | `"with_transcript"` or `"notes_only"`. |
| `has_notes` / `has_transcript` | Whether each section was captured. |
| `transcript_segment_count` | Number of transcript segments. |
| `valid_meeting` | Granola's valid-meeting flag (when set). |
| `importer_version` | Version tag of the importer schema. |

Temporal spans the meeting: calendar start→end when known, else the meeting's created time and last transcript segment.

### Example

```bash
me import granola --dry-run # preview everything Granola has
me import granola # full import (notes + transcripts) into ~/granola
me import granola --no-transcript # notes only (faster; fewer API calls)
me import granola --since 2026-01-01 # just this year's meetings
```

---

## me import git

Import a repo's git commit history as memories — one memory per commit, holding the commit message plus a capped changed-file list. Commit intent ("why did we do X") and touched paths become searchable agent context.
Expand Down
3 changes: 3 additions & 0 deletions docs/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,9 @@ Metadata is indexed with a GIN index, making attribute-based filtering fast. You
| `status` | Track lifecycle | `"active"`, `"implemented"`, `"superseded"`, `"archived"` |
| `source` | Where it came from | `"slack"`, `"meeting"`, `"docs"`, `"code-review"` |
| `confidence` | How certain you are | `"high"`, `"medium"`, `"low"` |
| `display_name` | Human label for the web tree | `"Weekly Sync — 2026-06-23"` |

`display_name` is a presentation hint: the web UI's tree view prefers it over the memory's `name` and content when labelling a leaf. Use it when the stable `name` is an opaque id (e.g. an importer keying idempotency on a source id) but you still want a readable label. It does not affect addressing or search.

### Meta vs. tree

Expand Down
197 changes: 197 additions & 0 deletions packages/cli/commands/import-granola.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
/**
* `me import granola` — import Granola meeting notes & transcripts as memories.
*
* Granola stores its meetings behind its cloud API, but persists the signed-in
* session locally (encrypted with Electron safeStorage). We read & decrypt that
* session, refresh the access token, then pull every meeting and write one
* memory per meeting under `<tree-root>.<document_id>` (default `~/granola`).
* Idempotency is keyed on `(tree, name=document_id)`, so re-runs reconcile in
* place via the server's content-aware `onConflict: "replace"`.
*
* macOS only for now (the local-credential read uses the login keychain).
*/

import * as clack from "@clack/prompts";
import { Command } from "commander";
import { resolveCredentials } from "../credentials.ts";
import {
GranolaAuthError,
readGranolaTokens,
} from "../importers/granola/auth.ts";
import {
DEFAULT_GRANOLA_TREE_ROOT,
type GranolaImportOptions,
type GranolaImportResult,
runGranolaImport,
} from "../importers/granola/index.ts";
import { createProgressReporter } from "../importers/index.ts";
import { getOutputFormat, output } from "../output.ts";
import {
buildMemoryClient,
handleError,
requireAuth,
requireSpace,
} from "../util.ts";
import { VALID_TREE_ROOT_RE } from "./import.ts";

/** Validate raw Commander opts into a typed import-option set (minus secrets). */
function buildGranolaOptions(
opts: Record<string, unknown>,
): Omit<GranolaImportOptions, "refreshToken"> {
const treeRoot =
typeof opts.treeRoot === "string"
? opts.treeRoot
: DEFAULT_GRANOLA_TREE_ROOT;
if (!VALID_TREE_ROOT_RE.test(treeRoot)) {
throw new Error(
`Invalid --tree-root: '${treeRoot}'. Use ltree labels ([A-Za-z0-9_-]) ` +
`separated by '.' or '/', with an optional leading '~' for your home.`,
);
}
for (const field of ["since", "until"] as const) {
const value = opts[field];
if (typeof value === "string" && Number.isNaN(Date.parse(value))) {
throw new Error(
`Invalid --${field}: '${value}' is not a valid ISO 8601 timestamp`,
);
}
}
return {
granolaDir:
typeof opts.granolaDir === "string" ? opts.granolaDir : undefined,
treeRoot,
since: typeof opts.since === "string" ? opts.since : undefined,
until: typeof opts.until === "string" ? opts.until : undefined,
// --include-invalid disables the default skip of non-meeting notes.
skipInvalid: opts.includeInvalid !== true,
// Transcripts are included by default; --no-transcript turns them off.
includeTranscript: opts.transcript !== false,
dryRun: opts.dryRun === true,
};
}

/** Run one Granola import end-to-end and render the outcome. */
export async function runGranolaImportCommand(
rawOpts: Record<string, unknown>,
globalOpts: Record<string, unknown>,
): Promise<void> {
const creds = resolveCredentials(
typeof globalOpts.server === "string" ? globalOpts.server : undefined,
);
const fmt = getOutputFormat(globalOpts);
requireAuth(creds, fmt);
requireSpace(creds, fmt);

let opts: Omit<GranolaImportOptions, "refreshToken">;
try {
opts = buildGranolaOptions(rawOpts);
} catch (error) {
handleError(error, fmt);
}

// Read & decrypt Granola's local session before touching the network.
let refreshToken: string;
try {
refreshToken = readGranolaTokens(opts.granolaDir).refresh_token;
} catch (error) {
if (error instanceof GranolaAuthError) {
handleError(error, fmt);
}
throw error;
}

const engine = buildMemoryClient(creds);
const progress =
fmt === "text" ? createProgressReporter(process.stderr) : undefined;
progress?.start();

let result: GranolaImportResult;
try {
result = await runGranolaImport(
engine,
{ ...opts, refreshToken },
progress,
);
} catch (error) {
progress?.stop();
handleError(error, fmt);
} finally {
progress?.stop();
}

renderGranolaResult(result, fmt);
if (result.failed > 0 && result.inserted === 0 && result.updated === 0) {
process.exit(2);
}
if (result.failed > 0) process.exit(1);
}

/** Print the import result in text or structured format. */
function renderGranolaResult(
result: GranolaImportResult,
fmt: "text" | "json" | "yaml",
): void {
output(result, fmt, () => {
const verb = result.dryRun ? "Would import" : "Imported";
clack.log.success(
`${verb} ${result.inserted} new, ${result.updated} updated, ` +
`${result.skipped} unchanged, ${result.failed} failed meetings ` +
`into ${result.tree}`,
);
console.log(` Scanned ${result.meetingsSeen} Granola meetings`);
const skipTotal = Object.values(result.skipReasons).reduce(
(a, b) => a + b,
0,
);
if (skipTotal > 0) {
const parts = Object.entries(result.skipReasons)
.filter(([, n]) => n > 0)
.map(([reason, n]) => `${reason}=${n}`);
console.log(` Meetings skipped: ${parts.join(", ")}`);
}
if (!result.includeTranscript) {
console.log(" Transcripts omitted (--no-transcript)");
}
for (const e of result.errors) {
console.log(` ✗ ${e.documentId}: ${e.error}`);
}
});
}

/** `me import granola` subcommand factory. */
export function createGranolaImportCommand(): Command {
return new Command("granola")
.description("import Granola meeting notes and transcripts as memories")
.option(
"--tree-root <path>",
`tree root under which '<document_id>' leaves are placed (default: ${DEFAULT_GRANOLA_TREE_ROOT})`,
)
.option(
"--since <iso>",
"only import meetings started at or after this timestamp",
)
.option(
"--until <iso>",
"only import meetings started at or before this timestamp",
)
.option(
"--no-transcript",
"import notes only, skipping the full meeting transcript",
)
.option(
"--include-invalid",
"include notes Granola did not flag as valid meetings",
)
.option(
"--granola-dir <dir>",
"override the Granola application-support directory",
)
.option(
"--dry-run",
"fetch and report what would be imported without writing",
)
.action(async (opts, cmdRef) => {
const globalOpts = cmdRef.optsWithGlobals();
await runGranolaImportCommand(opts, globalOpts);
});
}
3 changes: 3 additions & 0 deletions packages/cli/commands/import-group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
* me import codex Codex sessions
* me import opencode OpenCode sessions
* me import git [repo] git commit history
* me import granola Granola meeting notes & transcripts
*
* There is deliberately no bare default: `me import <file>` does not parse.
* The pre-group spellings stay registered as aliases built from the same
Expand All @@ -23,6 +24,7 @@ import {
} from "./import.ts";
import { createGitImportCommand } from "./import-git.ts";
import { createGitHookCommand } from "./import-git-hook.ts";
import { createGranolaImportCommand } from "./import-granola.ts";
import { createMemoryImportCommand } from "./memory-import.ts";

export function createImportCommand(): Command {
Expand All @@ -33,6 +35,7 @@ export function createImportCommand(): Command {
imp.addCommand(createClaudeImportCommand("claude"));
imp.addCommand(createCodexImportCommand("codex"));
imp.addCommand(createOpenCodeImportCommand("opencode"));
imp.addCommand(createGranolaImportCommand());
imp.addCommand(createGitImportCommand());
imp.addCommand(createGitHookCommand());
imp.addHelpText(
Expand Down
Loading