Skip to content
Merged
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
3 changes: 3 additions & 0 deletions Casks/transcripted.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
zap trash: [
"~/Library/Application Support/Transcripted",
"~/Library/Caches/com.justinbetker.draft",
"~/Library/Input Methods/Transcripted Keyboard.app",
"~/Library/Preferences/com.justinbetker.draft.plist",
"~/Library/Preferences/com.justinbetker.draft.inputmethod.Transcripted.plist",
"~/Library/Preferences/com.justinbetker.draft.writing.plist",
]
end
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ Markdown file on your Mac. Then you can ask Claude, or any AI, things like
you were typing.
- **Knows who's talking.** It splits the transcript by speaker. Name someone
once and it'll suggest their name next time it hears them.
- **Helps you write.** Autocomplete finishes your sentences, right where
you're typing. Press `Tab` to take the next word. It can also save what you
wrote, so your AI sees your notes and replies next to your meetings. Both
are optional, and you pick the apps.

Everything becomes a text file with timestamps and speaker names. You can also
drop in an audio or video file you already have.
Expand Down Expand Up @@ -100,6 +104,8 @@ Files are saved here by default. You can pick any folder in Settings.
~/Library/Application Support/Transcripted/captures/
```

Meetings, dictations, and writing each get their own folder in there.

## Install

You need an Apple Silicon Mac on macOS 26 or later.
Expand All @@ -114,13 +120,20 @@ brew tap r3dbars/transcripted https://github.com/r3dbars/transcripted
brew install --cask transcripted
```

Autocomplete downloads a model the first time you turn it on. The default,
Gemma, needs about 3.4 GB of disk. The optional Qwen model takes 5.6 GB and
needs a Mac with 16 GB of memory.

## Privacy

- Your audio and transcripts never leave your Mac.
- No bot joins your calls.
- The app sends anonymous crash reports and usage stats. They never include
audio, transcripts, names, or file paths. You can turn both off in
**Settings → Privacy**.
- What you write stays on your Mac. The only thing Writing sends is anonymous
suggestion counts, never text, and the same usage stats switch turns them
off.

Where everything is stored: [docs/storage-paths.md](docs/storage-paths.md).

Expand All @@ -137,6 +150,24 @@ Nothing. No account, no subscription.
**Is there a command-line tool?**
Yes. See the [CLI instructions](Tools/TranscriptedCLI/README.md).

## Uninstall

1. Quit Transcripted from the menu bar.
2. If you set up Writing, go to **System Settings → Keyboard → Input
Sources** and remove Transcripted.
3. Drag Transcripted out of Applications, or run
`brew uninstall --cask transcripted`.
4. Delete the keyboard, if it's there:
`~/Library/Input Methods/Transcripted Keyboard.app`

Your meetings, dictations, and writing stay where they are. To also delete the
Writing model and Writing's app data, remove these folders:

```text
~/Library/Application Support/Transcripted/models/writing/
~/Library/Application Support/Transcripted/writing/
```

## For contributors

