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
27 changes: 27 additions & 0 deletions docs/tracing.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,28 @@ anything from a missing launch line.

## `paste.verify` is the important one

Where the field reads back as text, the verifier also compares *what* arrived
with what was typed (`paste/landing.rs`). `text_complete` says whether any read
held the whole text. When scraps sit unchanged for 80 ms, a `paste.repair` line
follows: the verifier selected the scraps (AX range, read back to confirm) and
retyped the text over them. Its `outcome` is `repaired`, `late_complete` (a slow field finished on its own), `repair_failed` or
`skipped_<why>` (`recording`, `app_changed`, `field_changed`: the user edited
or sent it, `selection_no_focus` / `selection_refused` / `selection_unconfirmed`:
the AX selection could not be placed or confirmed); `landed` carries the scraps when diagnostics are
on. `repaired` and `late_complete` give `observed` with that `reason`; any
other outcome gives `verdict:"partial"`, puts the full text on the clipboard
(`clipboard.recovery`) and the pill says ⌘V. Seen 2026-09-26 (`0024-8368`): 156
characters typed into the Claude composer, 21 arrived, the last character of
each 16-character chunk and then the whole last chunk. Before this, that read
as `observed`.

`user_keys`, `foreign_keys` and `user_return_ms` count the key-downs that
reached the HID tap between injection and the verdict: from the hardware
(Karabiner's virtual keyboard counts as hardware), from another process, and
how long after injection began the user pressed Return. Counts only, never
which key. They are there to test whether something interleaved with the
injection.

`verdict: "observed"` with `reason: "cleared_after_landing"` means the field grew (`peak_delta_chars` > 0) and then went back to how it started — a chat app that sent or queued the message as soon as it arrived. Before 2026-09-14 that read as `swallowed` and the pill claimed nothing had been pasted.

`paste.result {"ok":true}` only means the events were handed to the window
Expand Down Expand Up @@ -304,6 +326,7 @@ later on the `paste.verify` line with the same trace id.
| --- | --- |
| `observed` | Someone read the target and it changed. |
| `swallowed` | Someone read the target and it did not. |
| `partial` | Someone read the target and only scraps of the text were there, and the repair could not fix it. |
| `ax_unreadable` | There was no baseline, so no evidence was ever going to arrive. |
| `pending` | The verifier has not concluded yet. Grep the trace id for `paste.verify`. |
| `inconclusive` | The verifier looked at both sides and still could not tell. |
Expand All @@ -316,6 +339,7 @@ later on the `paste.verify` line with the same trace id.
| `pasted` | **Observed.** Changed meaning in Polaris: it used to mean only that the events were posted, which is what let 219 blind pastes across the corpus — including `0112-bf80`, the one dictation where `Fn/Globe` was demonstrably held at injection — report themselves as successes. |
| `pasted_unverified` | Posted, nobody saw it land. `verification` says whether the evidence is still in flight (`pending`) or was never coming (`ax_unreadable`). Not a failure: the text very probably went in. |
| `paste_swallowed` | Observed *not* to have landed. |
| `paste_partial` | Observed to have landed incomplete or garbled. The full text is on the clipboard. Usually only on the `paste-verified` event, since `dictation.finish` is written before the verdict. |
| `clipboard_fallback` | The injection did not happen. The text is on the clipboard. |
| `aborted` | No text was produced at all. See below. |

Expand Down Expand Up @@ -399,6 +423,9 @@ grep '"outcome":"aborted"' ttp-trace.log
# Every dictation whose keystrokes were verifiably swallowed
grep 'paste.verify' ttp-trace.log | grep '"verdict":"swallowed"'

# Every dictation that landed as scraps, and what the repair did
grep -E 'paste.repair|"verdict":"partial"' ttp-trace.log

# Every dictation we could not verify at all, and why
grep 'paste.verify' ttp-trace.log | grep '"verdict":"unverified"'

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "ttp",
"private": true,
"version": "3.2.3",
"version": "3.2.4",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
225 changes: 225 additions & 0 deletions scripts/inject-bench/bench.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
// Injection bench: replays TTP's text-injection strategies into the focused
// field of a target app and reads back, over Accessibility, what landed.
//
// swiftc -O scripts/inject-bench/bench.swift -o /tmp/inject-bench
// /tmp/inject-bench probe com.anthropic.claudefordesktop
// /tmp/inject-bench run com.anthropic.claudefordesktop 6 [ttp,clip,...]
//
// REFOCUS_MS=0 bounce focus through Finder, return, wait N ms, then type
// HOLD_MS=800 leave a key-down without key-up before typing
//
// It takes the keyboard: the target comes to the front and text is typed into
// its focused field, then selected over Accessibility and deleted. Nothing is
// sent. Never uses a ⌘-shortcut to select: keycode 0 is Q on AZERTY, and ⌘ +
// keycode 0 quit the Claude app on the first attempt (2026-09-26).
//
// Findings on 2026-09-26, Claude desktop composer, 128 injections: every
// strategy lands intact when the field has focus; typed within 0 ms of the
// app regaining focus, the first two 16-char chunks are lost and ⌘V loses
// everything; at 100 ms nothing is lost. The 0024-8368 pattern (last char of
// each chunk) did not reproduce.
import AppKit
import ApplicationServices
import Foundation

setvbuf(stdout, nil, _IONBF, 0)

let texts = [
"Je vais te demander des promptes, donc des choses à copier et des choses à coller. D'accord. Ne les surcomplique pas, juste donne l'idée globale, simplifie.",
"Bon, alors là on a un vrai problème de fiabilité : quand je dicte une phrase un peu longue dans l'application, il arrive que seules quelques lettres éparpillées apparaissent au lieu du texte complet, et je ne m'en rends compte qu'après avoir envoyé le message. Il faut que ce soit réglé proprement.",
]

struct Variant {
let name: String
let chunk: Int // UTF-16 units per event; 0 = clipboard ⌘V
let keyUp: Bool
let gapMs: Double
let tap: CGEventTapLocation
}

let variants: [String: Variant] = [
"ttp": Variant(name: "ttp", chunk: 16, keyUp: false, gapMs: 0, tap: .cghidEventTap),
"ttp_up": Variant(name: "ttp_up", chunk: 16, keyUp: true, gapMs: 0, tap: .cghidEventTap),
"ttp_gap4": Variant(name: "ttp_gap4", chunk: 16, keyUp: false, gapMs: 4, tap: .cghidEventTap),
"up_gap4": Variant(name: "up_gap4", chunk: 16, keyUp: true, gapMs: 4, tap: .cghidEventTap),
"c8_up_gap4": Variant(name: "c8_up_gap4", chunk: 8, keyUp: true, gapMs: 4, tap: .cghidEventTap),
"c1_up": Variant(name: "c1_up", chunk: 1, keyUp: true, gapMs: 1, tap: .cghidEventTap),
"session": Variant(name: "session", chunk: 16, keyUp: false, gapMs: 0, tap: .cgAnnotatedSessionEventTap),
"clip": Variant(name: "clip", chunk: 0, keyUp: true, gapMs: 0, tap: .cgAnnotatedSessionEventTap),
]

// ── AX helpers ────────────────────────────────────────────────────────────
func wakeElectron(pid: pid_t) {
AXUIElementSetAttributeValue(AXUIElementCreateApplication(pid), "AXManualAccessibility" as CFString, kCFBooleanTrue)
}

func focusedElement(pid: pid_t) -> AXUIElement? {
let app = AXUIElementCreateApplication(pid)
var v: CFTypeRef?
guard AXUIElementCopyAttributeValue(app, kAXFocusedUIElementAttribute as CFString, &v) == .success else { return nil }
return (v as! AXUIElement)
}

func readText(pid: pid_t) -> String? {
guard let el = focusedElement(pid: pid) else { return nil }
var v: CFTypeRef?
if AXUIElementCopyAttributeValue(el, kAXValueAttribute as CFString, &v) == .success, let s = v as? String { return s }
return nil
}

func describe(pid: pid_t) -> String {
guard let el = focusedElement(pid: pid) else { return "no focused element" }
var role: CFTypeRef?; var sub: CFTypeRef?; var desc: CFTypeRef?
AXUIElementCopyAttributeValue(el, kAXRoleAttribute as CFString, &role)
AXUIElementCopyAttributeValue(el, kAXSubroleAttribute as CFString, &sub)
AXUIElementCopyAttributeValue(el, kAXDescriptionAttribute as CFString, &desc)
let t = readText(pid: pid)
return "role=\(role as? String ?? "-") sub=\(sub as? String ?? "-") desc=\(desc as? String ?? "-") value_chars=\(t.map { String($0.count) } ?? "unreadable")"
}

// ── Injection ─────────────────────────────────────────────────────────────
let source = CGEventSource(stateID: .privateState)!

func sleepMs(_ ms: Double) { if ms > 0 { usleep(useconds_t(ms * 1000)) } }

func chunks(_ text: String, _ max: Int) -> [String] {
var out: [String] = []; var cur = ""; var units = 0
for ch in text {
let w = String(ch).utf16.count
if units + w > max && !cur.isEmpty { out.append(cur); cur = ""; units = 0 }
cur.append(ch); units += w
}
if !cur.isEmpty { out.append(cur) }
return out
}

func postKey(_ code: CGKeyCode, flags: CGEventFlags, tap: CGEventTapLocation) {
let d = CGEvent(keyboardEventSource: source, virtualKey: code, keyDown: true)!
d.flags = flags; d.post(tap: tap)
usleep(8000)
let u = CGEvent(keyboardEventSource: source, virtualKey: code, keyDown: false)!
u.flags = flags; u.post(tap: tap)
}

func type(_ text: String, _ v: Variant) {
for c in chunks(text, v.chunk) {
let units = Array(c.utf16)
let d = CGEvent(keyboardEventSource: source, virtualKey: 0, keyDown: true)!
d.flags = []
d.keyboardSetUnicodeString(stringLength: units.count, unicodeString: units)
d.post(tap: v.tap)
if v.keyUp {
let u = CGEvent(keyboardEventSource: source, virtualKey: 0, keyDown: false)!
u.flags = []
u.keyboardSetUnicodeString(stringLength: units.count, unicodeString: units)
u.post(tap: v.tap)
}
sleepMs(v.gapMs)
}
}

func paste(_ text: String) {
let pb = NSPasteboard.general
let saved = pb.string(forType: .string)
pb.clearContents(); pb.setString(text, forType: .string)
postKey(9, flags: .maskCommand, tap: .cgAnnotatedSessionEventTap)
usleep(600_000)
pb.clearContents(); if let s = saved { pb.setString(s, forType: .string) }
}

/// Select the whole field through Accessibility, then Delete (keycode 51 is
/// the same key on every layout). Never a ⌘-shortcut: keycode 0 is Q on AZERTY,
/// and ⌘+keycode0 quit the Claude app on the first attempt.
func clearField(pid: pid_t) {
guard let el = focusedElement(pid: pid), let t = readText(pid: pid), !t.isEmpty else { return }
var range = CFRange(location: 0, length: (t as NSString).length)
let v = AXValueCreate(.cfRange, &range)!
AXUIElementSetAttributeValue(el, kAXSelectedTextRangeAttribute as CFString, v)
usleep(60_000)
postKey(51, flags: [], tap: .cgAnnotatedSessionEventTap)
usleep(150_000)
}

/// Poll until the field stops changing for 400 ms (max 3 s).
func settledText(pid: pid_t) -> (String?, Int) {
var last = readText(pid: pid); var stableSince = Date(); let start = Date()
while Date().timeIntervalSince(start) < 3 {
usleep(20_000)
let t = readText(pid: pid)
if t != last { last = t; stableSince = Date() }
else if Date().timeIntervalSince(stableSince) > 0.4 { break }
}
return (last, Int(Date().timeIntervalSince(start) * 1000))
}

// ── Main ──────────────────────────────────────────────────────────────────
let args = CommandLine.arguments
guard args.count >= 3, let target = NSRunningApplication.runningApplications(withBundleIdentifier: args[2]).first else {
print("usage: bench.swift probe|run <bundle-id> [reps] [variants]"); exit(2)
}
let pid = target.processIdentifier
wakeElectron(pid: pid)
usleep(300_000)

if args[1] == "probe" { print(describe(pid: pid)); exit(0) }

let reps = args.count > 3 ? Int(args[3]) ?? 5 : 5
let chosen = args.count > 4 ? args[4].split(separator: ",").map(String.init) : Array(variants.keys).sorted()
let previous = NSWorkspace.shared.frontmostApplication
let refocusMs: Int? = ProcessInfo.processInfo.environment["REFOCUS_MS"].flatMap { Int($0) }
let bounce = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.finder").first

target.activate()
usleep(700_000)
print("# target:", describe(pid: pid))
guard readText(pid: pid) != nil else { print("focused element is not readable — aborting"); exit(1) }
clearField(pid: pid)

var tally: [String: (n: Int, exact: Int, lost: Int)] = [:]
for rep in 0..<reps {
for name in chosen {
guard let v = variants[name] else { continue }
guard refocusMs != nil || NSWorkspace.shared.frontmostApplication?.processIdentifier == pid else {
print("# focus left the target — stopping"); exit(1)
}
let text = texts[rep % texts.count]
if let ms = refocusMs, let other = bounce {
other.activate(); usleep(400_000)
target.activate(); usleep(useconds_t(ms * 1000))
}
if let hold = ProcessInfo.processInfo.environment["HOLD_MS"].flatMap({ Double($0) }) {
// A keyDown with no keyUp, like every TTP chunk: macOS now believes
// the key is held. Press-and-hold kicks in after its delay.
let d = CGEvent(keyboardEventSource: source, virtualKey: 0, keyDown: true)!
d.flags = []; let u: [UniChar] = Array("x".utf16)
d.keyboardSetUnicodeString(stringLength: 1, unicodeString: u); d.post(tap: .cghidEventTap)
sleepMs(hold)
}
let before = readText(pid: pid) ?? ""
let t0 = Date()
if v.chunk == 0 { paste(text) } else { type(text, v) }
let injectMs = Int(Date().timeIntervalSince(t0) * 1000)
let (after, waitMs) = settledText(pid: pid)
let a = Array(after ?? ""), b = Array(before)
var pre = 0; while pre < a.count && pre < b.count && a[pre] == b[pre] { pre += 1 }
var suf = 0; while suf < a.count - pre && suf < b.count - pre && a[a.count-1-suf] == b[b.count-1-suf] { suf += 1 }
let landed = String(a[pre..<(a.count - suf)])
let exact = landed == text
var t = tally[name] ?? (0, 0, 0)
t.n += 1; if exact { t.exact += 1 } else { t.lost += text.count - landed.count }
tally[name] = t
let rec: [String: Any] = ["rep": rep, "variant": name, "expected": text.count, "landed": landed.count,
"exact": exact, "inject_ms": injectMs, "settle_ms": waitMs,
"got": exact ? "" : landed]
let data = try! JSONSerialization.data(withJSONObject: rec, options: [.sortedKeys])
print(String(data: data, encoding: .utf8)!)
clearField(pid: pid)
if let left = readText(pid: pid), left.count > 40 {
print("# field did not clear (\(left.count) chars left) — stopping"); previous?.activate(); exit(1)
}
}
}

print("\n# variant runs exact chars_lost")
for name in chosen { if let t = tally[name] { print(String(format: " %-12@ %4d %5d %10d", name as NSString, t.n, t.exact, t.lost)) } }
previous?.activate()
19 changes: 12 additions & 7 deletions scripts/trace_analyser/invariants.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
# `pasted` need names of their own, and both are documented.
DOCUMENTED_OUTCOMES = {
"pasted", "aborted", "clipboard_fallback",
"pasted_unverified", "paste_swallowed",
"pasted_unverified", "paste_swallowed", "paste_partial",
}

# The `PasteVerdict` vocabulary being added to `paste.verify` alongside this
Expand All @@ -65,6 +65,7 @@
PASTE_VERDICT_OBSERVED = "observed" # the text was seen to land
PASTE_VERDICT_SWALLOWED = "swallowed" # it was readable, and it did not
PASTE_VERDICT_UNVERIFIED = "unverified" # the target could not be read at all
PASTE_VERDICT_PARTIAL = "partial" # scraps landed, the repair failed

# `paste.verify`'s other two new fields, named for the same reason. `verdict`
# is the answer, `evidence` is what it was read off ("text" or "length") and
Expand Down Expand Up @@ -1699,13 +1700,17 @@ def check_paste_swallowed(corpus: Corpus):
verdict = v.get("verdict")
if verdict is not None:
if verdict not in (PASTE_VERDICT_SWALLOWED,
PASTE_VERDICT_UNVERIFIED):
PASTE_VERDICT_UNVERIFIED,
PASTE_VERDICT_PARTIAL):
continue # observed, or vocabulary this file does not know
sev = (ERROR if verdict == PASTE_VERDICT_SWALLOWED else WARN)
gloss = ("target unchanged after injection"
if verdict == PASTE_VERDICT_SWALLOWED else
"the target could not be read, so the landing is "
"unproven — this is not evidence the text was lost")
sev = (WARN if verdict == PASTE_VERDICT_UNVERIFIED else ERROR)
gloss = {
PASTE_VERDICT_SWALLOWED: "target unchanged after injection",
PASTE_VERDICT_PARTIAL: "only part of it landed and the "
"repair could not fix it (see "
"paste.repair)",
}.get(verdict, "the target could not be read, so the landing is "
"unproven — this is not evidence the text was lost")
elif v.get("ax_readable") is True and v.get("changed") is False:
# The pre-verdict shape. Same claim, weaker instrument.
sev, gloss = ERROR, "target unchanged"
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "ttp"
version = "3.2.3"
version = "3.2.4"
description = "Talk To Paste - Voice to text transcription"
authors = ["TTP Team"]
edition = "2021"
Expand Down
7 changes: 7 additions & 0 deletions src-tauri/src/fnkey.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ const KCG_EVENT_TAP_DISABLED_BY_TIMEOUT: u32 = 0xFFFF_FFFE;
/// kCGEventTapDisabledByUserInput — same consequence, different trigger.
const KCG_EVENT_TAP_DISABLED_BY_USER_INPUT: u32 = 0xFFFF_FFFF;
const KCG_KEYBOARD_EVENT_KEYCODE: u32 = 9;
/// `kCGEventSourceUnixProcessID`: 0 for hardware, the poster's pid otherwise.
const KCG_EVENT_SOURCE_UNIX_PROCESS_ID: u32 = 41;
const KCG_KEYBOARD_EVENT_AUTOREPEAT: u32 = 8;
const KCG_MOUSE_EVENT_BUTTON_NUMBER: u32 = 3;
const KCG_SESSION_EVENT_TAP: u32 = 1;
Expand Down Expand Up @@ -911,6 +913,11 @@ unsafe extern "C" fn fkey_tap_callback(
let raw = raw_event(event_type, event);
let keycode = raw.keycode;

if event_type == KCG_EVENT_KEY_DOWN {
let pid = CGEventGetIntegerValueField(event, KCG_EVENT_SOURCE_UNIX_PROCESS_ID);
crate::paste::input_marks::note_key_down(pid, keycode);
}

// Choosing a new trigger: the press belongs to the capture, and must not
// also start a recording with the old one.
if CAPTURING.load(Ordering::Relaxed) {
Expand Down
Loading
Loading