Add Settings item to the app menu with Cmd+,#125
Closed
statico wants to merge 316 commits into
Closed
Conversation
Add transcription lab and upgrade fast cleanup model
…dexSheet Plus-menu now offers "New People index" alongside personal note / ad-hoc / Granola import. Selecting it opens BuildIndexSheet: - estimating phase shows a spinner while count_tokens runs; - ready phase shows "≈ \$X — N meetings — ~M tokens, could be up to 3x"; - building phase shows running cost and entries-written count, with Stop; - completed/failed phases close cleanly. The sidebar grows an "Indexes" section above the date groups, listing one entry per dossier (with the configured kind icon — person.3 for People). Clicking opens the entry as a new MeetingSurface case (`.indexEntry`). IndexEntryView renders the dossier body with `[[Wikilink]]` cross-refs as tappable buttons that open the linked entry, plus a "Source meetings" section with quick-jump links back to the original markdown. State plumbing: - MeetingWindowState.indexItems (per-kind lists) loaded from disk on appear, on .indexUpdated, and after the build sheet closes. - AppState owns the lazy IndexBuilder per kind; the controller's onMakeIndexBuilder bridges into the window state. Missing API key surfaces a small inline view pointing to Settings. - AppState.finishMeetingSession now triggers People index incremental updates after a recording stops, but only if an index already exists on disk and an API key is configured. Search filters indexes the same way it filters meetings. Wikilinks parse via a tiny WikilinkParser that splits the body into .text / .wikilink segments and renders each appropriately. Plain text goes through AttributedString(markdown:) for inline formatting. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
People (167) collapses by default; click chevron to expand. Search auto- expands all matching folders. Avoids drowning the sidebar in entries on large archives. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each opened dossier becomes its own OpenIndexTab in the file tab bar with the canonical name as the tab title and the kind icon as the leading glyph. Closing the tab is the standard X. Re-opening an already-open entry switches to its existing tab rather than reloading. Refactor: MeetingSurface.indexEntry(kind, slug) → .indexTab(UUID); the single activeIndexEntry slot is replaced by an indexTabs array so multiple dossiers can be open simultaneously and switched between like meetings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Click a [[wikilink]] or source-meeting link → the current tab navigates to the target (replaces content, pushes prior content onto a per-tab history). Right-click the link → "Open in new tab" creates a sibling. Cmd+[ goes back; the back button is disabled with no history. Implementation: OpenIndexTab now holds a NavTabContent enum (either an index-entry payload or a meeting payload, both wrappable for read-only display) plus a navigation stack. NavTabContentView renders a small back-bar above the content and dispatches to IndexEntryView or MeetingTabContentView depending on the current case. External URLs are unchanged; only in-app cross-refs gain this behavior. Meeting tabs opened from the regular sidebar still use OpenMeetingTab with full recording-capable state. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…People index Three feature areas land together: 1. Agentic cross-meeting Q&A — provider-neutral LLMProvider protocol, an AnthropicProvider that streams over the Messages API, a MeetingQAAgent that runs a tool-use loop with grep / read_file / list_dir over the meeting archive, sandboxed by PathSandbox. UI surfaces the agent's trace (status line, expandable trace pane, Stop button, running cost). Drops the obsolete local-QA backend; agentic Q&A is Anthropic-only. 2. Today's calendar list + currentMeeting hardening — eventsForToday() with multi-calendar support, 60s memory cache + disk-backed offline fallback, OAuth scope upgrade (calendar.readonly) with primary-only fallback for legacy connections; a hardened currentMeeting() that filters all-day, declined, and ended events; smarter join-link extraction. MeetingAttendee gains a declined flag surfaced with strikethrough; applyCalendarEvent locks user-picked events. 3. People index — generated dossier folders alongside meetings. Plus-menu offers New People index; BuildIndexSheet shows an estimate then streams progress with running cost and Stop. Index agent walks the archive and writes one dossier per canonical person to .indexes/people/<slug>.md with YAML frontmatter (canonical_name, aliases, source_meetings, last_updated). _manifest.json tracks processed meetings for incremental updates on .meetingRecordingStopped. Sidebar gains a collapsible People (N) folder; entries open as real tabs. In-tab links navigate browser-style: click navigates in place (back button + Cmd+[), right-click for Open in new tab. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…file The 30-iteration cap was the limiting factor on full archive coverage — 787 meetings need hundreds of tool calls and the agent ran out of rope. Bumped to 200 iterations; cost mostly compounds via prompt caching past that point so the new ceiling is still cost-bounded but no longer the binding constraint for typical archive sizes. Also persist the manifest after every successful write_file so hitting Stop mid-build no longer loses dedupe state. Partial manifests don't mark meetings as processed (only completed builds do that), so a resumption sees a manifest present, reads existing entries via the on-disk .md files, and appends rather than recreating. Updated full-build prompt with a new Step 1: list_dir the index dir first, read_file any existing entry before writing, preserve existing body. Makes resumption non-destructive. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…exist
The old estimate used count_tokens on a single iteration, ignoring the
fact that each agent iteration appends prior tool results to context.
For a 787-meeting archive it was off by ~100x. Replaced with a
per-meeting heuristic, calibrated against observed runs:
Haiku 4.5: $0.002–$0.006 per meeting
Sonnet 4.6: $0.005–$0.018 per meeting
Opus 4.7: $0.025–$0.075 per meeting
The dialog now shows a range ("Likely $X–$Y") with a clear note that
it's order-of-magnitude and the running cost during the build is
authoritative. Estimate is also faster to render — no API call needed.
Detect existing entries on disk and surface a "resume" banner: the
button label flips to "Resume" and the copy explains that the agent
will read each existing dossier before writing, so re-running on an
already-built index appends and updates rather than starting over.
Pairs with the prompt change in the prior commit which instructs the
agent to list_dir + read_file existing entries before writing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the full-build flow handed the agent every meeting in the archive on every run, relying on the prompt to make it append rather than recreate. Wasteful: cost paid twice for already-covered ground. Now we filter. coveredMeetings(in:kind:) walks every existing entry's source_meetings and returns the union — meetings that show up in *any* entry are considered done. The unprocessed list is what gets passed to the agent, so resume genuinely skips covered work. Estimate uses the unprocessed count, not the archive size, so the cost range scales down on resume. The dialog explains what's happening: "Resuming existing index: 39 entries on disk, 187 of 787 meetings already covered. This run will only process the remaining 600." Building view gains a progress bar driven by a new .meetingsProcessed(processed, total) event. After each successful write_file the builder re-reads the entry, picks up its source_meetings, and emits an updated count. You can watch it advance toward 787. Edge case: if every meeting is already covered, we skip the agent entirely, show a green "Index is up to date" banner, and the only button is "Done". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…xListView tab Three related UI moves: 1. **Move People index trigger off the + menu and onto the home view.** It now lives next to the Granola sync row with the same status semantics: - "Build People index from N meetings" (no entries yet) - "Sync N new into People index" (entries exist, some meetings unprocessed) - "People index up to date" with a re-sync icon (everything covered) Refresh on app focus, .meetingRecordingStopped, and .indexUpdated. 2. **Sidebar 'People (N)' becomes a single-click row.** Drops the expandable disclosure (which would have been unwieldy at hundreds of entries — earlier instinct confirmed). Click → opens an IndexListView tab. 3. **New IndexListView surface.** Searchable, scrollable list of every entry in the kind. Click a name → navigates the *current tab* to that dossier (browser-style, back works). Right-click → "Open in new tab". Empty state has a "Build" CTA. Plumbing: NavTabContent gains an .indexList(kind) case; state.openIndexList(kind:) creates or focuses the tab. The "+" menu loses its New People index entry — it was redundant once the home row exists. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…d of running silently Previously the per-entry refresh would have run a silent agent and overwritten the dossier. Better: the ↻ button now types "Look up what we know about <name> and augment their People page." into the bottom Q&A bar and fires it. Same agent infrastructure (Q&A trace, streaming, Stop button), but the user can see the search happening, learn what kinds of prompts work, and decide what to do with the answer. Implementation: a new MeetingWindowState.pendingQAPrompt that deep views publish into; MeetingRootView observes it on .onChange, fills qaQuestion and triggers askAcrossMeetings, then clears the slot. Future: a follow-up "Apply changes to dossier" button could surface after the agent finishes, with confirmation, to actually write back to the .md file. v1 leaves that to the user — they can manually edit the markdown if they want to fold the answer in. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the ↻ button on a dossier kicks off a Q&A run, remember the target (kind/slug/canonicalName) in state.pendingDossierApply. After the run finishes, an orange "Apply to <name>'s dossier" button surfaces between the streamed answer and the input row, with a Discard option. Click Apply → take the trimmed answer, write it as the entry's new body, update last_updated, and live-update any open tab showing that entry. The pending apply is cleared whenever the user manually submits a different question, hits the X to clear the Q&A bar, or clicks Discard — so a stray Apply button never targets the wrong dossier. Frontmatter (canonical_name, aliases, source_meetings) stays as-is. We don't try to parse new aliases or sources out of free-form prose. The prompt sent to the agent now asks for a thorough markdown dossier with sections + wikilinks, so the answer is shaped like dossier content. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ssier on Apply The visible Q&A prompt becomes "Tell me about <Name>" — short, clear, educational; the user could have written it themselves. The merge instructions (preserve existing body, fold in new info, no frontmatter, use sections + wikilinks) are embedded in the *Apply* step instead, as a separate single-shot LLM call that has access to the existing dossier body. Apply now: 1. Reads the existing dossier from disk 2. Calls IndexBuilder.mergeDossierBody — system prompt = merge rules, user message = existing body + new findings, no tools, prose-only output 3. Writes the merged body back; updates last_updated; live-refreshes any open tab showing the entry Button label is "Apply to <slug>.md" so the user sees exactly which file they're touching. While merging it shows "Merging into <slug>.md…" with a spinner; both buttons disable during the merge call. Cost: one extra small LLM call per Apply (~few cents on Sonnet, ~1 cent on Haiku). User can Discard to back out before the call. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Apply was only updating body + lastUpdated. Now scans both the Q&A answer and the merged body for `YYYY-MM-DD/<slug>.md` patterns and folds any newly-cited meetings into the entry's source_meetings frontmatter (union with existing, sorted). Tolerates trailing `:line` numbers from Q&A-style citations. Aliases stay as-is — extracting those reliably from prose needs more care than a regex. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…agraphs, links The dossier body was being rendered with inline-only AttributedString, so `## About` showed as literal hashes, bullets as literal dashes, and wikilinks rendered on their own line because each segment was a separate View in a vertical stack. Replaced with a small block-level markdown parser (MarkdownBlockParser) that splits the body into headings, paragraphs, bullet lists, and fenced code blocks. Each block renders with appropriate fonts and spacing. Within each block, [[Wikilinks]] are rewritten to `wikilink://<slug>` links and rendered via AttributedString — so they stay inline with the surrounding text and word-wrap naturally. Taps are intercepted via an `.environment(\.openURL, ...)` override on the view root, which routes to onOpenEntry. Right-click → "Open in new tab" on individual wikilinks isn't preserved (links inside Text don't support context menus); the right-click affordance still exists on the People list and on source-meeting links. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SwiftUI's Text(AttributedString) doesn't support per-link context menus, so right-click on a wikilink inside a dossier body wasn't surfacing the "Open in new tab" affordance after the markdown render rewrite. Use the macOS browser convention instead: Cmd+click → open in new tab; plain click → navigate in place. Modifier check happens inside the openURL interceptor via NSApp.currentEvent. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The "X meetings to process using Sonnet 4.6" line is now an inline Picker — Opus 4.7 / Sonnet 4.6 / Haiku 4.5 — bound to the same @AppStorage("claudeAPIModel") that drives Settings → Cross-Meeting Q&A. Cost range recalculates instantly on change (heuristic only, no API call needed). AppState's index-builder cache is now keyed on the active model, so calling makeIndexBuilder after the picker changes the model returns a freshly-constructed builder with the new provider rather than a stale cached one. The sheet fetches the builder on demand at click time via a `fetchBuilder` closure rather than receiving it once at sheet open. This way the picker's current model selection is honored without any explicit "recreate builder" plumbing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…sh, timestamp Every dossier now carries a `generation:` block recording how its body was produced — Claude API model, named prompt template, SHA-256 prefix of the actual prompt content, and timestamp. Stamped automatically by IndexBuilder after every successful write_file (full-build, incremental update) and by the merge call inside Apply. Stored as flat YAML fields in the entry's frontmatter (`generated_by_model`, `generated_by_prompt`, `generated_by_hash`, `generated_at`) so it survives external editing without breaking the parser. In the entry view, the bare ↻ refresh icon becomes a small grey pill: "Sonnet 4.6 generated ↻". Clicking still kicks off the same Q&A-driven refresh; hovering shows full provenance (date, prompt kind, hash) as a tooltip. Falls back to a plain icon for entries without provenance (pre-existing dossiers from before this commit). The hash is the first 12 hex chars of SHA-256(prompt). When we change a prompt, the hash changes — so we can tell at a glance which prompt revision wrote a given entry, and find stale ones if needed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ew" button The bottom Q&A bar is now a thread, not a single answer. State moves from a flat (qaAnswer, qaUsage) pair to qaThread: [QATurn] — each turn holds its own question, streaming answer, usage, and isStreaming flag. The thread renders top-to-bottom with the active streaming turn at the bottom; the scrollview auto-pins to the latest answer so follow-ups stay in view. Each new question carries the prior thread as conversation history. The agent's loop now accepts pre-built [LLMMessage] (alternating user/ assistant) so prior turns become context — "tell me about Ryan" → "what fund does he run" now resolves "he" correctly. Inline picker on the input row, bound to @AppStorage("claudeAPIModel") so it shares the setting with the build sheet and Settings. Model choice applies on the next send (the existing run keeps its model). The X clear icon becomes a labeled "+ New" button — visible only when the thread is non-empty, with a tooltip explaining what it does. Cancels any in-flight stream and resets all Q&A state including pendingDossierApply. Apply-to-dossier now uses the *latest* turn's answer, since that's typically the refined follow-up the user actually wants to merge. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cmd+K opens a search sheet that filters across all three content types
in real time. Results are grouped under labeled sections (PEOPLE, MEETINGS,
NOTES) with the kind icon and a count. Click → opens the selected item
in the current tab; Enter → activates the first match; Esc → dismisses.
People are matched against canonical_name; meetings/notes against
filename + date folder. Notes are split out from meetings by the
quick-note filename prefix. Each section is capped at 12 to keep the
palette tight.
Implementation: a new CommandKSearchSheet view + a hidden Button at the
root attached to .keyboardShortcut("k", modifiers: .command). The
keyboard binding fires whether or not the search field is focused; once
the sheet appears, the field auto-focuses for typing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
↑/↓ moves a highlight through the flattened result list (people, then meetings, then notes); the highlighted row gets an orange background and a tiny ↩ glyph at the right edge. Enter activates the highlighted item instead of always firing the first match. Hover also moves the highlight, so mouse and keyboard users stay in sync. The selection auto-scrolls into view. Inline key hints (↑↓ ⏎) live in the search field's right side, next to ESC. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Typing felt slow because the palette's results were a computed property re-evaluated on every body access. With ~1000 candidates (people + meetings + notes), each keystroke triggered ~5 full scans plus a fresh batch of CommandKItem closure allocations. SwiftUI also re-rendered the view tree multiple times per keystroke. Two changes: 1. Snapshot a flat haystack at sheet-open time, with each row's title pre-lowercased so per-keystroke filtering is a tight String.contains loop with no dictionary traversal or repeated lowercasing. 2. Cache the filtered results and flat list in @State, recomputed only when the query actually changes (.onChange). All result reads in body now hit cached values; no recompute per access. The haystack snapshot is taken on .onAppear, so it doesn't observe live updates to indexItems / historyGroups during the sheet's lifetime — fine for a transient palette; if the user dismisses + reopens, they get a fresh snapshot. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors browser convention: Cmd+N → fresh tab. Wires the existing
state.startNewNote() to a hidden Button at the root with
.keyboardShortcut("n", modifiers: .command). Same hidden-button pattern
as the Cmd+K palette trigger.
Note tabs don't start recording — they're just a markdown editor for
quick capture, same as picking "New personal note" from the + menu.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The "What Ghost Pepper does" section in the right Models pane is now
live pickers for Speech-to-text, Cleanup, and Agent. Each picker is
filtered to choices the user can actually use:
- Speech-to-text: only speech models the user has downloaded
- Cleanup: only downloaded cleanup models (Meeting summary inherits
this — labeled "(same as Cleanup)" rather than getting its own
picker, since the underlying setting is shared)
- Agent: Claude models, only when the API key is set
When a section has no usable choices — e.g. no speech models downloaded
yet, or no Claude key — the picker is replaced with a small hint
pointing to the right Settings panel ("Download a speech model in
Settings → Models", etc.) so the path to fix is obvious.
Selection writes through @AppStorage to the same key the rest of the
app reads, so changing the model here also changes it for the build
sheet, the Q&A bar, and incremental updates. Diarization stays a
status row since it's a property of the chosen speech model, not an
independent selection.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rename Models panel (right sidebar): - Inline download/delete buttons per local model - Cancel-on-download for llama.cpp-served cleanup models - DeepSeek R1 Distill Qwen 7B added to the cleanup catalog Usage report (new right-sidebar tab, segmented picker switches with Models): - UsageStatsStore tracks dictation, meetings, granola, people-pages, Q&A - One-time backfill from disk uses dated folder names (not filesystem ctime, which collapses years of meetings into a single day after restores) - 7d / 30d / lifetime window picker drives chart, totals, and prompt - Bar chart + per-row recent/lifetime breakdown - "Generate feature requests" via the selected cleanup model with a pre-ranked prompt so small (0.8B) models can't invert most/least Granola sync: - Auto-imports on sheet open (local cache first, then API if key set) - v6 encrypted cache detected; explicit "use API key" pivot button - "Sync Granola" button replaces the hung "Checking Granola..." state - NotificationCenter .granolaImported drives the usage counter Naming + chrome: - Menu bar dropdown entry "Meetings..." -> "IDE..." - README footer (factorialcap.com + @matthartman) - Privacy table row updated to reflect local-only usage counters Bundles in pre-existing in-flight work: agent backend selector (cloud vs local llama.cpp + Qwen tool-call parser), Reader capture, QA answer/attachment UI, meeting attendee + transcript polish. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Made with Claude Code.
Adds a standard "Settings…" item to the application menu (bound to Cmd+,) via SwiftUI
.commands, with a gearshape icon for macOS Tahoe. Settings was previously only reachable from the menu bar dropdown, which can be hidden.