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
8 changes: 7 additions & 1 deletion App/SimbiApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ struct SimbiApp: App {
}

var body: some Scene {
WindowGroup {
Window("Simbi", id: SimbiWindow.mainID) {
SimbiRootView()
}
.commands {
Expand Down Expand Up @@ -54,5 +54,11 @@ struct SimbiApp: App {
Settings {
SettingsView()
}
MenuBarExtra {
QuickCaptureMenuContent()
} label: {
QuickCaptureMenuBarLabel()
}
.menuBarExtraStyle(.window)
}
}
52 changes: 52 additions & 0 deletions PRODUCT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Product

## Register

product

## Users

People who use Simbi for DSU calls, meetings, interviews, lectures, and other
conversations. They often need to start recording quickly, before opening a
full app window, and may be distracted by the call itself.

## Product Purpose

Simbi turns live conversations into local, editable notes with speaker-labelled
transcripts and AI-generated summaries. The most important workflow is reliable
capture: a user should be able to start a recording in an existing Simbi folder
with minimal interruption and without risking that they forget to press Record.

Quick capture creates a timestamped note in a user-selected existing folder,
starts recording immediately, and leaves naming and organization refinements
for later. Simbi should be ready from the menubar before a call begins.

## Brand Personality

Calm, trustworthy, native to macOS. The product should feel prepared and
quietly protective of the user's meetings rather than demanding attention.

## Anti-references

Avoid setup-heavy workflows, mandatory naming before capture, notification-heavy
recording controls, separate proprietary storage areas, and generic dashboard
patterns that make the user hunt for the primary action.

## Design Principles

- Capture first: starting a recording should be the shortest reliable path.
- Organize without interruption: reuse the user's normal Simbi folders and defer
optional naming work.
- Make state unmistakable: the menubar must clearly show when recording is live.
- Stay native: use familiar macOS menubar, settings, permissions, and launch-at-
login conventions.
- Preserve agency: stopping is always explicit, and quick capture never hides
where the note was saved.

## Accessibility & Inclusion

Use system controls and labels, support VoiceOver through explicit menu and
button labels, preserve keyboard access in the main app, and do not rely on color
alone to communicate recording state. Respect reduced-motion preferences and
provide clear error feedback if permissions, folder creation, or recording
startup fails.
24 changes: 16 additions & 8 deletions Packages/SimbiKit/Sources/CodexKit/AppServerClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,18 @@ public actor AppServerClient {
AppServerJanitor.shared.prepare(binaryPath: installation.binaryURL.path)
}

/// Project methods and `thread/start.projectId` are capability-gated by
/// app-server. Keep the handshake shape testable because silently
/// dropping this flag makes every Simbi thread unassigned again.
nonisolated static func initializeParams(version: String) -> [String: any Sendable] {
[
"clientInfo": [
"name": "simbi", "title": "Simbi", "version": version,
],
"capabilities": ["experimentalApi": true],
]
}

public func addNotificationHandler(
_ handler: @escaping @Sendable (String, Data) -> Void
) {
Expand Down Expand Up @@ -287,15 +299,11 @@ public actor AppServerClient {
await self?.readLoop(socketTask)
}

let version =
Bundle.main.object(
forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "dev"
_ = try await send(
method: "initialize",
params: [
"clientInfo": [
"name": "simbi", "title": "Simbi",
"version": Bundle.main.object(
forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "dev",
]
])
method: "initialize", params: Self.initializeParams(version: version))
try await write(["jsonrpc": "2.0", "method": "initialized"])

let auth = try await send(
Expand Down
12 changes: 6 additions & 6 deletions Packages/SimbiKit/Sources/CodexKit/CodexTrust.swift
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import Foundation
import SimbiKit

/// Pre-trusts a note folder in `~/.codex/config.toml` so the chat TUI
/// skips its "Do you trust the contents of this directory?" gate on every
/// note. Appends the same `[projects."<path>"]` entry codex writes when
/// Pre-trusts the Simbi project folder in `~/.codex/config.toml` so the chat
/// TUI skips its "Do you trust the contents of this directory?" gate.
/// Appends the same `[projects."<path>"]` entry codex writes when
/// the user picks "Yes, continue"; trust is per exact path — a parent
/// entry does not cover children (verified against codex 0.147), so each
/// note folder needs its own entry. `-c` overrides on the command line
/// are ignored for the trust decision, hence the config file.
/// entry does not cover children (verified against codex 0.147). `-c`
/// overrides on the command line are ignored for the trust decision, hence
/// the config file.
public enum CodexTrust {
public static func ensureTrusted(
directory: URL, installation: CodexInstallation = .standard
Expand Down
40 changes: 35 additions & 5 deletions Packages/SimbiKit/Sources/CodexKit/CodexWorker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ public enum CodexWorkerError: Error {
/// Wire shapes shared by every worker turn (verified in
/// Sources/simbi-appserver-spike/README.md).
enum CodexTurn {
static func threadStartParams(
cwd: URL, sandbox: String, projectId: String?
) -> [String: any Sendable] {
var params: [String: any Sendable] = [
"cwd": cwd.path, "approvalPolicy": "never", "sandbox": sandbox,
]
if let projectId { params["projectId"] = projectId }
return params
}

/// A single text input item for `turn/start`.
static func textInput(_ text: String) -> [[String: any Sendable]] {
[["type": "text", "text": text, "text_elements": [String]()]]
Expand Down Expand Up @@ -67,9 +77,20 @@ enum CodexTurn {
static func startThread(
client: AppServerClient, cwd: URL, sandbox: String, name: String
) async throws -> String {
let projectId: String?
do {
projectId = try await SimbiCodexProjectOrganizer.ensureProject(
client: client, rootURL: cwd)
} catch {
// Project organization is additive. A Codex app update must not
// be allowed to disable transcription or AI notes if its
// experimental project API changes.
Log.codex.warning("organizing Codex project failed; continuing unassigned: \(error)")
projectId = nil
}
let resultData = try await client.request(
method: "thread/start",
params: ["cwd": cwd.path, "approvalPolicy": "never", "sandbox": sandbox])
params: threadStartParams(cwd: cwd, sandbox: sandbox, projectId: projectId))
let id = try threadId(fromStartResult: resultData)
_ = try await client.request(
method: "thread/name/set", params: ["threadId": id, "name": name])
Expand All @@ -96,7 +117,9 @@ public enum WorkerOutput {
/// bookkeeping is per-thread.
actor CodexWorkerTurnRunner {
struct Spec: Sendable {
var cwd: URL
var project: SimbiCodexProject
var noteFolderURL: URL
var taskDirectoryURL: URL
/// `thread/start` sandbox ("workspace-write" / "read-only").
var sandbox: String
/// Turn sandbox scope; nil inherits the thread's sandbox (titler).
Expand Down Expand Up @@ -160,7 +183,8 @@ actor CodexWorkerTurnRunner {
/// §5.1: state.json records thread ids).
func run(
instructions: String,
threadName: String,
role: String,
detail: String? = nil,
onThreadStarted: @Sendable (String) async -> Void = { _ in }
) async throws -> String? {
if !bound {
Expand All @@ -183,7 +207,9 @@ actor CodexWorkerTurnRunner {
}

let threadId = try await CodexTurn.startThread(
client: client, cwd: spec.cwd, sandbox: spec.sandbox, name: threadName)
client: client, cwd: spec.project.rootURL, sandbox: spec.sandbox,
name: spec.project.threadName(
for: spec.noteFolderURL, role: role, detail: detail))
activeThreads.insert(threadId)
await onThreadStarted(threadId)

Expand All @@ -207,7 +233,11 @@ actor CodexWorkerTurnRunner {
_ = try await client.request(
method: "turn/start",
params: CodexTurn.startParams(
threadId: threadId, text: instructions, writableRoot: spec.writableRoot,
threadId: threadId,
text: spec.project.instructions(
for: spec.noteFolderURL, taskDirectoryURL: spec.taskDirectoryURL,
task: instructions),
writableRoot: spec.writableRoot,
model: spec.model, effort: spec.effort))
try await awaitTurnCompletion(threadId: threadId)
return messages[threadId]
Expand Down
13 changes: 8 additions & 5 deletions Packages/SimbiKit/Sources/CodexKit/FileConverter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import Foundation
import SimbiKit

/// Runs the per-file converter jobs for one note (SPEC.md §5.3): each
/// imported file gets its own Codex thread (cwd = note folder,
/// workspace-write sandbox) with one turn that converts `files/<name>` to
/// `context/<name>.md`; the thread is archived when the job ends.
/// imported file gets its own thread in the shared Simbi Codex project, with
/// a workspace-write sandbox restricted to the note folder. One turn converts
/// `files/<name>` to `context/<name>.md`; the thread is archived when it ends.
public actor FileConverter {
private let noteFolderURL: URL
private let runner: CodexWorkerTurnRunner
Expand All @@ -21,6 +21,7 @@ public actor FileConverter {
public init(
noteFolderURL: URL, client: AppServerClient, model: String? = nil,
effort: String? = nil,
projectRootURL: URL = SimbiHome().rootURL,
turnTimeout: Duration = .seconds(900), // generous — odd formats send the agent exploring
anydocPath: String? = nil,
shouldArchiveOnJobEnd: @escaping @Sendable (String) async -> Bool = { _ in true },
Expand All @@ -34,7 +35,9 @@ public actor FileConverter {
self.runner = CodexWorkerTurnRunner(
client: client,
spec: .init(
cwd: noteFolderURL, sandbox: "workspace-write", writableRoot: noteFolderURL,
project: SimbiCodexProject(rootURL: projectRootURL),
noteFolderURL: noteFolderURL, taskDirectoryURL: noteFolderURL,
sandbox: "workspace-write", writableRoot: noteFolderURL,
model: model, effort: effort, turnTimeout: turnTimeout,
shouldArchiveOnEnd: shouldArchiveOnJobEnd))
}
Expand Down Expand Up @@ -69,7 +72,7 @@ public actor FileConverter {
) async throws {
let message = try await runner.run(
instructions: instructions(fileName: fileName),
threadName: "[simbi] convert: \(fileName)",
role: "Convert", detail: fileName,
onThreadStarted: onThreadStarted)
if let message, let reason = CodexWorkerTurnRunner.reportedFailure(in: message) {
throw CodexWorkerError.reportedFailure(reason)
Expand Down
17 changes: 10 additions & 7 deletions Packages/SimbiKit/Sources/CodexKit/NoteSummarizer.swift
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import Foundation
import SimbiKit

/// Generates a note's AI notes (AI Notes spec §3): one fresh thread per
/// generation (cwd = note folder, workspace-write sandbox), one turn whose
/// input is the SUMMARY.md instructions. The thread reads note.md,
/// transcript.vtt, context/*.md, and any current summary.md from its own
/// cwd and writes summary.md itself by design (user decision 2026-08-10),
/// Generates a note's AI notes (AI Notes spec §3): one fresh thread in the
/// shared Simbi Codex project per generation, with its writable sandbox scoped
/// to the note folder. The turn reads note.md,
/// transcript.vtt, context/*.md, and any current summary.md from its task
/// directory and writes summary.md itself by design (user decision 2026-08-10),
/// converter-style; its final message is only a DONE/FAILED status reply.
public actor NoteSummarizer {
private let noteFolderURL: URL
Expand All @@ -17,6 +17,7 @@ public actor NoteSummarizer {
public init(
noteFolderURL: URL, client: AppServerClient, model: String? = nil,
effort: String? = nil,
projectRootURL: URL = SimbiHome().rootURL,
turnTimeout: Duration = .seconds(600),
instructionsProvider: @escaping @Sendable () -> String = {
AgentInstructions.summary.contents(homeRootURL: SimbiHome().rootURL)
Expand All @@ -29,7 +30,9 @@ public actor NoteSummarizer {
self.runner = CodexWorkerTurnRunner(
client: client,
spec: .init(
cwd: noteFolderURL, sandbox: "workspace-write", writableRoot: noteFolderURL,
project: SimbiCodexProject(rootURL: projectRootURL),
noteFolderURL: noteFolderURL, taskDirectoryURL: noteFolderURL,
sandbox: "workspace-write", writableRoot: noteFolderURL,
model: model, effort: effort, turnTimeout: turnTimeout))
}

Expand All @@ -39,7 +42,7 @@ public actor NoteSummarizer {
public func generate() async throws {
let message = try await runner.run(
instructions: instructionsProvider(),
threadName: "[simbi] summary: \(noteFolderURL.lastPathComponent)")
role: "AI Notes")

if let message, let reason = CodexWorkerTurnRunner.reportedFailure(in: message) {
throw CodexWorkerError.reportedFailure(reason)
Expand Down
11 changes: 7 additions & 4 deletions Packages/SimbiKit/Sources/CodexKit/NoteTitler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import Foundation
import SimbiKit

/// Names a note whose title is still the default: one fresh read-only
/// thread per attempt (cwd = note folder, `sandbox: read-only`), one turn
/// whose input is the TITLE.md instructions. Unlike the summarizer the
/// thread in the shared Simbi Codex project per attempt. Its task directory is
/// the note folder and its sandbox is read-only. Unlike the summarizer the
/// thread writes nothing — its final agent message IS the result, which
/// `sanitizedTitle` turns into a folder-safe name.
public actor NoteTitler {
Expand All @@ -14,6 +14,7 @@ public actor NoteTitler {
public init(
noteFolderURL: URL, client: AppServerClient, model: String? = nil,
effort: String? = nil,
projectRootURL: URL = SimbiHome().rootURL,
turnTimeout: Duration = .seconds(180),
instructionsProvider: @escaping @Sendable () -> String = {
AgentInstructions.title.contents(homeRootURL: SimbiHome().rootURL)
Expand All @@ -27,7 +28,9 @@ public actor NoteTitler {
self.runner = CodexWorkerTurnRunner(
client: client,
spec: .init(
cwd: noteFolderURL, sandbox: "read-only", writableRoot: nil,
project: SimbiCodexProject(rootURL: projectRootURL),
noteFolderURL: noteFolderURL, taskDirectoryURL: noteFolderURL,
sandbox: "read-only", writableRoot: nil,
model: model, effort: effort, turnTimeout: turnTimeout))
}

Expand Down Expand Up @@ -88,7 +91,7 @@ public actor NoteTitler {
public func generateTitle() async throws -> String {
let message = try await runner.run(
instructions: instructionsProvider(),
threadName: "[simbi] title: \(noteFolderURL.lastPathComponent)")
role: "Note Title")

guard let message else { throw CodexWorkerError.noOutput }
if let reason = CodexWorkerTurnRunner.reportedFailure(in: message) {
Expand Down
Loading