Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
9b6e44a
core: action grammar and observation format
claude Sep 7, 2026
eedfb90
core: policy pack, enforcer, conformance suite, and the platform ports
claude Sep 7, 2026
5187ad7
app: Android side, framework APIs only
claude Sep 7, 2026
da9d749
core: default ASSISTANT.md planner instructions
claude Sep 7, 2026
5e21704
docs: decision log and README
claude Sep 7, 2026
89fd501
loop: TaskRunner with step budget, loop detector, approvals, stale ch…
claude Sep 7, 2026
29e0383
Merge branch 'build/loop' into claude/tst-assist-android-taiz9d
claude Sep 7, 2026
943ef47
core: redactor, config presets and endpoint kinds, cost meter with sp…
claude Sep 7, 2026
94be599
build: treat a blank ANDROID_HOME as absent; declare kotlin.android a…
claude Sep 7, 2026
d31c4ff
Merge branch 'build/redact-meter-config' into claude/tst-assist-andro…
claude Sep 7, 2026
4d9d00d
core: injection suite over the task loop
claude Sep 7, 2026
cfc18d4
app: wire the task controller; ship ASSISTANT.md in the core jar
claude Sep 7, 2026
4d75458
build: put AGP on the root classpath when an SDK is present
claude Sep 7, 2026
9d48cf2
core/audit: desktop schema v1-v2 verbatim, phone v3 with actions, app…
claude Sep 7, 2026
f2e0fcc
core: net (bind rules, auth token, handshake gate, endpoints, provide…
claude Sep 7, 2026
cf671de
build: enable AndroidX (OkHttp's Android artifact needs it)
claude Sep 7, 2026
dd0b023
Merge branch 'build/audit' into claude/tst-assist-android-taiz9d
claude Sep 7, 2026
f48eb91
Merge branch 'build/net-secrets-steering' into claude/tst-assist-andr…
claude Sep 7, 2026
f3abbee
app: cloud planner, audit listener, planner selection, full task wiring
claude Sep 7, 2026
75bb248
build: depend on okhttp-jvm, not the Android variant
claude Sep 7, 2026
6dc5302
app: fix the keyguard attribute name in the voice-interaction metadata
claude Sep 7, 2026
b2ceb78
core: wire-protocol client model of tstd (event gate, client messages…
claude Sep 7, 2026
4e4a49a
Merge branch 'build/wire' into claude/tst-assist-android-taiz9d
claude Sep 7, 2026
5a72109
wire: sockets come from Endpoints, not a raw OkHttp factory
claude Sep 7, 2026
99df565
docs: reality check and review closeout (TM-010–013)
WagiklinKasa Sep 7, 2026
c67b211
core: TM-014 M1 review closeout
WagiklinKasa Sep 7, 2026
d81f8d2
app: first-smoke copy on the settings screen
WagiklinKasa Sep 7, 2026
8c22046
app: launcher icon so TST Assist is findable after sideload
WagiklinKasa Sep 7, 2026
461c9cc
app: settings names the model and the endpoint (TM-015)
WagiklinKasa Sep 8, 2026
d901007
core: closed verbs and policy for non-tree actions (TM-016)
WagiklinKasa Sep 8, 2026
b7f1f73
core: intent specs for phone, partner, device, and media (TM-021)
WagiklinKasa Sep 8, 2026
7b5f02f
core: spend cap pauses the loop (TM-017)
WagiklinKasa Sep 8, 2026
f1961d3
core: planner-emitted goal apps (TM-019)
WagiklinKasa Sep 8, 2026
ca4cbcf
core: end-state validator on done (TM-018)
WagiklinKasa Sep 8, 2026
63f08fb
core: live meter chip hydrates day spend from audit (TM-025)
WagiklinKasa Sep 8, 2026
1c42eb6
core: family-mode host blocklist and settings cap/codec (TM-020)
WagiklinKasa Sep 8, 2026
add514e
app: intent, device, partner, and media executors (TM-022, TM-024)
WagiklinKasa Sep 8, 2026
c82bdec
core: model profiles and three-brain offline suite
WagiklinKasa Sep 8, 2026
7f77dc2
docs: TM-023 app socket scan and M2 closeout
WagiklinKasa Sep 8, 2026
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
57 changes: 53 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,69 @@ TST Assist: an open-source, bring-your-own-model assistant for Android, built on
No account, no server, no subscription, no telemetry, the key never touches disk in plaintext,
and the agent cannot rewrite its own rules.

The plan and architecture live in [`docs/tst-assist-android.md`](docs/tst-assist-android.md).
The plan is [`docs/tst-assist-android.md`](docs/tst-assist-android.md). What the code actually
does today, checked against TST Desk's source, is [`docs/reality-check.md`](docs/reality-check.md).
Decisions are in [`docs/decisions.md`](docs/decisions.md).

**Status:** pre-alpha, private use. The pure-JVM core is built and tested. The Android app
is written against framework APIs and type-checked. Head of work is `grok/m2` (M2 code:
intents, meter chip, spend-cap pause, family host block, end-state validator, model-profile
suite). Pixel 7 Pro verify is not done. The three-brain suite in CI is offline fixtures;
live calls are opt-in and have not been claimed as run.

## How it drives the phone

1. The accessibility service flattens the screen into numbered controls (hints) with stable
identities, and renders them as a data block the model reads.
2. The model answers with exactly one line of a closed grammar: `tap 7`, `type 3 "on my way"`,
`scroll 4 down`, `done "..."`. Nothing else executes. Hints not on screen are refused.
3. The policy pack decides the tier: silent, once per task, every time, or refused. Every
rule is data in `policy/policy.yaml`, and `policy/cases.yaml` pins every case.
4. Tier 2 actions (send, call, pay, delete, install, share, settings, notification reply,
leaving the goal's apps) show a card on the phone with the target highlighted, plus a
notification with Approve and Deny. No answer is a deny.
5. The kill switch is a Quick Settings tile and a Stop action on the task notification.

## Layout

| Path | What | Builds where |
|---|---|---|
| `core/` | Pure-JVM Kotlin: action grammar, observation format, hint table, policy enforcer, meter, audit schema, redactor, wire clients. No Android imports. | Any JDK 17+: `./gradlew :core:test` |
| `app/` | The Android side: accessibility observer and executor, overlay approval card, notification actions, kill switch, assistant role. | Needs the Android SDK: `./gradlew :app:assembleDebug` |
| `core/` | Pure-JVM Kotlin: action grammar, observation format, hint table, policy enforcer, task loop, meter, audit schema, redactor, network rules, tstd wire client. No Android imports. | Any JDK 17+: `./gradlew :core:test` |
| `app/` | The Android side: accessibility observer and executor, overlay approval card, notification actions, kill switch tile, assistant role, Keystore secret store, SQLite audit. | Needs the Android SDK: `./gradlew :app:assembleDebug` |
| `policy/` | The policy pack and its conformance cases. Data, refused to the agent. | |
| `tools/androidcheck/` | Type-checks `app/` sources against a framework jar on machines with no SDK. | `TST_ANDROID_CHECK=1 ./gradlew :tools:androidcheck:compileKotlin` |
| `docs/` | The plan, and the reality check of it against the TST Desk code. | |
| `docs/` | Plan, reality check, decisions. | |

`:app` is only part of the build when an Android SDK is found (`ANDROID_HOME`, `ANDROID_SDK_ROOT`,
or `sdk.dir` in `local.properties`). Without one, `:core` still builds and tests.

## The promises, and what proves them today

| Promise | Where it is enforced | Test |
|---|---|---|
| No account | There is no sign-up path; the only credential is a provider key or a tstd token. | onboarding review |
| No server | Nothing of ours is in the loop. Bind rules refuse `0.0.0.0` and accept loopback or Tailscale only. | `core/net` tests |
| No subscription | Prices live in `config.yaml`; Device mode is priced at zero and the meter says zero. | `core/meter` tests |
| No telemetry | Every outbound connection goes through one `Endpoints` object that refuses hosts config did not name. | `core/net` tests, source scan |
| Key never touches disk | Android Keystore wraps the key; only ciphertext is stored. One redactor for logs and audit rows. | `core/redact` tests |
| The agent cannot rewrite its own rules | `ASSISTANT.md`, `CHARTER.md`, `profiles/`, `policy.yaml` are refused before any approval. | `core/steering` tests |
| Screen and notification text is data | Labels are one bounded, escaped token inside the `<<OBS` block; only the grammar executes. | `ObservationTest`, `ActionParserTest`, loop tests |
| The driven phone shows the approval | Overlay card with the highlighted target plus a notification with actions, on the phone. | approval round trip in loop tests; on-device check pending |
| Audit is append-only | SQLite triggers abort UPDATE and DELETE on every audited table. | `core/audit` tests |

## Building

```
./gradlew :core:test # pure JVM, no SDK needed (includes offline suite)
TST_ANDROID_CHECK=1 ./gradlew :tools:androidcheck:compileKotlin # type-check app sources without an SDK
./gradlew :app:assembleDebug # needs the Android SDK

# Live three-brain suite: not CI, spends keys, still through Endpoints.
# Default brains: OpenRouter moonshotai/kimi-k3, OpenRouter z-ai/glm-5.2,
# local loopback if present else "third brain not configured".
OPENROUTER_API_KEY=… ./gradlew :core:test --tests com.thatsimpletech.assist.core.profile.ModelSuiteTest -Dassist.liveSuite=1
```

## License

Apache 2.0. See `LICENSE` and `NOTICE`.
3 changes: 2 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
// actions, kill switch tile, assistant role. Framework APIs only for now (no androidx), so
// tools/androidcheck can type-check these sources against the framework jar without an SDK.
plugins {
alias(libs.plugins.android.application)
// AGP comes from the root buildscript classpath (see the root build.gradle.kts), so no version here.
id("com.android.application")
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.serialization)
}
Expand Down
123 changes: 123 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<!-- Network: only the hosts named in config are ever contacted (core Endpoints). -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- The task runner and the kill switch live in a foreground service. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Wake lock only while a task runs. -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- M2 device / intent grants. Dial-only and SMS-draft: no CALL_PHONE, no SEND_SMS.
Wi-Fi / Bluetooth: visible QS or Settings panel; no CHANGE_WIFI_STATE / BLUETOOTH_*. -->
<uses-permission android:name="android.permission.SET_ALARM" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY" />
<uses-permission android:name="android.permission.WRITE_SETTINGS" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.WRITE_CONTACTS" />

<application
android:name=".AssistApp"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:allowBackup="false"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@android:style/Theme.DeviceDefault.DayNight">

<activity
android:name=".ui.MainActivity"
android:exported="true"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>

<!-- Observer and executor. We are not an accessibility tool and do not claim to be (P7). -->
<service
android:name=".a11y.AssistAccessibilityService"
android:exported="false"
android:label="@string/a11y_label"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/accessibility_service_config" />
</service>

<!-- Notification read and inline reply, policy-gated (plan §2). -->
<service
android:name=".notif.AssistNotificationListener"
android:exported="false"
android:label="@string/notif_label"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>

<!-- Kill switch in Quick Settings (plan §4). -->
<service
android:name=".kill.KillSwitchTile"
android:exported="true"
android:icon="@android:drawable/ic_lock_power_off"
android:label="@string/kill_tile_label"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>

<!-- Default digital assistant role: long-press power, corner swipe, screen context (plan §2). -->
<service
android:name=".assistant.AssistInteractionService"
android:exported="true"
android:label="@string/app_name"
android:permission="android.permission.BIND_VOICE_INTERACTION">
<intent-filter>
<action android:name="android.service.voice.VoiceInteractionService" />
</intent-filter>
<meta-data
android:name="android.voice_interaction"
android:resource="@xml/interaction_service" />
</service>
<service
android:name=".assistant.AssistSessionService"
android:exported="true"
android:permission="android.permission.BIND_VOICE_INTERACTION" />
<service
android:name=".assistant.AssistRecognitionService"
android:exported="true"
android:label="@string/app_name"
android:permission="android.permission.BIND_VOICE_INTERACTION">
<intent-filter>
<action android:name="android.speech.RecognitionService" />
</intent-filter>
</service>

<!-- Runs a task; hosts the persistent notification with the kill action. -->
<service
android:name=".task.TaskForegroundService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Runs a user-started assistant task and shows its approval and stop controls" />
</service>

<receiver
android:name=".approval.ApprovalReceiver"
android:exported="false" />
</application>
</manifest>
130 changes: 130 additions & 0 deletions app/src/main/kotlin/com/thatsimpletech/assist/AssistApp.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package com.thatsimpletech.assist

import android.app.Application
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import com.thatsimpletech.assist.a11y.AssistAccessibilityService
import com.thatsimpletech.assist.approval.AndroidApprovalSurface
import com.thatsimpletech.assist.approval.ApprovalNotifier
import com.thatsimpletech.assist.approval.OverlayCard
import com.thatsimpletech.assist.audit.AndroidSqlExecutor
import com.thatsimpletech.assist.core.audit.AuditStore
import com.thatsimpletech.assist.config.ProviderSettings
import com.thatsimpletech.assist.core.config.AssistConfig
import com.thatsimpletech.assist.core.config.TierName
import com.thatsimpletech.assist.core.grammar.HintCodec
import com.thatsimpletech.assist.core.net.Endpoints
import com.thatsimpletech.assist.core.net.ProviderPolicy
import com.thatsimpletech.assist.core.policy.PolicyPack
import com.thatsimpletech.assist.core.profile.ModelProfiles
import com.thatsimpletech.assist.core.redact.Redactor
import com.thatsimpletech.assist.core.secrets.SecretStore
import com.thatsimpletech.assist.core.steering.DefaultInstructions
import com.thatsimpletech.assist.core.steering.InstructionStack
import com.thatsimpletech.assist.core.steering.RulesBoundary
import com.thatsimpletech.assist.secrets.KeystoreSecretStore
import java.io.File
import java.util.UUID

class AssistApp : Application() {
override fun onCreate() {
super.onCreate()
Graph.init(this)
}
}

/**
* The app's object graph, by hand. Everything here is either core (pure) or a thin Android
* adapter over it. No DI framework, no reflection, nothing to audit twice.
*/
object Graph {
lateinit var app: Application
private set

/** Epoch seconds, the audit store's clock. */
val clock: () -> Double = { System.currentTimeMillis() / 1000.0 }

val pack: PolicyPack by lazy { PolicyPack.loadDefault() }

/**
* Active config: shipped YAML plus the settings-screen overlay (model, base URL, mode,
* spend cap). Reloaded when the person taps Save provider, so a running task keeps the
* old client.
*/
val config: AssistConfig get() = _config
val endpoints: Endpoints get() = _endpoints
val provider: ProviderSettings get() = ProviderSettings.load(app)

@Volatile private lateinit var _config: AssistConfig
@Volatile private lateinit var _endpoints: Endpoints

val secrets: SecretStore by lazy { KeystoreSecretStore(app) }
val notifier: ApprovalNotifier by lazy { ApprovalNotifier(app) }
val approvals: AndroidApprovalSurface by lazy {
AndroidApprovalSurface(
notifier = notifier,
overlay = { AssistAccessibilityService.instance?.let { OverlayCard(it) } },
timeoutSeconds = pack.approvalTimeoutSeconds,
)
}

val audit: AuditStore by lazy {
val file = File(app.filesDir, "audit.db")
val store = AuditStore(AndroidSqlExecutor(SQLiteDatabase.openOrCreateDatabase(file, null)), clock, Redactor::text)
store.migrate()
store
}

/** Per-endpoint suite stats. Not a rules file; the agent has no verb that writes it. */
val profilesFile: File
get() = File(app.filesDir, ModelProfiles.RELATIVE_PATH).also { it.parentFile?.mkdirs() }

/**
* Agent-unwritable rules dir: ASSISTANT.md, CHARTER.md, profiles/, policy.yaml (plan §6).
* Seeded from the core jar on first run; the boundary refuses any agent write into it.
*/
val rulesDir: File by lazy {
val dir = File(app.filesDir, "rules").apply { mkdirs() }
val assistant = File(dir, InstructionStack.DEVICE_FILE)
if (!assistant.exists()) assistant.writeText(DefaultInstructions.load())
val profiles = File(dir, InstructionStack.PROFILES_DIR).apply { mkdirs() }
val letters = File(profiles, "${InstructionStack.LETTERS_PROFILE}.md")
if (!letters.exists()) letters.writeText(DefaultInstructions.lettersProfile())
dir
}
val rulesBoundary: RulesBoundary by lazy { RulesBoundary(rulesDir.toPath()) }

/**
* Device instructions, plus the letters profile when that codec is on. [codec] is
* captured at run start so a settings change cannot switch mid-task.
*/
fun instructions(codec: HintCodec = provider.codec()): String {
val profile = InstructionStack.LETTERS_PROFILE.takeIf { codec is HintCodec.Letters }
val layers = InstructionStack.resolve(rulesDir.toPath(), profile, "")
return InstructionStack.deviceAndProfile(layers, DefaultInstructions.load())
}

/** A random id minted once; it only ever appears in this phone's own audit rows. */
val deviceId: String by lazy {
val prefs = app.getSharedPreferences("device", Context.MODE_PRIVATE)
prefs.getString("id", null) ?: UUID.randomUUID().toString().also { prefs.edit().putString("id", it).apply() }
}

/** Provider credential account for Cloud-key mode. */
const val CREDENTIAL_ID = "openrouter"

fun init(application: Application) {
app = application
reloadProvider()
}

/** Rebuilds config and the outbound host gate from what is on the settings screen. */
fun reloadProvider() {
val settings = provider
val next = settings.toConfig(AssistConfig.loadDefault())
_config = next
val brain = next.tier(TierName.BRAIN).baseUrl
val hosts = if (ProviderPolicy.hostAllowed(brain, settings.familyMode)) next.allowedHosts else emptySet()
_endpoints = Endpoints(hosts)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.thatsimpletech.assist.a11y

import android.accessibilityservice.AccessibilityService
import android.view.accessibility.AccessibilityEvent

/**
* The observer/executor host. It keeps the last activity name (from window-state events)
* and hands itself to the app graph; the task runner lives in the foreground service.
*/
class AssistAccessibilityService : AccessibilityService() {
@Volatile
var lastActivity: String = ""
private set

lateinit var walker: TreeWalker
private set

override fun onServiceConnected() {
walker = TreeWalker(this)
instance = this
}

override fun onAccessibilityEvent(event: AccessibilityEvent?) {
if (event?.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) {
val cls = event.className?.toString()
if (!cls.isNullOrEmpty()) lastActivity = cls.substringAfterLast('.')
}
}

override fun onInterrupt() {}

override fun onDestroy() {
if (instance === this) instance = null
super.onDestroy()
}

companion object {
/** Null when the accessibility service is off: the app then runs in no-accessibility mode (P6). */
@Volatile
var instance: AssistAccessibilityService? = null
private set
}
}
Loading
Loading