feat: share wake gate via SwabbleKit

This commit is contained in:
Peter Steinberger
2025-12-23 01:30:40 +01:00
parent cf48d297dd
commit ef35868bef
2945 changed files with 27887 additions and 122 deletions

11
Swabble/CHANGELOG.md Normal file
View File

@@ -0,0 +1,11 @@
# Changelog
## 0.2.0 — 2025-12-23
### Highlights
- Added `SwabbleKit` (multi-platform wake-word gate utilities with segment-aware gap detection).
- Swabble package now supports iOS + macOS consumers; CLI remains macOS 26-only.
### Changes
- CLI wake-word matching/stripping routed through `SwabbleKit` helpers.
- Speech pipeline types now explicitly gated to macOS 26 / iOS 26 availability.

View File

@@ -4,10 +4,12 @@ import PackageDescription
let package = Package( let package = Package(
name: "swabble", name: "swabble",
platforms: [ platforms: [
.macOS(.v26), .macOS(.v15),
.iOS(.v17),
], ],
products: [ products: [
.library(name: "Swabble", targets: ["Swabble"]), .library(name: "Swabble", targets: ["Swabble"]),
.library(name: "SwabbleKit", targets: ["SwabbleKit"]),
.executable(name: "swabble", targets: ["SwabbleCLI"]), .executable(name: "swabble", targets: ["SwabbleCLI"]),
], ],
dependencies: [ dependencies: [
@@ -19,13 +21,30 @@ let package = Package(
name: "Swabble", name: "Swabble",
path: "Sources/SwabbleCore", path: "Sources/SwabbleCore",
swiftSettings: []), swiftSettings: []),
.target(
name: "SwabbleKit",
path: "Sources/SwabbleKit",
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
.executableTarget( .executableTarget(
name: "SwabbleCLI", name: "SwabbleCLI",
dependencies: [ dependencies: [
"Swabble", "Swabble",
"SwabbleKit",
.product(name: "Commander", package: "Commander"), .product(name: "Commander", package: "Commander"),
], ],
path: "Sources/swabble"), path: "Sources/swabble"),
.testTarget(
name: "SwabbleKitTests",
dependencies: [
"SwabbleKit",
.product(name: "Testing", package: "swift-testing"),
],
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
.enableExperimentalFeature("SwiftTesting"),
]),
.testTarget( .testTarget(
name: "swabbleTests", name: "swabbleTests",
dependencies: [ dependencies: [

View File

@@ -1,9 +1,10 @@
# 🎙️ swabble — Speech.framework wake-word hook daemon (macOS 26) # 🎙️ swabble — Speech.framework wake-word hook daemon (macOS 26)
swabble is a Swift 6.2, macOS 26-only rewrite of the brabble voice daemon. It listens on your mic, gates on a wake word, transcribes locally using Apple's new SpeechAnalyzer + SpeechTranscriber, then fires a shell hook with the transcript. No cloud calls, no Whisper binaries. swabble is a Swift 6.2 wake-word hook daemon. The CLI targets macOS 26 (SpeechAnalyzer + SpeechTranscriber). The shared `SwabbleKit` target is multi-platform and exposes wake-word gating utilities for iOS/macOS apps.
- **Local-only**: Speech.framework on-device models; zero network usage. - **Local-only**: Speech.framework on-device models; zero network usage.
- **Wake word**: Default `clawd` (aliases `claude`), optional `--no-wake` bypass. - **Wake word**: Default `clawd` (aliases `claude`), optional `--no-wake` bypass.
- **SwabbleKit**: Shared wake gate utilities (gap-based gating when you provide speech segments).
- **Hooks**: Run any command with prefix/env, cooldown, min_chars, timeout. - **Hooks**: Run any command with prefix/env, cooldown, min_chars, timeout.
- **Services**: launchd helper stubs for start/stop/install. - **Services**: launchd helper stubs for start/stop/install.
- **File transcribe**: TXT or SRT with time ranges (using AttributedString splits). - **File transcribe**: TXT or SRT with time ranges (using AttributedString splits).
@@ -30,7 +31,7 @@ swift run swabble transcribe /path/to/audio.m4a --format srt --output out.srt
``` ```
## Use as a library ## Use as a library
Add swabble as a SwiftPM dependency and import the `Swabble` product to reuse the Speech pipeline, config loader, hook executor, and transcript store in your own app: Add swabble as a SwiftPM dependency and import the `Swabble` or `SwabbleKit` product:
```swift ```swift
// Package.swift // Package.swift
@@ -38,7 +39,10 @@ dependencies: [
.package(url: "https://github.com/steipete/swabble.git", branch: "main"), .package(url: "https://github.com/steipete/swabble.git", branch: "main"),
], ],
targets: [ targets: [
.target(name: "MyApp", dependencies: [.product(name: "Swabble", package: "swabble")]), .target(name: "MyApp", dependencies: [
.product(name: "Swabble", package: "swabble"), // Speech pipeline (macOS 26+ / iOS 26+)
.product(name: "SwabbleKit", package: "swabble"), // Wake-word gate utilities (iOS 17+ / macOS 15+)
]),
] ]
``` ```
@@ -93,7 +97,7 @@ Environment variables:
## Speech pipeline ## Speech pipeline
- `AVAudioEngine` tap → `BufferConverter``AnalyzerInput``SpeechAnalyzer` with a `SpeechTranscriber` module. - `AVAudioEngine` tap → `BufferConverter``AnalyzerInput``SpeechAnalyzer` with a `SpeechTranscriber` module.
- Requests volatile + final results; wake gating is string match on partial/final. - Requests volatile + final results; the CLI uses text-only wake gating today.
- Authorization requested at first start; requires macOS 26 + new Speech.framework APIs. - Authorization requested at first start; requires macOS 26 + new Speech.framework APIs.
## Development ## Development

View File

@@ -2,11 +2,13 @@ import AVFoundation
import Foundation import Foundation
import Speech import Speech
@available(macOS 26.0, iOS 26.0, *)
public struct SpeechSegment: Sendable { public struct SpeechSegment: Sendable {
public let text: String public let text: String
public let isFinal: Bool public let isFinal: Bool
} }
@available(macOS 26.0, iOS 26.0, *)
public enum SpeechPipelineError: Error { public enum SpeechPipelineError: Error {
case authorizationDenied case authorizationDenied
case analyzerFormatUnavailable case analyzerFormatUnavailable
@@ -14,6 +16,7 @@ public enum SpeechPipelineError: Error {
} }
/// Live microphone SpeechAnalyzer SpeechTranscriber pipeline. /// Live microphone SpeechAnalyzer SpeechTranscriber pipeline.
@available(macOS 26.0, iOS 26.0, *)
public actor SpeechPipeline { public actor SpeechPipeline {
private struct UnsafeBuffer: @unchecked Sendable { let buffer: AVAudioPCMBuffer } private struct UnsafeBuffer: @unchecked Sendable { let buffer: AVAudioPCMBuffer }

View File

@@ -0,0 +1,202 @@
import Foundation
public struct WakeWordSegment: Sendable, Equatable {
public let text: String
public let start: TimeInterval
public let duration: TimeInterval
public let range: Range<String.Index>?
public init(text: String, start: TimeInterval, duration: TimeInterval, range: Range<String.Index>? = nil) {
self.text = text
self.start = start
self.duration = duration
self.range = range
}
public var end: TimeInterval { start + duration }
}
public struct WakeWordGateConfig: Sendable, Equatable {
public var triggers: [String]
public var minPostTriggerGap: TimeInterval
public var minCommandLength: Int
public init(
triggers: [String],
minPostTriggerGap: TimeInterval = 0.45,
minCommandLength: Int = 1)
{
self.triggers = triggers
self.minPostTriggerGap = minPostTriggerGap
self.minCommandLength = minCommandLength
}
}
public struct WakeWordGateMatch: Sendable, Equatable {
public let triggerEndTime: TimeInterval
public let postGap: TimeInterval
public let command: String
public init(triggerEndTime: TimeInterval, postGap: TimeInterval, command: String) {
self.triggerEndTime = triggerEndTime
self.postGap = postGap
self.command = command
}
}
public enum WakeWordGate {
private struct Token {
let normalized: String
let start: TimeInterval
let end: TimeInterval
let range: Range<String.Index>?
let text: String
}
private struct TriggerTokens {
let tokens: [String]
}
public static func match(
transcript: String,
segments: [WakeWordSegment],
config: WakeWordGateConfig)
-> WakeWordGateMatch? {
let triggerTokens = normalizeTriggers(config.triggers)
guard !triggerTokens.isEmpty else { return nil }
let tokens = normalizeSegments(segments)
guard !tokens.isEmpty else { return nil }
var bestIndex: Int?
var bestTriggerEnd: TimeInterval = 0
var bestGap: TimeInterval = 0
for trigger in triggerTokens {
let count = trigger.tokens.count
guard count > 0, tokens.count > count else { continue }
for i in 0...(tokens.count - count - 1) {
var matched = true
for t in 0..<count {
if tokens[i + t].normalized != trigger.tokens[t] {
matched = false
break
}
}
if !matched { continue }
let triggerEnd = tokens[i + count - 1].end
let nextToken = tokens[i + count]
let gap = nextToken.start - triggerEnd
if gap < config.minPostTriggerGap { continue }
if let bestIndex, i <= bestIndex { continue }
bestIndex = i
bestTriggerEnd = triggerEnd
bestGap = gap
}
}
guard let bestIndex else { return nil }
let command = commandText(transcript: transcript, segments: segments, triggerEndTime: bestTriggerEnd)
.trimmingCharacters(in: Self.whitespaceAndPunctuation)
guard command.count >= config.minCommandLength else { return nil }
return WakeWordGateMatch(triggerEndTime: bestTriggerEnd, postGap: bestGap, command: command)
}
public static func commandText(
transcript: String,
segments: [WakeWordSegment],
triggerEndTime: TimeInterval)
-> String {
let threshold = triggerEndTime + 0.001
for segment in segments where segment.start >= threshold {
if normalizeToken(segment.text).isEmpty { continue }
if let range = segment.range {
let slice = transcript[range.lowerBound...]
return String(slice).trimmingCharacters(in: Self.whitespaceAndPunctuation)
}
break
}
let text = segments
.filter { $0.start >= threshold && !normalizeToken($0.text).isEmpty }
.map { $0.text }
.joined(separator: " ")
return text.trimmingCharacters(in: Self.whitespaceAndPunctuation)
}
public static func matchesTextOnly(text: String, triggers: [String]) -> Bool {
guard !text.isEmpty else { return false }
let normalized = text.lowercased()
for trigger in triggers {
let token = trigger.trimmingCharacters(in: whitespaceAndPunctuation).lowercased()
if token.isEmpty { continue }
if normalized.contains(token) { return true }
}
return false
}
public static func stripWake(text: String, triggers: [String]) -> String {
var out = text
for trigger in triggers {
let token = trigger.trimmingCharacters(in: whitespaceAndPunctuation)
guard !token.isEmpty else { continue }
out = out.replacingOccurrences(of: token, with: "", options: [.caseInsensitive])
}
return out.trimmingCharacters(in: whitespaceAndPunctuation)
}
private static func normalizeTriggers(_ triggers: [String]) -> [TriggerTokens] {
var output: [TriggerTokens] = []
for trigger in triggers {
let tokens = trigger
.split(whereSeparator: { $0.isWhitespace })
.map { normalizeToken(String($0)) }
.filter { !$0.isEmpty }
if tokens.isEmpty { continue }
output.append(TriggerTokens(tokens: tokens))
}
return output
}
private static func normalizeSegments(_ segments: [WakeWordSegment]) -> [Token] {
segments.compactMap { segment in
let normalized = normalizeToken(segment.text)
guard !normalized.isEmpty else { return nil }
return Token(
normalized: normalized,
start: segment.start,
end: segment.end,
range: segment.range,
text: segment.text)
}
}
private static func normalizeToken(_ token: String) -> String {
token
.trimmingCharacters(in: whitespaceAndPunctuation)
.lowercased()
}
private static let whitespaceAndPunctuation = CharacterSet.whitespacesAndNewlines
.union(.punctuationCharacters)
}
#if canImport(Speech)
import Speech
public enum WakeWordSpeechSegments {
public static func from(transcription: SFTranscription, transcript: String) -> [WakeWordSegment] {
transcription.segments.map { segment in
let range = Range(segment.substringRange, in: transcript)
return WakeWordSegment(
text: segment.substring,
start: segment.timestamp,
duration: segment.duration,
range: range)
}
}
}
#endif

View File

@@ -1,6 +1,7 @@
import Commander import Commander
import Foundation import Foundation
@available(macOS 26.0, *)
@MainActor @MainActor
enum CLIRegistry { enum CLIRegistry {
static var descriptors: [CommandDescriptor] { static var descriptors: [CommandDescriptor] {

View File

@@ -1,7 +1,9 @@
import Commander import Commander
import Foundation import Foundation
import Swabble import Swabble
import SwabbleKit
@available(macOS 26.0, *)
@MainActor @MainActor
struct ServeCommand: ParsableCommand { struct ServeCommand: ParsableCommand {
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String? @Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
@@ -68,17 +70,12 @@ struct ServeCommand: ParsableCommand {
} }
private static func matchesWake(text: String, cfg: SwabbleConfig) -> Bool { private static func matchesWake(text: String, cfg: SwabbleConfig) -> Bool {
let lowered = text.lowercased() let triggers = [cfg.wake.word] + cfg.wake.aliases
if lowered.contains(cfg.wake.word.lowercased()) { return true } return WakeWordGate.matchesTextOnly(text: text, triggers: triggers)
return cfg.wake.aliases.contains(where: { lowered.contains($0.lowercased()) })
} }
private static func stripWake(text: String, cfg: SwabbleConfig) -> String { private static func stripWake(text: String, cfg: SwabbleConfig) -> String {
var out = text let triggers = [cfg.wake.word] + cfg.wake.aliases
out = out.replacingOccurrences(of: cfg.wake.word, with: "", options: [.caseInsensitive]) return WakeWordGate.stripWake(text: text, triggers: triggers)
for alias in cfg.wake.aliases {
out = out.replacingOccurrences(of: alias, with: "", options: [.caseInsensitive])
}
return out.trimmingCharacters(in: .whitespacesAndNewlines)
} }
} }

View File

@@ -1,6 +1,7 @@
import Commander import Commander
import Foundation import Foundation
@available(macOS 26.0, *)
@MainActor @MainActor
private func runCLI() async -> Int32 { private func runCLI() async -> Int32 {
do { do {
@@ -15,6 +16,7 @@ private func runCLI() async -> Int32 {
} }
} }
@available(macOS 26.0, *)
@MainActor @MainActor
private func dispatch(invocation: CommandInvocation) async throws { private func dispatch(invocation: CommandInvocation) async throws {
let parsed = invocation.parsedValues let parsed = invocation.parsedValues
@@ -95,5 +97,10 @@ private func dispatch(invocation: CommandInvocation) async throws {
} }
} }
let exitCode = await runCLI() if #available(macOS 26.0, *) {
exit(exitCode) let exitCode = await runCLI()
exit(exitCode)
} else {
fputs("error: swabble requires macOS 26 or newer\n", stderr)
exit(1)
}

View File

@@ -0,0 +1,63 @@
import Foundation
import Testing
import SwabbleKit
@Suite struct WakeWordGateTests {
@Test func matchRequiresGapAfterTrigger() {
let transcript = "hey clawd do thing"
let segments = makeSegments(
transcript: transcript,
words: [
("hey", 0.0, 0.1),
("clawd", 0.2, 0.1),
("do", 0.35, 0.1),
("thing", 0.5, 0.1),
])
let config = WakeWordGateConfig(triggers: ["clawd"], minPostTriggerGap: 0.3)
#expect(WakeWordGate.match(transcript: transcript, segments: segments, config: config) == nil)
}
@Test func matchAllowsGapAndExtractsCommand() {
let transcript = "hey clawd do thing"
let segments = makeSegments(
transcript: transcript,
words: [
("hey", 0.0, 0.1),
("clawd", 0.2, 0.1),
("do", 0.9, 0.1),
("thing", 1.1, 0.1),
])
let config = WakeWordGateConfig(triggers: ["clawd"], minPostTriggerGap: 0.3)
let match = WakeWordGate.match(transcript: transcript, segments: segments, config: config)
#expect(match?.command == "do thing")
}
@Test func matchHandlesMultiWordTriggers() {
let transcript = "hey clawd do it"
let segments = makeSegments(
transcript: transcript,
words: [
("hey", 0.0, 0.1),
("clawd", 0.2, 0.1),
("do", 0.8, 0.1),
("it", 1.0, 0.1),
])
let config = WakeWordGateConfig(triggers: ["hey clawd"], minPostTriggerGap: 0.3)
let match = WakeWordGate.match(transcript: transcript, segments: segments, config: config)
#expect(match?.command == "do it")
}
}
private func makeSegments(
transcript: String,
words: [(String, TimeInterval, TimeInterval)])
-> [WakeWordSegment] {
var searchStart = transcript.startIndex
var output: [WakeWordSegment] = []
for (word, start, duration) in words {
let range = transcript.range(of: word, range: searchStart..<transcript.endIndex)
output.append(WakeWordSegment(text: word, start: start, duration: duration, range: range))
if let range { searchStart = range.upperBound }
}
return output
}

View File

@@ -1,11 +1,12 @@
# swabble — macOS 26 speech hook daemon (Swift 6.2) # swabble — macOS 26 speech hook daemon (Swift 6.2)
Goal: brabble-style always-on voice hook for macOS 26 using Apple Speech.framework (SpeechAnalyzer + SpeechTranscriber) instead of whisper.cpp. Local-only, wake word gated, dispatches a shell hook with the transcript. Goal: brabble-style always-on voice hook for macOS 26 using Apple Speech.framework (SpeechAnalyzer + SpeechTranscriber) instead of whisper.cpp. Local-only, wake word gated, dispatches a shell hook with the transcript. Shared wake-gate utilities live in `SwabbleKit` for reuse by other apps (iOS/macOS).
## Requirements ## Requirements
- macOS 26+, Swift 6.2, Speech.framework with on-device assets. - macOS 26+, Swift 6.2, Speech.framework with on-device assets.
- Local only; no network calls during transcription. - Local only; no network calls during transcription.
- Wake word gating (default "clawd" plus aliases) with bypass flag `--no-wake`. - Wake word gating (default "clawd" plus aliases) with bypass flag `--no-wake`.
- `SwabbleKit` target (multi-platform) providing wake-word gating helpers that can use speech segment timing to require a post-trigger gap.
- Hook execution with cooldown, min_chars, timeout, prefix, env vars. - Hook execution with cooldown, min_chars, timeout, prefix, env vars.
- Simple config at `~/.config/swabble/config.json` (JSON, Codable) — no TOML. - Simple config at `~/.config/swabble/config.json` (JSON, Codable) — no TOML.
- CLI implemented with Commander (SwiftPM package `steipete/Commander`); core types are available via the SwiftPM library product `Swabble` for embedding. - CLI implemented with Commander (SwiftPM package `steipete/Commander`); core types are available via the SwiftPM library product `Swabble` for embedding.
@@ -17,7 +18,7 @@ Goal: brabble-style always-on voice hook for macOS 26 using Apple Speech.framewo
- **CLI layer (Commander)**: Root command `swabble` with subcommands `serve`, `transcribe`, `test-hook`, `mic list|set`, `doctor`, `health`, `tail-log`. Runtime flags from Commander (`-v/--verbose`, `--json-output`, `--log-level`). Custom `--config` path applies everywhere. - **CLI layer (Commander)**: Root command `swabble` with subcommands `serve`, `transcribe`, `test-hook`, `mic list|set`, `doctor`, `health`, `tail-log`. Runtime flags from Commander (`-v/--verbose`, `--json-output`, `--log-level`). Custom `--config` path applies everywhere.
- **Config**: `SwabbleConfig` Codable. Fields: audio device name/index, wake (enabled/word/aliases/sensitivity placeholder), hook (command/args/prefix/cooldown/min_chars/timeout/env), logging (level, format), transcripts (enabled, max kept), speech (locale, enableEtiquetteReplacements flag). Stored JSON; default written by `setup`. - **Config**: `SwabbleConfig` Codable. Fields: audio device name/index, wake (enabled/word/aliases/sensitivity placeholder), hook (command/args/prefix/cooldown/min_chars/timeout/env), logging (level, format), transcripts (enabled, max kept), speech (locale, enableEtiquetteReplacements flag). Stored JSON; default written by `setup`.
- **Audio + Speech pipeline**: `SpeechPipeline` wraps `AVAudioEngine` input → `SpeechAnalyzer` with `SpeechTranscriber` module. Emits partial/final transcripts via async stream. Requests `.audioTimeRange` when transcripts enabled. Handles Speech permission and asset download prompts ahead of capture. - **Audio + Speech pipeline**: `SpeechPipeline` wraps `AVAudioEngine` input → `SpeechAnalyzer` with `SpeechTranscriber` module. Emits partial/final transcripts via async stream. Requests `.audioTimeRange` when transcripts enabled. Handles Speech permission and asset download prompts ahead of capture.
- **Wake gate**: text-based keyword match against latest partial/final; strips wake term before hook dispatch. `--no-wake` disables. - **Wake gate**: CLI currently uses text-only keyword match; shared `SwabbleKit` gate can enforce a minimum pause between the wake word and the next token when speech segments are available. `--no-wake` disables gating.
- **Hook executor**: async `HookExecutor` spawns `Process` with configured args, prefix substitution `${hostname}`. Enforces cooldown + timeout; injects env `SWABBLE_TEXT`, `SWABBLE_PREFIX` plus user env map. - **Hook executor**: async `HookExecutor` spawns `Process` with configured args, prefix substitution `${hostname}`. Enforces cooldown + timeout; injects env `SWABBLE_TEXT`, `SWABBLE_PREFIX` plus user env map.
- **Transcripts store**: in-memory ring buffer; optional persisted JSON lines under `~/Library/Application Support/swabble/transcripts.log`. - **Transcripts store**: in-memory ring buffer; optional persisted JSON lines under `~/Library/Application Support/swabble/transcripts.log`.
- **Logging**: simple structured logger to stderr; respects log level. - **Logging**: simple structured logger to stderr; respects log level.
@@ -25,7 +26,7 @@ Goal: brabble-style always-on voice hook for macOS 26 using Apple Speech.framewo
## Out of scope (initial cut) ## Out of scope (initial cut)
- Model management (Speech handles assets). - Model management (Speech handles assets).
- Launchd helper (planned follow-up). - Launchd helper (planned follow-up).
- Advanced wake-word detector (text match only for now). - Advanced wake-word detector (segment-aware gate now lives in `SwabbleKit`; CLI still text-only until segment timing is plumbed through).
## Open decisions ## Open decisions
- Whether to expose a UNIX control socket for `status`/`health` (currently planned as stdin/out direct calls). - Whether to expose a UNIX control socket for `status`/`health` (currently planned as stdin/out direct calls).

View File

@@ -2,6 +2,7 @@ import AVFAudio
import Foundation import Foundation
import Observation import Observation
import Speech import Speech
import SwabbleKit
private func makeAudioTapEnqueueCallback(queue: AudioBufferQueue) -> @Sendable (AVAudioPCMBuffer, AVAudioTime) -> Void { private func makeAudioTapEnqueueCallback(queue: AudioBufferQueue) -> @Sendable (AVAudioPCMBuffer, AVAudioTime) -> Void {
{ buffer, _ in { buffer, _ in
@@ -289,15 +290,18 @@ final class VoiceWakeManager: NSObject {
private nonisolated func makeRecognitionResultHandler() -> @Sendable (SFSpeechRecognitionResult?, Error?) -> Void { private nonisolated func makeRecognitionResultHandler() -> @Sendable (SFSpeechRecognitionResult?, Error?) -> Void {
{ [weak self] result, error in { [weak self] result, error in
let transcript = result?.bestTranscription.formattedString let transcript = result?.bestTranscription.formattedString
let segments = result.flatMap { result in
transcript.map { WakeWordSpeechSegments.from(transcription: result.bestTranscription, transcript: $0) }
} ?? []
let errorText = error?.localizedDescription let errorText = error?.localizedDescription
Task { @MainActor in Task { @MainActor in
self?.handleRecognitionCallback(transcript: transcript, errorText: errorText) self?.handleRecognitionCallback(transcript: transcript, segments: segments, errorText: errorText)
} }
} }
} }
private func handleRecognitionCallback(transcript: String?, errorText: String?) { private func handleRecognitionCallback(transcript: String?, segments: [WakeWordSegment], errorText: String?) {
if let errorText { if let errorText {
self.statusText = "Recognizer error: \(errorText)" self.statusText = "Recognizer error: \(errorText)"
self.isListening = false self.isListening = false
@@ -313,7 +317,7 @@ final class VoiceWakeManager: NSObject {
} }
guard let transcript else { return } guard let transcript else { return }
guard let cmd = self.extractCommand(from: transcript) else { return } guard let cmd = self.extractCommand(from: transcript, segments: segments) else { return }
if cmd == self.lastDispatched { return } if cmd == self.lastDispatched { return }
self.lastDispatched = cmd self.lastDispatched = cmd
@@ -334,30 +338,18 @@ final class VoiceWakeManager: NSObject {
} }
} }
private func extractCommand(from transcript: String) -> String? { private func extractCommand(from transcript: String, segments: [WakeWordSegment]) -> String? {
Self.extractCommand(from: transcript, triggers: self.activeTriggerWords) Self.extractCommand(from: transcript, segments: segments, triggers: self.activeTriggerWords)
} }
nonisolated static func extractCommand(from transcript: String, triggers: [String]) -> String? { nonisolated static func extractCommand(
var bestRange: Range<String.Index>? from transcript: String,
for trigger in triggers { segments: [WakeWordSegment],
let token = trigger.trimmingCharacters(in: .whitespacesAndNewlines) triggers: [String],
guard !token.isEmpty else { continue } minPostTriggerGap: TimeInterval = 0.45) -> String?
guard let range = transcript.range(of: token, options: [.caseInsensitive, .backwards]) else { continue } {
if let currentBest = bestRange { let config = WakeWordGateConfig(triggers: triggers, minPostTriggerGap: minPostTriggerGap)
if range.lowerBound > currentBest.lowerBound { return WakeWordGate.match(transcript: transcript, segments: segments, config: config)?.command
bestRange = range
}
} else {
bestRange = range
}
}
guard let bestRange else { return nil }
let after = transcript[bestRange.upperBound...]
let trimmed = after.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
return String(trimmed)
} }
private static func configureAudioSession() throws { private static func configureAudioSession() throws {

View File

@@ -54,3 +54,4 @@ Sources/Voice/VoiceWakePreferences.swift
../shared/ClawdisKit/Sources/ClawdisKit/ScreenCommands.swift ../shared/ClawdisKit/Sources/ClawdisKit/ScreenCommands.swift
../shared/ClawdisKit/Sources/ClawdisKit/StoragePaths.swift ../shared/ClawdisKit/Sources/ClawdisKit/StoragePaths.swift
../shared/ClawdisKit/Sources/ClawdisKit/SystemCommands.swift ../shared/ClawdisKit/Sources/ClawdisKit/SystemCommands.swift
../../Swabble/Sources/SwabbleKit/WakeWordGate.swift

View File

@@ -1,33 +1,90 @@
import Foundation
import Testing import Testing
import SwabbleKit
@testable import Clawdis @testable import Clawdis
@Suite struct VoiceWakeManagerExtractCommandTests { @Suite struct VoiceWakeManagerExtractCommandTests {
@Test func extractCommandReturnsNilWhenNoTriggerFound() { @Test func extractCommandReturnsNilWhenNoTriggerFound() {
#expect(VoiceWakeManager.extractCommand(from: "hello world", triggers: ["clawd"]) == nil) let transcript = "hello world"
let segments = makeSegments(
transcript: transcript,
words: [("hello", 0.0, 0.1), ("world", 0.2, 0.1)])
#expect(VoiceWakeManager.extractCommand(from: transcript, segments: segments, triggers: ["clawd"]) == nil)
} }
@Test func extractCommandTrimsTokensAndResult() { @Test func extractCommandTrimsTokensAndResult() {
let cmd = VoiceWakeManager.extractCommand(from: "hey clawd do thing ", triggers: [" clawd "]) let transcript = "hey clawd do thing"
let segments = makeSegments(
transcript: transcript,
words: [
("hey", 0.0, 0.1),
("clawd", 0.2, 0.1),
("do", 0.9, 0.1),
("thing", 1.1, 0.1),
])
let cmd = VoiceWakeManager.extractCommand(
from: transcript,
segments: segments,
triggers: [" clawd "],
minPostTriggerGap: 0.3)
#expect(cmd == "do thing") #expect(cmd == "do thing")
} }
@Test func extractCommandPicksLatestTriggerOccurrence() { @Test func extractCommandReturnsNilWhenGapTooShort() {
let transcript = "clawd first\nthen something\nclaude second" let transcript = "hey clawd do thing"
let cmd = VoiceWakeManager.extractCommand(from: transcript, triggers: ["clawd", "claude"]) let segments = makeSegments(
#expect(cmd == "second") transcript: transcript,
} words: [
("hey", 0.0, 0.1),
@Test func extractCommandIsCaseInsensitive() { ("clawd", 0.2, 0.1),
let cmd = VoiceWakeManager.extractCommand(from: "HELLO CLAWD run it", triggers: ["clawd"]) ("do", 0.35, 0.1),
#expect(cmd == "run it") ("thing", 0.5, 0.1),
])
let cmd = VoiceWakeManager.extractCommand(
from: transcript,
segments: segments,
triggers: ["clawd"],
minPostTriggerGap: 0.3)
#expect(cmd == nil)
} }
@Test func extractCommandReturnsNilWhenNothingAfterTrigger() { @Test func extractCommandReturnsNilWhenNothingAfterTrigger() {
#expect(VoiceWakeManager.extractCommand(from: "hey clawd \n", triggers: ["clawd"]) == nil) let transcript = "hey clawd"
let segments = makeSegments(
transcript: transcript,
words: [("hey", 0.0, 0.1), ("clawd", 0.2, 0.1)])
#expect(VoiceWakeManager.extractCommand(from: transcript, segments: segments, triggers: ["clawd"]) == nil)
} }
@Test func extractCommandIgnoresEmptyTriggers() { @Test func extractCommandIgnoresEmptyTriggers() {
let cmd = VoiceWakeManager.extractCommand(from: "hey clawd do thing", triggers: ["", " ", "clawd"]) let transcript = "hey clawd do thing"
let segments = makeSegments(
transcript: transcript,
words: [
("hey", 0.0, 0.1),
("clawd", 0.2, 0.1),
("do", 0.9, 0.1),
("thing", 1.1, 0.1),
])
let cmd = VoiceWakeManager.extractCommand(
from: transcript,
segments: segments,
triggers: ["", " ", "clawd"],
minPostTriggerGap: 0.3)
#expect(cmd == "do thing") #expect(cmd == "do thing")
} }
} }
private func makeSegments(
transcript: String,
words: [(String, TimeInterval, TimeInterval)])
-> [WakeWordSegment] {
var searchStart = transcript.startIndex
var output: [WakeWordSegment] = []
for (word, start, duration) in words {
let range = transcript.range(of: word, range: searchStart..<transcript.endIndex)
output.append(WakeWordSegment(text: word, start: start, duration: duration, range: range))
if let range { searchStart = range.upperBound }
}
return output
}

View File

@@ -8,6 +8,8 @@ options:
packages: packages:
ClawdisKit: ClawdisKit:
path: ../shared/ClawdisKit path: ../shared/ClawdisKit
Swabble:
path: ../../Swabble
schemes: schemes:
Clawdis: Clawdis:
@@ -29,6 +31,8 @@ targets:
- package: ClawdisKit - package: ClawdisKit
- package: ClawdisKit - package: ClawdisKit
product: ClawdisChatUI product: ClawdisChatUI
- package: Swabble
product: SwabbleKit
- sdk: AppIntents.framework - sdk: AppIntents.framework
preBuildScripts: preBuildScripts:
- name: SwiftFormat (lint) - name: SwiftFormat (lint)
@@ -86,6 +90,8 @@ targets:
- path: Tests - path: Tests
dependencies: dependencies:
- target: Clawdis - target: Clawdis
- package: Swabble
product: SwabbleKit
settings: settings:
base: base:
PRODUCT_BUNDLE_IDENTIFIER: com.steipete.clawdis.ios.tests PRODUCT_BUNDLE_IDENTIFIER: com.steipete.clawdis.ios.tests

View File

@@ -17,6 +17,7 @@ let package = Package(
.package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.1.0"), .package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.1.0"),
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.8.1"), .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.8.1"),
.package(path: "../shared/ClawdisKit"), .package(path: "../shared/ClawdisKit"),
.package(path: "../../Swabble"),
.package(path: "../../Peekaboo/Core/PeekabooCore"), .package(path: "../../Peekaboo/Core/PeekabooCore"),
.package(path: "../../Peekaboo/Core/PeekabooAutomationKit"), .package(path: "../../Peekaboo/Core/PeekabooAutomationKit"),
], ],
@@ -41,6 +42,7 @@ let package = Package(
"ClawdisProtocol", "ClawdisProtocol",
.product(name: "ClawdisKit", package: "ClawdisKit"), .product(name: "ClawdisKit", package: "ClawdisKit"),
.product(name: "ClawdisChatUI", package: "ClawdisKit"), .product(name: "ClawdisChatUI", package: "ClawdisKit"),
.product(name: "SwabbleKit", package: "swabble"),
.product(name: "MenuBarExtraAccess", package: "MenuBarExtraAccess"), .product(name: "MenuBarExtraAccess", package: "MenuBarExtraAccess"),
.product(name: "Subprocess", package: "swift-subprocess"), .product(name: "Subprocess", package: "swift-subprocess"),
.product(name: "Sparkle", package: "Sparkle"), .product(name: "Sparkle", package: "Sparkle"),
@@ -56,7 +58,12 @@ let package = Package(
]), ]),
.testTarget( .testTarget(
name: "ClawdisIPCTests", name: "ClawdisIPCTests",
dependencies: ["ClawdisIPC", "Clawdis", "ClawdisProtocol"], dependencies: [
"ClawdisIPC",
"Clawdis",
"ClawdisProtocol",
.product(name: "SwabbleKit", package: "swabble"),
],
swiftSettings: [ swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"), .enableUpcomingFeature("StrictConcurrency"),
.enableExperimentalFeature("SwiftTesting"), .enableExperimentalFeature("SwiftTesting"),

View File

@@ -2,6 +2,7 @@ import AVFoundation
import Foundation import Foundation
import OSLog import OSLog
import Speech import Speech
import SwabbleKit
#if canImport(AppKit) #if canImport(AppKit)
import AppKit import AppKit
#endif #endif
@@ -35,6 +36,7 @@ actor VoiceWakeRuntime {
private var currentConfig: RuntimeConfig? private var currentConfig: RuntimeConfig?
private var listeningState: ListeningState = .idle private var listeningState: ListeningState = .idle
private var overlayToken: UUID? private var overlayToken: UUID?
private var activeTriggerEndTime: TimeInterval?
// Tunables // Tunables
// Silence threshold once we've captured user speech (post-trigger). // Silence threshold once we've captured user speech (post-trigger).
@@ -147,9 +149,13 @@ actor VoiceWakeRuntime {
self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self, generation] result, error in self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self, generation] result, error in
guard let self else { return } guard let self else { return }
let transcript = result?.bestTranscription.formattedString let transcript = result?.bestTranscription.formattedString
let segments = result.flatMap { result in
transcript.map { WakeWordSpeechSegments.from(transcription: result.bestTranscription, transcript: $0) }
} ?? []
let isFinal = result?.isFinal ?? false let isFinal = result?.isFinal ?? false
Task { await self.handleRecognition( Task { await self.handleRecognition(
transcript: transcript, transcript: transcript,
segments: segments,
isFinal: isFinal, isFinal: isFinal,
error: error, error: error,
config: config, config: config,
@@ -184,6 +190,7 @@ actor VoiceWakeRuntime {
self.audioEngine = nil self.audioEngine = nil
self.currentConfig = nil self.currentConfig = nil
self.listeningState = .idle self.listeningState = .idle
self.activeTriggerEndTime = nil
self.logger.debug("voicewake runtime stopped") self.logger.debug("voicewake runtime stopped")
DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "stopped") DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "stopped")
@@ -206,6 +213,7 @@ actor VoiceWakeRuntime {
private func handleRecognition( private func handleRecognition(
transcript: String?, transcript: String?,
segments: [WakeWordSegment],
isFinal: Bool, isFinal: Bool,
error: Error?, error: Error?,
config: RuntimeConfig, config: RuntimeConfig,
@@ -224,7 +232,11 @@ actor VoiceWakeRuntime {
if !transcript.isEmpty { if !transcript.isEmpty {
self.lastHeard = now self.lastHeard = now
if self.isCapturing { if self.isCapturing {
let trimmed = Self.trimmedAfterTrigger(transcript, triggers: config.triggers) let trimmed = Self.commandAfterTrigger(
transcript: transcript,
segments: segments,
triggerEndTime: self.activeTriggerEndTime,
triggers: config.triggers)
self.capturedTranscript = trimmed self.capturedTranscript = trimmed
self.updateHeardBeyondTrigger(withTrimmed: trimmed) self.updateHeardBeyondTrigger(withTrimmed: trimmed)
if isFinal { if isFinal {
@@ -252,37 +264,27 @@ actor VoiceWakeRuntime {
if self.isCapturing { return } if self.isCapturing { return }
if Self.matches(text: transcript, triggers: config.triggers) { let gateConfig = WakeWordGateConfig(triggers: config.triggers)
if let match = WakeWordGate.match(transcript: transcript, segments: segments, config: gateConfig) {
if let cooldown = cooldownUntil, now < cooldown { if let cooldown = cooldownUntil, now < cooldown {
return return
} }
await self.beginCapture(transcript: transcript, config: config) await self.beginCapture(command: match.command, triggerEndTime: match.triggerEndTime, config: config)
} }
} }
private static func matches(text: String, triggers: [String]) -> Bool { private func beginCapture(command: String, triggerEndTime: TimeInterval, config: RuntimeConfig) async {
guard !text.isEmpty else { return false }
let normalized = text.lowercased()
for trigger in triggers {
let t = trigger.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
if t.isEmpty { continue }
if normalized.contains(t) { return true }
}
return false
}
private func beginCapture(transcript: String, config: RuntimeConfig) async {
self.listeningState = .voiceWake self.listeningState = .voiceWake
self.isCapturing = true self.isCapturing = true
DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "beginCapture") DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "beginCapture")
let trimmed = Self.trimmedAfterTrigger(transcript, triggers: config.triggers) self.capturedTranscript = command
self.capturedTranscript = trimmed
self.committedTranscript = "" self.committedTranscript = ""
self.volatileTranscript = trimmed self.volatileTranscript = command
self.captureStartedAt = Date() self.captureStartedAt = Date()
self.cooldownUntil = nil self.cooldownUntil = nil
self.heardBeyondTrigger = !trimmed.isEmpty self.heardBeyondTrigger = !command.isEmpty
self.triggerChimePlayed = false self.triggerChimePlayed = false
self.activeTriggerEndTime = triggerEndTime
if config.triggerChime != .none, !self.triggerChimePlayed { if config.triggerChime != .none, !self.triggerChimePlayed {
self.triggerChimePlayed = true self.triggerChimePlayed = true
@@ -354,6 +356,7 @@ actor VoiceWakeRuntime {
self.lastHeard = nil self.lastHeard = nil
self.heardBeyondTrigger = false self.heardBeyondTrigger = false
self.triggerChimePlayed = false self.triggerChimePlayed = false
self.activeTriggerEndTime = nil
await MainActor.run { AppStateStore.shared.stopVoiceEars() } await MainActor.run { AppStateStore.shared.stopVoiceEars() }
if let token = self.overlayToken { if let token = self.overlayToken {
@@ -467,6 +470,22 @@ actor VoiceWakeRuntime {
return text return text
} }
private static func commandAfterTrigger(
transcript: String,
segments: [WakeWordSegment],
triggerEndTime: TimeInterval?,
triggers: [String]) -> String
{
guard let triggerEndTime else {
return trimmedAfterTrigger(transcript, triggers: triggers)
}
let trimmed = WakeWordGate.commandText(
transcript: transcript,
segments: segments,
triggerEndTime: triggerEndTime)
return trimmed.isEmpty ? trimmedAfterTrigger(transcript, triggers: triggers) : trimmed
}
#if DEBUG #if DEBUG
static func _testTrimmedAfterTrigger(_ text: String, triggers: [String]) -> String { static func _testTrimmedAfterTrigger(_ text: String, triggers: [String]) -> String {
self.trimmedAfterTrigger(text, triggers: triggers) self.trimmedAfterTrigger(text, triggers: triggers)
@@ -481,9 +500,6 @@ actor VoiceWakeRuntime {
.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor ?? .clear .attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor ?? .clear
} }
static func _testMatches(text: String, triggers: [String]) -> Bool {
self.matches(text: text, triggers: triggers)
}
#endif #endif
private static func delta(after committed: String, current: String) -> String { private static func delta(after committed: String, current: String) -> String {

View File

@@ -2,6 +2,7 @@ import AVFoundation
import Foundation import Foundation
import OSLog import OSLog
import Speech import Speech
import SwabbleKit
enum VoiceWakeTestState: Equatable { enum VoiceWakeTestState: Equatable {
case idle case idle
@@ -93,14 +94,16 @@ final class VoiceWakeTester {
self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in
guard let self, !self.isStopping else { return } guard let self, !self.isStopping else { return }
let text = result?.bestTranscription.formattedString ?? "" let text = result?.bestTranscription.formattedString ?? ""
let matched = Self.matches(text: text, triggers: triggers) let segments = result.map { WakeWordSpeechSegments.from(transcription: $0.bestTranscription, transcript: text) } ?? []
let gateConfig = WakeWordGateConfig(triggers: triggers)
let match = WakeWordGate.match(transcript: text, segments: segments, config: gateConfig)
let isFinal = result?.isFinal ?? false let isFinal = result?.isFinal ?? false
let errorMessage = error?.localizedDescription let errorMessage = error?.localizedDescription
Task { [weak self] in Task { [weak self] in
guard let self, !self.isStopping else { return } guard let self, !self.isStopping else { return }
await self.handleResult( await self.handleResult(
matched: matched, match: match,
text: text, text: text,
isFinal: isFinal, isFinal: isFinal,
errorMessage: errorMessage, errorMessage: errorMessage,
@@ -120,7 +123,7 @@ final class VoiceWakeTester {
} }
private func handleResult( private func handleResult(
matched: Bool, match: WakeWordGateMatch?,
text: String, text: String,
isFinal: Bool, isFinal: Bool,
errorMessage: String?, errorMessage: String?,
@@ -129,15 +132,15 @@ final class VoiceWakeTester {
if !text.isEmpty { if !text.isEmpty {
self.lastHeard = Date() self.lastHeard = Date()
} }
if matched, !text.isEmpty { if let match, !match.command.isEmpty {
self.holdingAfterDetect = true self.holdingAfterDetect = true
self.detectedText = text self.detectedText = match.command
self.logger.info("voice wake detected; forwarding (len=\(text.count))") self.logger.info("voice wake detected; forwarding (len=\(match.command.count))")
await MainActor.run { AppStateStore.shared.triggerVoiceEars(ttl: nil) } await MainActor.run { AppStateStore.shared.triggerVoiceEars(ttl: nil) }
Task.detached { Task.detached {
await VoiceWakeForwarder.forward(transcript: text) await VoiceWakeForwarder.forward(transcript: match.command)
} }
Task { @MainActor in onUpdate(.detected(text)) } Task { @MainActor in onUpdate(.detected(match.command)) }
self.holdUntilSilence(onUpdate: onUpdate) self.holdUntilSilence(onUpdate: onUpdate)
return return
} }
@@ -187,15 +190,6 @@ final class VoiceWakeTester {
_ = preferredMicID _ = preferredMicID
} }
private static func matches(text: String, triggers: [String]) -> Bool {
let lowered = text.lowercased()
return triggers.contains { lowered.contains($0.lowercased()) }
}
static func _testMatches(text: String, triggers: [String]) -> Bool {
self.matches(text: text, triggers: triggers)
}
private nonisolated static func ensurePermissions() async throws -> Bool { private nonisolated static func ensurePermissions() async throws -> Bool {
let speechStatus = SFSpeechRecognizer.authorizationStatus() let speechStatus = SFSpeechRecognizer.authorizationStatus()
if speechStatus == .notDetermined { if speechStatus == .notDetermined {

View File

@@ -1,23 +1,9 @@
import Foundation
import Testing import Testing
import SwabbleKit
@testable import Clawdis @testable import Clawdis
@Suite struct VoiceWakeRuntimeTests { @Suite struct VoiceWakeRuntimeTests {
@Test func matchesIsCaseInsensitive() {
let triggers = ["ClAwD", "buddy"]
#expect(VoiceWakeRuntime._testMatches(text: "hey clawd are you there", triggers: triggers))
#expect(!VoiceWakeRuntime._testMatches(text: "nothing to see", triggers: triggers))
}
@Test func matchesIgnoresWhitespace() {
let triggers = [" claude "]
#expect(VoiceWakeRuntime._testMatches(text: "hello claude!", triggers: triggers))
}
@Test func matchesSkipsEmptyTriggers() {
let triggers = [" ", ""]
#expect(!VoiceWakeRuntime._testMatches(text: "hello", triggers: triggers))
}
@Test func trimsAfterTriggerKeepsPostSpeech() { @Test func trimsAfterTriggerKeepsPostSpeech() {
let triggers = ["claude", "clawd"] let triggers = ["claude", "clawd"]
let text = "hey Claude how are you" let text = "hey Claude how are you"
@@ -48,4 +34,46 @@ import Testing
let text = "claude write a note" let text = "claude write a note"
#expect(VoiceWakeRuntime._testHasContentAfterTrigger(text, triggers: triggers)) #expect(VoiceWakeRuntime._testHasContentAfterTrigger(text, triggers: triggers))
} }
@Test func gateRequiresGapBetweenTriggerAndCommand() {
let transcript = "hey clawd do thing"
let segments = makeSegments(
transcript: transcript,
words: [
("hey", 0.0, 0.1),
("clawd", 0.2, 0.1),
("do", 0.35, 0.1),
("thing", 0.5, 0.1),
])
let config = WakeWordGateConfig(triggers: ["clawd"], minPostTriggerGap: 0.3)
#expect(WakeWordGate.match(transcript: transcript, segments: segments, config: config) == nil)
}
@Test func gateAcceptsGapAndExtractsCommand() {
let transcript = "hey clawd do thing"
let segments = makeSegments(
transcript: transcript,
words: [
("hey", 0.0, 0.1),
("clawd", 0.2, 0.1),
("do", 0.9, 0.1),
("thing", 1.1, 0.1),
])
let config = WakeWordGateConfig(triggers: ["clawd"], minPostTriggerGap: 0.3)
#expect(WakeWordGate.match(transcript: transcript, segments: segments, config: config)?.command == "do thing")
}
}
private func makeSegments(
transcript: String,
words: [(String, TimeInterval, TimeInterval)])
-> [WakeWordSegment] {
var searchStart = transcript.startIndex
var output: [WakeWordSegment] = []
for (word, start, duration) in words {
let range = transcript.range(of: word, range: searchStart..<transcript.endIndex)
output.append(WakeWordSegment(text: word, start: start, duration: duration, range: range))
if let range { searchStart = range.upperBound }
}
return output
} }

View File

@@ -1,15 +1,47 @@
import Foundation
import Testing import Testing
@testable import Clawdis import SwabbleKit
struct VoiceWakeTesterTests { struct VoiceWakeTesterTests {
@Test func matchesIsCaseInsensitiveAndSubstring() { @Test func matchRespectsGapRequirement() {
let triggers = ["Claude", "wake word"] let transcript = "hey claude do thing"
#expect(VoiceWakeTester._testMatches(text: "hey claude are you there", triggers: triggers)) let segments = makeSegments(
#expect(VoiceWakeTester._testMatches(text: "this has wake word inside", triggers: triggers)) transcript: transcript,
words: [
("hey", 0.0, 0.1),
("claude", 0.2, 0.1),
("do", 0.35, 0.1),
("thing", 0.5, 0.1),
])
let config = WakeWordGateConfig(triggers: ["claude"], minPostTriggerGap: 0.3)
#expect(WakeWordGate.match(transcript: transcript, segments: segments, config: config) == nil)
} }
@Test func matchesReturnsFalseWhenNoTrigger() { @Test func matchReturnsCommandAfterGap() {
let triggers = ["claude"] let transcript = "hey claude do thing"
#expect(!VoiceWakeTester._testMatches(text: "random text", triggers: triggers)) let segments = makeSegments(
transcript: transcript,
words: [
("hey", 0.0, 0.1),
("claude", 0.2, 0.1),
("do", 0.8, 0.1),
("thing", 1.0, 0.1),
])
let config = WakeWordGateConfig(triggers: ["claude"], minPostTriggerGap: 0.3)
#expect(WakeWordGate.match(transcript: transcript, segments: segments, config: config)?.command == "do thing")
} }
} }
private func makeSegments(
transcript: String,
words: [(String, TimeInterval, TimeInterval)])
-> [WakeWordSegment] {
var searchStart = transcript.startIndex
var output: [WakeWordSegment] = []
for (word, start, duration) in words {
let range = transcript.range(of: word, range: searchStart..<transcript.endIndex)
output.append(WakeWordSegment(text: word, start: start, duration: duration, range: range))
if let range { searchStart = range.upperBound }
}
return output
}

View File

@@ -0,0 +1 @@
50345

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,311 @@
// Generated by Apple Swift version 6.2.3 (swiftlang-6.2.3.3.21 clang-1700.6.3.2)
#ifndef CLAWDISCHATUI_SWIFT_H
#define CLAWDISCHATUI_SWIFT_H
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wgcc-compat"
#if !defined(__has_include)
# define __has_include(x) 0
#endif
#if !defined(__has_attribute)
# define __has_attribute(x) 0
#endif
#if !defined(__has_feature)
# define __has_feature(x) 0
#endif
#if !defined(__has_warning)
# define __has_warning(x) 0
#endif
#if __has_include(<swift/objc-prologue.h>)
# include <swift/objc-prologue.h>
#endif
#pragma clang diagnostic ignored "-Wauto-import"
#if defined(__OBJC__)
#include <Foundation/Foundation.h>
#endif
#if defined(__cplusplus)
#include <cstdint>
#include <cstddef>
#include <cstdbool>
#include <cstring>
#include <stdlib.h>
#include <new>
#include <type_traits>
#else
#include <stdint.h>
#include <stddef.h>
#include <stdbool.h>
#include <string.h>
#endif
#if defined(__cplusplus)
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wnon-modular-include-in-framework-module"
#if defined(__arm64e__) && __has_include(<ptrauth.h>)
# include <ptrauth.h>
#else
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wreserved-macro-identifier"
# ifndef __ptrauth_swift_value_witness_function_pointer
# define __ptrauth_swift_value_witness_function_pointer(x)
# endif
# ifndef __ptrauth_swift_class_method_pointer
# define __ptrauth_swift_class_method_pointer(x)
# endif
#pragma clang diagnostic pop
#endif
#pragma clang diagnostic pop
#endif
#if !defined(SWIFT_TYPEDEFS)
# define SWIFT_TYPEDEFS 1
# if __has_include(<uchar.h>)
# include <uchar.h>
# elif !defined(__cplusplus)
typedef unsigned char char8_t;
typedef uint_least16_t char16_t;
typedef uint_least32_t char32_t;
# endif
typedef float swift_float2 __attribute__((__ext_vector_type__(2)));
typedef float swift_float3 __attribute__((__ext_vector_type__(3)));
typedef float swift_float4 __attribute__((__ext_vector_type__(4)));
typedef double swift_double2 __attribute__((__ext_vector_type__(2)));
typedef double swift_double3 __attribute__((__ext_vector_type__(3)));
typedef double swift_double4 __attribute__((__ext_vector_type__(4)));
typedef int swift_int2 __attribute__((__ext_vector_type__(2)));
typedef int swift_int3 __attribute__((__ext_vector_type__(3)));
typedef int swift_int4 __attribute__((__ext_vector_type__(4)));
typedef unsigned int swift_uint2 __attribute__((__ext_vector_type__(2)));
typedef unsigned int swift_uint3 __attribute__((__ext_vector_type__(3)));
typedef unsigned int swift_uint4 __attribute__((__ext_vector_type__(4)));
#endif
#if !defined(SWIFT_PASTE)
# define SWIFT_PASTE_HELPER(x, y) x##y
# define SWIFT_PASTE(x, y) SWIFT_PASTE_HELPER(x, y)
#endif
#if !defined(SWIFT_METATYPE)
# define SWIFT_METATYPE(X) Class
#endif
#if !defined(SWIFT_CLASS_PROPERTY)
# if __has_feature(objc_class_property)
# define SWIFT_CLASS_PROPERTY(...) __VA_ARGS__
# else
# define SWIFT_CLASS_PROPERTY(...)
# endif
#endif
#if !defined(SWIFT_RUNTIME_NAME)
# if __has_attribute(objc_runtime_name)
# define SWIFT_RUNTIME_NAME(X) __attribute__((objc_runtime_name(X)))
# else
# define SWIFT_RUNTIME_NAME(X)
# endif
#endif
#if !defined(SWIFT_COMPILE_NAME)
# if __has_attribute(swift_name)
# define SWIFT_COMPILE_NAME(X) __attribute__((swift_name(X)))
# else
# define SWIFT_COMPILE_NAME(X)
# endif
#endif
#if !defined(SWIFT_METHOD_FAMILY)
# if __has_attribute(objc_method_family)
# define SWIFT_METHOD_FAMILY(X) __attribute__((objc_method_family(X)))
# else
# define SWIFT_METHOD_FAMILY(X)
# endif
#endif
#if !defined(SWIFT_NOESCAPE)
# if __has_attribute(noescape)
# define SWIFT_NOESCAPE __attribute__((noescape))
# else
# define SWIFT_NOESCAPE
# endif
#endif
#if !defined(SWIFT_RELEASES_ARGUMENT)
# if __has_attribute(ns_consumed)
# define SWIFT_RELEASES_ARGUMENT __attribute__((ns_consumed))
# else
# define SWIFT_RELEASES_ARGUMENT
# endif
#endif
#if !defined(SWIFT_WARN_UNUSED_RESULT)
# if __has_attribute(warn_unused_result)
# define SWIFT_WARN_UNUSED_RESULT __attribute__((warn_unused_result))
# else
# define SWIFT_WARN_UNUSED_RESULT
# endif
#endif
#if !defined(SWIFT_NORETURN)
# if __has_attribute(noreturn)
# define SWIFT_NORETURN __attribute__((noreturn))
# else
# define SWIFT_NORETURN
# endif
#endif
#if !defined(SWIFT_CLASS_EXTRA)
# define SWIFT_CLASS_EXTRA
#endif
#if !defined(SWIFT_PROTOCOL_EXTRA)
# define SWIFT_PROTOCOL_EXTRA
#endif
#if !defined(SWIFT_ENUM_EXTRA)
# define SWIFT_ENUM_EXTRA
#endif
#if !defined(SWIFT_CLASS)
# if __has_attribute(objc_subclassing_restricted)
# define SWIFT_CLASS(SWIFT_NAME) SWIFT_RUNTIME_NAME(SWIFT_NAME) __attribute__((objc_subclassing_restricted)) SWIFT_CLASS_EXTRA
# define SWIFT_CLASS_NAMED(SWIFT_NAME) __attribute__((objc_subclassing_restricted)) SWIFT_COMPILE_NAME(SWIFT_NAME) SWIFT_CLASS_EXTRA
# else
# define SWIFT_CLASS(SWIFT_NAME) SWIFT_RUNTIME_NAME(SWIFT_NAME) SWIFT_CLASS_EXTRA
# define SWIFT_CLASS_NAMED(SWIFT_NAME) SWIFT_COMPILE_NAME(SWIFT_NAME) SWIFT_CLASS_EXTRA
# endif
#endif
#if !defined(SWIFT_RESILIENT_CLASS)
# if __has_attribute(objc_class_stub)
# define SWIFT_RESILIENT_CLASS(SWIFT_NAME) SWIFT_CLASS(SWIFT_NAME) __attribute__((objc_class_stub))
# define SWIFT_RESILIENT_CLASS_NAMED(SWIFT_NAME) __attribute__((objc_class_stub)) SWIFT_CLASS_NAMED(SWIFT_NAME)
# else
# define SWIFT_RESILIENT_CLASS(SWIFT_NAME) SWIFT_CLASS(SWIFT_NAME)
# define SWIFT_RESILIENT_CLASS_NAMED(SWIFT_NAME) SWIFT_CLASS_NAMED(SWIFT_NAME)
# endif
#endif
#if !defined(SWIFT_PROTOCOL)
# define SWIFT_PROTOCOL(SWIFT_NAME) SWIFT_RUNTIME_NAME(SWIFT_NAME) SWIFT_PROTOCOL_EXTRA
# define SWIFT_PROTOCOL_NAMED(SWIFT_NAME) SWIFT_COMPILE_NAME(SWIFT_NAME) SWIFT_PROTOCOL_EXTRA
#endif
#if !defined(SWIFT_EXTENSION)
# define SWIFT_EXTENSION(M) SWIFT_PASTE(M##_Swift_, __LINE__)
#endif
#if !defined(OBJC_DESIGNATED_INITIALIZER)
# if __has_attribute(objc_designated_initializer)
# define OBJC_DESIGNATED_INITIALIZER __attribute__((objc_designated_initializer))
# else
# define OBJC_DESIGNATED_INITIALIZER
# endif
#endif
#if !defined(SWIFT_ENUM_ATTR)
# if __has_attribute(enum_extensibility)
# define SWIFT_ENUM_ATTR(_extensibility) __attribute__((enum_extensibility(_extensibility)))
# else
# define SWIFT_ENUM_ATTR(_extensibility)
# endif
#endif
#if !defined(SWIFT_ENUM)
# define SWIFT_ENUM(_type, _name, _extensibility) enum _name : _type _name; enum SWIFT_ENUM_ATTR(_extensibility) SWIFT_ENUM_EXTRA _name : _type
# if __has_feature(generalized_swift_name)
# define SWIFT_ENUM_NAMED(_type, _name, SWIFT_NAME, _extensibility) enum _name : _type _name SWIFT_COMPILE_NAME(SWIFT_NAME); enum SWIFT_COMPILE_NAME(SWIFT_NAME) SWIFT_ENUM_ATTR(_extensibility) SWIFT_ENUM_EXTRA _name : _type
# else
# define SWIFT_ENUM_NAMED(_type, _name, SWIFT_NAME, _extensibility) SWIFT_ENUM(_type, _name, _extensibility)
# endif
#endif
#if !defined(SWIFT_UNAVAILABLE)
# define SWIFT_UNAVAILABLE __attribute__((unavailable))
#endif
#if !defined(SWIFT_UNAVAILABLE_MSG)
# define SWIFT_UNAVAILABLE_MSG(msg) __attribute__((unavailable(msg)))
#endif
#if !defined(SWIFT_AVAILABILITY)
# define SWIFT_AVAILABILITY(plat, ...) __attribute__((availability(plat, __VA_ARGS__)))
#endif
#if !defined(SWIFT_WEAK_IMPORT)
# define SWIFT_WEAK_IMPORT __attribute__((weak_import))
#endif
#if !defined(SWIFT_DEPRECATED)
# define SWIFT_DEPRECATED __attribute__((deprecated))
#endif
#if !defined(SWIFT_DEPRECATED_MSG)
# define SWIFT_DEPRECATED_MSG(...) __attribute__((deprecated(__VA_ARGS__)))
#endif
#if !defined(SWIFT_DEPRECATED_OBJC)
# if __has_feature(attribute_diagnose_if_objc)
# define SWIFT_DEPRECATED_OBJC(Msg) __attribute__((diagnose_if(1, Msg, "warning")))
# else
# define SWIFT_DEPRECATED_OBJC(Msg) SWIFT_DEPRECATED_MSG(Msg)
# endif
#endif
#if defined(__OBJC__)
#if !defined(IBSegueAction)
# define IBSegueAction
#endif
#endif
#if !defined(SWIFT_EXTERN)
# if defined(__cplusplus)
# define SWIFT_EXTERN extern "C"
# else
# define SWIFT_EXTERN extern
# endif
#endif
#if !defined(SWIFT_CALL)
# define SWIFT_CALL __attribute__((swiftcall))
#endif
#if !defined(SWIFT_INDIRECT_RESULT)
# define SWIFT_INDIRECT_RESULT __attribute__((swift_indirect_result))
#endif
#if !defined(SWIFT_CONTEXT)
# define SWIFT_CONTEXT __attribute__((swift_context))
#endif
#if !defined(SWIFT_ERROR_RESULT)
# define SWIFT_ERROR_RESULT __attribute__((swift_error_result))
#endif
#if defined(__cplusplus)
# define SWIFT_NOEXCEPT noexcept
#else
# define SWIFT_NOEXCEPT
#endif
#if !defined(SWIFT_C_INLINE_THUNK)
# if __has_attribute(always_inline)
# if __has_attribute(nodebug)
# define SWIFT_C_INLINE_THUNK inline __attribute__((always_inline)) __attribute__((nodebug))
# else
# define SWIFT_C_INLINE_THUNK inline __attribute__((always_inline))
# endif
# else
# define SWIFT_C_INLINE_THUNK inline
# endif
#endif
#if defined(_WIN32)
#if !defined(SWIFT_IMPORT_STDLIB_SYMBOL)
# define SWIFT_IMPORT_STDLIB_SYMBOL __declspec(dllimport)
#endif
#else
#if !defined(SWIFT_IMPORT_STDLIB_SYMBOL)
# define SWIFT_IMPORT_STDLIB_SYMBOL
#endif
#endif
#if defined(__OBJC__)
#if __has_feature(objc_modules)
#if __has_warning("-Watimport-in-framework-header")
#pragma clang diagnostic ignored "-Watimport-in-framework-header"
#endif
#endif
#endif
#pragma clang diagnostic ignored "-Wproperty-attribute-mismatch"
#pragma clang diagnostic ignored "-Wduplicate-method-arg"
#if __has_warning("-Wpragma-clang-attribute")
# pragma clang diagnostic ignored "-Wpragma-clang-attribute"
#endif
#pragma clang diagnostic ignored "-Wunknown-pragmas"
#pragma clang diagnostic ignored "-Wnullability"
#pragma clang diagnostic ignored "-Wdollar-in-identifier-extension"
#pragma clang diagnostic ignored "-Wunsafe-buffer-usage"
#if __has_attribute(external_source_symbol)
# pragma push_macro("any")
# undef any
# pragma clang attribute push(__attribute__((external_source_symbol(language="Swift", defined_in="ClawdisChatUI",generated_declaration))), apply_to=any(function,enum,objc_interface,objc_category,objc_protocol))
# pragma pop_macro("any")
#endif
#if defined(__OBJC__)
#endif
#if __has_attribute(external_source_symbol)
# pragma clang attribute pop
#endif
#if defined(__cplusplus)
#endif
#pragma clang diagnostic pop
#endif

View File

@@ -0,0 +1,3 @@
module ClawdisChatUI {
header "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/include/ClawdisChatUI-Swift.h"
}

View File

@@ -0,0 +1,82 @@
{
"": {
"swift-dependencies": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/master.swiftdeps"
},
"/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatComposer.swift": {
"dependencies": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatComposer.d",
"object": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatComposer.swift.o",
"swiftmodule": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatComposer~partial.swiftmodule",
"swift-dependencies": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatComposer.swiftdeps",
"diagnostics": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatComposer.dia"
},
"/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatMarkdownSplitter.swift": {
"dependencies": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatMarkdownSplitter.d",
"object": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatMarkdownSplitter.swift.o",
"swiftmodule": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatMarkdownSplitter~partial.swiftmodule",
"swift-dependencies": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatMarkdownSplitter.swiftdeps",
"diagnostics": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatMarkdownSplitter.dia"
},
"/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatMessageViews.swift": {
"dependencies": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatMessageViews.d",
"object": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatMessageViews.swift.o",
"swiftmodule": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatMessageViews~partial.swiftmodule",
"swift-dependencies": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatMessageViews.swiftdeps",
"diagnostics": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatMessageViews.dia"
},
"/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatModels.swift": {
"dependencies": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatModels.d",
"object": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatModels.swift.o",
"swiftmodule": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatModels~partial.swiftmodule",
"swift-dependencies": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatModels.swiftdeps",
"diagnostics": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatModels.dia"
},
"/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatPayloadDecoding.swift": {
"dependencies": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatPayloadDecoding.d",
"object": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatPayloadDecoding.swift.o",
"swiftmodule": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatPayloadDecoding~partial.swiftmodule",
"swift-dependencies": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatPayloadDecoding.swiftdeps",
"diagnostics": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatPayloadDecoding.dia"
},
"/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatSessions.swift": {
"dependencies": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatSessions.d",
"object": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatSessions.swift.o",
"swiftmodule": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatSessions~partial.swiftmodule",
"swift-dependencies": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatSessions.swiftdeps",
"diagnostics": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatSessions.dia"
},
"/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatSheets.swift": {
"dependencies": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatSheets.d",
"object": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatSheets.swift.o",
"swiftmodule": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatSheets~partial.swiftmodule",
"swift-dependencies": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatSheets.swiftdeps",
"diagnostics": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatSheets.dia"
},
"/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatTheme.swift": {
"dependencies": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatTheme.d",
"object": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatTheme.swift.o",
"swiftmodule": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatTheme~partial.swiftmodule",
"swift-dependencies": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatTheme.swiftdeps",
"diagnostics": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatTheme.dia"
},
"/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatTransport.swift": {
"dependencies": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatTransport.d",
"object": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatTransport.swift.o",
"swiftmodule": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatTransport~partial.swiftmodule",
"swift-dependencies": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatTransport.swiftdeps",
"diagnostics": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatTransport.dia"
},
"/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift": {
"dependencies": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatView.d",
"object": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatView.swift.o",
"swiftmodule": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatView~partial.swiftmodule",
"swift-dependencies": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatView.swiftdeps",
"diagnostics": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatView.dia"
},
"/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatViewModel.swift": {
"dependencies": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatViewModel.d",
"object": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatViewModel.swift.o",
"swiftmodule": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatViewModel~partial.swiftmodule",
"swift-dependencies": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatViewModel.swiftdeps",
"diagnostics": "/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/.build/arm64-apple-macosx/debug/ClawdisChatUI.build/ChatViewModel.dia"
}
}

View File

@@ -0,0 +1,11 @@
/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatComposer.swift
/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatMarkdownSplitter.swift
/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatMessageViews.swift
/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatModels.swift
/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatPayloadDecoding.swift
/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatSessions.swift
/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatSheets.swift
/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatTheme.swift
/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatTransport.swift
/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift
/Users/steipete/Projects/clawdis/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatViewModel.swift

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More