It's a native Swift app. Build and test:
Expand Down
10 changes: 10 additions & 0 deletions Sources/UI/Settings/AgentConnectionSettingsPage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ struct AgentConnectionSettingsPage: View {

private let meetingsFolderURL = AgentConnectionGuide.meetingsFolder
private let dictationsFolderURL = AgentConnectionGuide.dictationsFolder
private let writingFolderURL = AgentConnectionGuide.writingFolder
@State private var detectedAgents: Set<AgentMCPAgent> = []
@State private var connectedAgents: Set<AgentMCPAgent> = []
@State private var rowPhases: [AgentMCPAgent: RowPhase] = [:]
Expand Down Expand Up @@ -309,6 +310,15 @@ struct AgentConnectionSettingsPage: View {
reveal(dictationsFolderURL)
}

AgentFolderRow(
name: "Writing",
detail: "Writing Markdown files.",
path: writingFolderURL.path,
isAvailable: folderExists(writingFolderURL)
) {
reveal(writingFolderURL)
}

SettingsInlineActionButton(
title: copiedFolderPaths ? "Copied" : "Copy Paths",
symbolName: "folder",
Expand Down
16 changes: 15 additions & 1 deletion Sources/UI/Shared/AgentConnectionGuide.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ enum AgentConnectionGuide {
DictationStoragePaths.transcriptsFolder
}

/// `<capture-library>/writing/`. The pure path form, so building a prompt
/// never creates the folder for people who don't use Writing.
static var writingFolder: URL {
FileManager.writingDirectory(in: FileManager.default.transcriptedCaptureLibraryDir)
}

static var codexInboxFolder: URL {
FileManager.default.transcriptedAppSupportDir
.appendingPathComponent("CodexInbox", isDirectory: true)
Expand Down Expand Up @@ -115,14 +121,17 @@ enum AgentConnectionGuide {
Dictations:
- \(dictationsFolder.path)

Writing:
- \(writingFolder.path)

Use these files as the source of truth.

Good first asks:
- Summarize my latest meeting. Tell me which Transcripted source you used, including the filename/date, then list decisions and action items.
- Review yesterday. Use recent context or a recap to tell me what I promised, what changed, and what I should follow up on today.

Rules:
- Prefer Transcripted direct tools when available; otherwise search meetings and dictations together from files.
- Prefer Transcripted direct tools when available; otherwise search meetings, dictations, and writing together from files.
- Cite filenames, dates, speakers, and timestamps when useful.
- For relative dates like today or yesterday, state the exact dates searched.
- If a direct tool fails, fall back to the folders.
Expand Down Expand Up @@ -202,6 +211,7 @@ enum AgentConnectionGuide {
- Setup prompt file: \(setupPath)
- Meetings: \(meetingsFolder.path)
- Dictations: \(dictationsFolder.path)
- Writing: \(writingFolder.path)
- State file: \(inboxPath)/state.json
- Pending folder: \(inboxPath)/pending
- Processed folder: \(inboxPath)/processed
Expand Down Expand Up @@ -298,6 +308,9 @@ enum AgentConnectionGuide {

Dictations:
\(dictationsFolder.path)

Writing:
\(writingFolder.path)
"""
}

Expand Down Expand Up @@ -335,6 +348,7 @@ enum AgentConnectionGuide {
Use Transcripted's local Markdown meetings as the source of truth:
- Meetings: \(meetingsFolder.path)
- Dictations: \(dictationsFolder.path)
- Writing: \(writingFolder.path)

Rules:
- Process only new unprocessed meetings unless the user asks for backfill.
Expand Down
20 changes: 19 additions & 1 deletion Tests/AgentConnectionGuideTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ func testAgentConnectionGuide() {
"local agent prompt should not include web/Cowork routing"
)
assertTrue(
prompt.contains("Prefer Transcripted direct tools when available; otherwise search meetings and dictations together from files."),
prompt.contains("Prefer Transcripted direct tools when available; otherwise search meetings, dictations, and writing together from files."),
"prompt should support direct-tool retrieval and folder fallback"
)
assertTrue(
Expand Down Expand Up @@ -105,6 +105,15 @@ func testAgentConnectionGuide() {
prompt.contains("Dictations:\n- \(AgentConnectionGuide.dictationsFolder.path)"),
"prompt should include the dictations folder"
)
assertTrue(
prompt.contains("Writing:\n- \(AgentConnectionGuide.writingFolder.path)"),
"prompt should include the writing folder"
)
assertEqual(
AgentConnectionGuide.writingFolder.lastPathComponent,
"writing",
"writing folder should be the capture library's writing/ folder"
)
assertTrue(
prompt.contains("If helpful, start with this meeting:\nPlanning Sync.md"),
"meeting-specific prompt should preserve the selected filename"
Expand Down Expand Up @@ -139,6 +148,10 @@ func testAgentConnectionGuide() {

assertTrue(readme.contains("Transcripted Codex Inbox"), "README should name the Codex Inbox")
assertTrue(agents.contains("Process only new unprocessed meetings"), "AGENTS should tell Codex not to backfill by default")
assertTrue(
agents.contains("- Writing: \(AgentConnectionGuide.writingFolder.path)"),
"AGENTS should list the writing folder next to meetings and dictations"
)
assertTrue(setup.contains("transcripted-inbox-watch"), "setup prompt should name the heartbeat automation")
assertTrue(state.contains("\"processedMeetings\": []"), "state should start with no processed meetings")
assertTrue(state.contains("\"installedAt\""), "state should create a setup baseline")
Expand All @@ -151,6 +164,10 @@ func testAgentConnectionGuide() {
assertTrue(prompt.contains("Transcripted Codex Inbox Setup"), "prompt should title the setup clearly")
assertTrue(prompt.contains(inboxURL.path), "prompt should include the inbox path")
assertTrue(prompt.contains(AgentConnectionGuide.meetingsFolder.path), "prompt should include the meetings folder")
assertTrue(
prompt.contains("- Writing: \(AgentConnectionGuide.writingFolder.path)"),
"prompt should include the writing folder"
)
assertTrue(prompt.contains("Monday through Friday"), "prompt should ask for a weekday schedule")
assertTrue(prompt.contains(":05 and :35"), "prompt should ask for checks after the hour and half-hour")
assertTrue(prompt.contains("stay quiet"), "prompt should keep empty checks silent")
Expand Down Expand Up @@ -194,6 +211,7 @@ func testAgentConnectionGuide() {
)
assertTrue(folderText.contains(AgentConnectionGuide.meetingsFolder.path), "folder copy should include current meetings path")
assertTrue(folderText.contains(AgentConnectionGuide.dictationsFolder.path), "folder copy should include current dictations path")
assertTrue(folderText.contains(AgentConnectionGuide.writingFolder.path), "folder copy should include current writing path")
}

runSuite("AgentConnectionGuide bundled skills — files and manifest are versioned") {
Expand Down
13 changes: 13 additions & 0 deletions Tests/Benchmarks/HomeRecentCaptureBenchmark.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,19 @@ struct HomeRecentCaptureBenchmark {
let runRoot = URL(fileURLWithPath: fileManager.currentDirectoryPath, isDirectory: true)
.appendingPathComponent("build/home-recent-capture-benchmark", isDirectory: true)
.appendingPathComponent(UUID().uuidString, isDirectory: true)

// Harnesses must not touch real user state. Loading resolves the capture
// library through `transcriptedCaptureLibraryDir`, which rewrites
// mcp-directories.json, and the loader's default metadata cache is a
// `static let` whose path is fixed at first access. Point the whole
// app-owned container at this run's scratch folder before anything
// touches storage, so neither lands in ~/Library/Application Support/Transcripted.
let containerRoot = runRoot.appendingPathComponent("container", isDirectory: true).standardizedFileURL
setenv("TRANSCRIPTED_CONTAINER_DIR", containerRoot.path, 1)
guard fileManager.transcriptedMCPDirectoriesManifestURL.standardizedFileURL.path.hasPrefix(containerRoot.path + "/") else {
throw BenchmarkError.configuration("TRANSCRIPTED_CONTAINER_DIR override did not take effect; refusing to touch the real app-support container")
}

let captureRoot = runRoot.appendingPathComponent("captures", isDirectory: true)
let meetingsRoot = captureRoot.appendingPathComponent("meetings", isDirectory: true)
let dictationsRoot = captureRoot.appendingPathComponent("dictations", isDirectory: true)
Expand Down
71 changes: 59 additions & 12 deletions Tests/TranscriptedStoragePathsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,58 @@ func testTranscriptedStoragePaths() {
return attributes?[.posixPermissions] as? NSNumber
}

let originalManifestURL = FileManager.default.transcriptedMCPDirectoriesManifestURL
let originalManifestExists = FileManager.default.fileExists(atPath: originalManifestURL.path)
let originalManifestData = try? Data(contentsOf: originalManifestURL)
defer {
if originalManifestExists, let originalManifestData {
try? FileManager.default.createPrivateDirectory(at: originalManifestURL.deletingLastPathComponent())
try? originalManifestData.write(to: originalManifestURL, options: [.atomic])
FileManager.default.restrictFileToOwnerOnly(at: originalManifestURL)
} else {
try? FileManager.default.removeItem(at: originalManifestURL)
/// Modification time and size, never the contents: the real manifest holds
/// the user's capture-library paths.
func fingerprint(of url: URL) -> String? {
guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path) else {
return nil
}
let modified = (attributes[.modificationDate] as? Date)?.timeIntervalSinceReferenceDate
let size = (attributes[.size] as? NSNumber)?.intValue
return "mtime=\(modified.map { String($0) } ?? "?") size=\(size.map { String($0) } ?? "?")"
}

// Harnesses must not touch real user state. The suites that go through
// `setCaptureLibraryURL` or the `transcriptedCaptureLibraryDir` getter rewrite
// mcp-directories.json at `transcriptedMCPDirectoriesManifestURL`, which follows
// the `TRANSCRIPTED_CONTAINER_DIR` container override that run-tests.sh exports
// as a throwaway folder. Suites that call the writer directly pass their own
// temp `manifestURL`. If the override is missing, stop here rather than write
// (and "restore") ~/Library/Application Support/Transcripted/mcp-directories.json.
let realManifestURL = (
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Library/Application Support", isDirectory: true)
)
.appendingPathComponent("Transcripted", isDirectory: true)
.appendingPathComponent("mcp-directories.json", isDirectory: false)
.standardizedFileURL
let realManifestBefore = fingerprint(of: realManifestURL)
let isolatedManifestURL = FileManager.default.transcriptedMCPDirectoriesManifestURL.standardizedFileURL

var manifestWritesAreIsolated = false
runSuite("Transcripted MCP directory manifest — test writes land in the test container, not the real one") {
guard let containerRaw = ProcessInfo.processInfo.environment["TRANSCRIPTED_CONTAINER_DIR"],
!containerRaw.isEmpty else {
assertTrue(
false,
"TRANSCRIPTED_CONTAINER_DIR must be exported by the test harness so manifest writes stay out of the real app-support container"
)
return
}
// Standardize so a `TMPDIR` trailing slash (→ `//`) can't break the prefix.
let container = URL(fileURLWithPath: containerRaw, isDirectory: true).standardizedFileURL.path
let underContainer = isolatedManifestURL.path.hasPrefix(container + "/")
let isRealManifest = isolatedManifestURL.path == realManifestURL.path

assertTrue(
underContainer,
"the manifest the storage helpers write should live under the test container, got \(isolatedManifestURL.path)"
)
assertFalse(isRealManifest, "the manifest the storage helpers write must not be the real one")
manifestWritesAreIsolated = underContainer && !isRealManifest
}
guard manifestWritesAreIsolated else { return }

runSuite("FileManager.createPrivateDirectory — tightens existing directories to owner-only") {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("TranscriptedStoragePathsTests-existing-\(UUID().uuidString)", isDirectory: true)
Expand Down Expand Up @@ -142,8 +181,8 @@ func testTranscriptedStoragePaths() {
let persisted = TranscriptedStoragePreferences.setCaptureLibraryURL(customRoot)
assertTrue(persisted, "test setup should persist a safe custom capture-library root")

let manifestURL = FileManager.default.transcriptedMCPDirectoriesManifestURL
guard let data = try? Data(contentsOf: manifestURL),
// The container-scoped manifest checked at the top of this file.
guard let data = try? Data(contentsOf: isolatedManifestURL),
let manifest = try? JSONDecoder().decode(TranscriptedMCPDirectoriesManifest.self, from: data) else {
assertTrue(false, "manifest should be readable JSON after setting a custom capture library")
return
Expand Down Expand Up @@ -591,4 +630,12 @@ func testTranscriptedStoragePaths() {
"app writer output for the fixture's capture-library input should match the golden fixture exactly"
)
}

runSuite("Transcripted MCP directory manifest — the suites above left the real manifest untouched") {
assertEqual(
fingerprint(of: realManifestURL),
realManifestBefore,
"the real ~/Library/Application Support/Transcripted/mcp-directories.json should keep its mtime and size (nil = absent before and after)"
)
}
}
2 changes: 1 addition & 1 deletion Tools/TranscriptedMCP/Sources/TranscriptedMCP/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ struct AgentDictationEntry: Codable {
}
}

/// A parsed writing day file (`Writing_<YYYY-MM-dd>.md`): what the user typed
/// A parsed writing day file (`Writing_<YYYY-MM-dd>.md`): what the user wrote
/// with the Transcripted keyboard, one entry per app/idle-gap segment.
struct AgentWritingDay: Codable {
let version: String
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import Foundation
import MCP
import TranscriptedCaptureKit

// Writing day files (`Writing_<date>.md`): what the user typed with the
// Writing day files (`Writing_<date>.md`): what the user wrote with the
// Transcripted keyboard. These mirror list_dictations / read_dictation so an
// agent that knows one knows the other.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -446,7 +446,7 @@ func registerToolHandlers(server: Server, index: TranscriptIndex, directories: T
),
Tool(
name: "list_writing",
description: "List saved writing days: what the user typed with the Transcripted keyboard, one Writing_<date> file per day, with entry counts, source apps, and recent titles. Use read_writing with a returned filename for the text.",
description: "List saved writing days: what the user wrote with the Transcripted keyboard, one Writing_<date> file per day, with entry counts, source apps, and recent titles. Use read_writing with a returned filename for the text.",
inputSchema: .object([
"type": .string("object"),
"properties": .object([
Expand Down Expand Up @@ -499,7 +499,7 @@ func registerToolHandlers(server: Server, index: TranscriptIndex, directories: T
),
Tool(
name: "search_context",
description: "Search across saved meetings, dictations, writing, or all of them. Defaults to hybrid (full-text + on-device semantic), so paraphrases match, not just exact wording; writing is matched by full text only. Great for finding everything you captured about a topic, whether it came from a meeting, a quick dictated note, or something you typed.",
description: "Search across saved meetings, dictations, writing, or all of them. Defaults to hybrid (full-text + on-device semantic), so paraphrases match, not just exact wording; writing is matched by full text only. Great for finding everything you captured about a topic, whether it came from a meeting, a quick dictated note, or something you wrote.",
inputSchema: .object([
"type": .string("object"),
"properties": .object([
Expand Down
Loading
Loading