updates
ober
cd1da5704687a8a9948157588b381266063dc561
--- a/Makefile +++ b/Makefile @@ -1,12 +1,10 @@ # jerbuild is the self-contained build tool: it bundles Chez Scheme + the # jerboa stdlib under ~/.cache/jerbuild/<sha>/, so the host dev loop and the # native `binary` need NO mutable dependency checkout and NO separately-built Chez. -JERBOA_VERSION ?= v0.2.3 +JERBOA_VERSION ?= v0.2.8 JERBOA_TOOL_DIR ?= $(CURDIR)/.jerboa/bin JERBUILD ?= $(shell if [ -x ./jerbuild ]; then echo ./jerbuild; \ elif [ -x "$(JERBOA_TOOL_DIR)/jerbuild" ]; then echo "$(JERBOA_TOOL_DIR)/jerbuild"; \ - elif [ -x ../jerboa/dist/jerbuild ]; then echo ../jerboa/dist/jerbuild; \ - elif [ -x ../jerboa/jerbuild ]; then echo ../jerboa/jerbuild; \ elif command -v jerbuild >/dev/null 2>&1; then command -v jerbuild; \ else echo "$(JERBOA_TOOL_DIR)/jerbuild"; fi) @@ -104,10 +102,6 @@ ensure-jerboa-tools: echo "=== Using project-local ./jerbuild ==="; \ elif [ -x "$(JERBOA_TOOL_DIR)/jerbuild" ]; then \ echo "=== Using downloaded Jerboa toolchain: $(JERBOA_TOOL_DIR) ==="; \ - elif [ -x ../jerboa/dist/jerbuild ]; then \ - echo "=== Using sibling Jerboa build: ../jerboa/dist/jerbuild ==="; \ - elif [ -x ../jerboa/jerbuild ]; then \ - echo "=== Using sibling Jerboa build: ../jerboa/jerbuild ==="; \ elif command -v jerbuild >/dev/null 2>&1; then \ echo "=== Using Jerboa toolchain from PATH: $$(command -v jerbuild) ==="; \ else \ @@ -391,7 +385,7 @@ install: binary # CROSS-compile from this host to another platform using the host Chez # ($(SCHEME)) + a cross-built Chez kernel in $(JERBOA_HOME) + a musl/clang # cross toolchain. These vars are used ONLY by the cross targets. -JERBOA_HOME ?= $(HOME)/mine/jerboa +JERBOA_HOME ?= $(JH) SCHEME ?= $(JERBOA_HOME)/.chez/bin/scheme FREEBSD_AMD64_CC ?= $(JERBOA_HOME)/support/cross-cc-freebsd-amd64 XC_LIBDIRS = ./lib:$(JSQLITE_LIBDIR):$(WEBSEARCH_OVERLAY)/src:vendor/jerboa-websearch/src:$(JH)/lib:$(JERBOA_HOME)/lib --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # jerboa-code -**A portable AI coding agent written in [Jerboa](https://git.sr.ht/~lisp/jerboa) Scheme.** +**A portable AI coding agent written in [Jerboa](https://git.jerboa.sh/ober/jerboa) Scheme.** `jcode` is a terminal coding agent — think opencode / aider / Claude Code — that compiles to a standalone binary without a Node or Python runtime. It talks to 14 @@ -50,7 +50,7 @@ release bundle into `.jerboa/bin`. See **[docs/getting-started.md](docs/getting-started.md)** for the full setup. ```bash -git clone https://git.sr.ht/~lisp/jerboa-code && cd jerboa-code +git clone https://git.jerboa.sh/ober/jerboa-code && cd jerboa-code make build # compile src/ → lib/ make binary # produce the standalone ./jcode --- a/android/app/src/main/java/dev/jerboa/jcode/JcodeClient.kt +++ b/android/app/src/main/java/dev/jerboa/jcode/JcodeClient.kt @@ -10,16 +10,16 @@ import java.io.BufferedReader import java.io.BufferedWriter import java.io.InputStreamReader import java.io.OutputStreamWriter +import java.net.InetSocketAddress import java.net.Socket import java.util.concurrent.LinkedBlockingQueue import java.util.concurrent.atomic.AtomicBoolean /** - * TCP socket client for connecting to `jcode serve --port PORT` in Termux. + * TCP socket client for connecting to `jcode serve --port PORT`. * * Replaces JcodeProcess (which spawned an embedded binary). The jcode server - * runs in Termux with full environment access; this client connects over - * localhost and communicates via the same JSONL protocol. + * runs on the configured host and communicates via the JSONL protocol. * * Auth handshake: on connect, sends {"type":"auth","token":"<token>"} and * waits for {"type":"auth_ok"}. On failure, reports to listener and closes. @@ -67,6 +67,7 @@ class JcodeClient(private val context: Context) { private val main = Handler(Looper.getMainLooper()) private val outbound = LinkedBlockingQueue<String>() private val connected = AtomicBoolean(false) + private val connecting = AtomicBoolean(false) private val shouldReconnect = AtomicBoolean(false) private var socket: Socket? = null @@ -84,7 +85,7 @@ class JcodeClient(private val context: Context) { private fun prefs(): SharedPreferences = context.getSharedPreferences("jcode_settings", Context.MODE_PRIVATE) - fun getHost(): String = prefs().getString("host", "127.0.0.1") ?: "127.0.0.1" + fun getHost(): String = prefs().getString("host", DEFAULT_HOST) ?: DEFAULT_HOST fun getPort(): Int = prefs().getInt("port", 8321) fun getToken(): String = prefs().getString("token", "") ?: "" @@ -93,82 +94,94 @@ class JcodeClient(private val context: Context) { * thread. Calls listener on the main thread. */ fun connect() { - disconnect() + if (connected.get() || !connecting.compareAndSet(false, true)) { + Log.i(TAG, "connect ignored; already connected or connecting") + return + } shouldReconnect.set(true) + outbound.clear() val host = getHost() val port = getPort() val token = getToken() if (token.isEmpty()) { + connecting.set(false) main.post { listener?.onAuthFailed("No auth token configured. Open Settings and paste the token from Termux.") } return } readerThread = Thread { + var lastFailure: Exception? = null try { - Log.i(TAG, "connecting to $host:$port") - val s = Socket(host, port) - socket = s - connected.set(true) - - val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8)) - val writer = BufferedWriter(OutputStreamWriter(s.getOutputStream(), Charsets.UTF_8)) - - // Send auth handshake - val authMsg = JSONObject() - .put("type", "auth") - .put("token", token) - writer.write(authMsg.toString() + "\n") - writer.flush() + try { + Log.i(TAG, "connecting to $host:$port") + val s = Socket() + socket = s + s.connect(InetSocketAddress(host, port), CONNECT_TIMEOUT_MS) + s.soTimeout = AUTH_TIMEOUT_MS + + val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8)) + val writer = BufferedWriter(OutputStreamWriter(s.getOutputStream(), Charsets.UTF_8)) + + val authMsg = JSONObject() + .put("type", "auth") + .put("token", token) + writer.write(authMsg.toString() + "\n") + writer.flush() + + val responseLine = reader.readLine() + ?: throw IllegalStateException("server closed connection during auth") + + val response = JSONObject(responseLine) + val type = response.optString("type", "") + + if (type == "error") { + val msg = response.optString("message", "auth failed") + s.close() + connecting.set(false) + main.post { listener?.onAuthFailed(msg) } + return@Thread + } - // Wait for auth response - val responseLine = reader.readLine() - if (responseLine == null) { - connected.set(false) - main.post { listener?.onDisconnected("Server closed connection during auth") } - s.close() - return@Thread - } + if (type != "auth_ok") { + s.close() + connecting.set(false) + main.post { listener?.onAuthFailed("Unexpected response: $type") } + return@Thread + } - val response = JSONObject(responseLine) - val type = response.optString("type", "") + s.soTimeout = 0 + connected.set(true) + connecting.set(false) + Log.i(TAG, "authenticated to $host:$port") + main.post { listener?.onConnected() } - if (type == "error") { - connected.set(false) - val msg = response.optString("message", "auth failed") - main.post { listener?.onAuthFailed(msg) } - s.close() - return@Thread - } + writerThread = Thread { writerLoop(writer) }.apply { + name = "jcode-writer" + isDaemon = true + start() + } - if (type != "auth_ok") { - connected.set(false) - main.post { listener?.onAuthFailed("Unexpected response: $type") } - s.close() + readerLoop(reader) return@Thread + } catch (e: Exception) { + Log.w(TAG, "connect failed to $host:$port", e) + lastFailure = e + connected.set(false) + socket?.let { + try { it.close() } catch (_: Exception) {} + } + socket = null } - // Auth succeeded - Log.i(TAG, "authenticated to $host:$port") - main.post { listener?.onConnected() } - - // Start writer thread - writerThread = Thread { writerLoop(writer) }.apply { - name = "jcode-writer" - isDaemon = true - start() - } - - // Reader loop runs on this thread - readerLoop(reader) - } catch (e: Exception) { - Log.w(TAG, "connect failed", e) - connected.set(false) - main.post { listener?.onDisconnected("Connection failed: ${e.message}") } - scheduleReconnect() + lastFailure = e } + connecting.set(false) + connected.set(false) + main.post { listener?.onDisconnected("Connection failed: ${lastFailure?.message ?: "unknown error"}") } + scheduleReconnect() }.apply { name = "jcode-connect" isDaemon = true @@ -182,6 +195,7 @@ class JcodeClient(private val context: Context) { fun disconnect() { shouldReconnect.set(false) connected.set(false) + connecting.set(false) outbound.clear() reconnectThread?.interrupt() reconnectThread = null @@ -418,5 +432,8 @@ class JcodeClient(private val context: Context) { companion object { private const val TAG = "JcodeClient" + private const val DEFAULT_HOST = "10.66.60.2" + private const val CONNECT_TIMEOUT_MS = 10_000 + private const val AUTH_TIMEOUT_MS = 15_000 } } --- a/android/app/src/main/java/dev/jerboa/jcode/MainActivity.kt +++ b/android/app/src/main/java/dev/jerboa/jcode/MainActivity.kt @@ -14,8 +14,8 @@ import android.widget.Button import android.widget.EditText import android.widget.ImageButton import android.widget.LinearLayout +import android.widget.PopupMenu import android.widget.ScrollView -import android.widget.Switch import android.widget.TextView import android.widget.Toast import android.app.AlertDialog @@ -35,10 +35,7 @@ class MainActivity : Activity(), JcodeClient.Listener { private lateinit var titleText: TextView private lateinit var cwdText: TextView - private lateinit var modeLabel: TextView - private lateinit var modeSwitch: Switch - private lateinit var btnNewSession: ImageButton - private lateinit var btnSettings: ImageButton + private lateinit var btnMenu: ImageButton private lateinit var chatScroll: ScrollView private lateinit var chatContainer: LinearLayout private lateinit var toolsScroll: ScrollView @@ -73,6 +70,7 @@ class MainActivity : Activity(), JcodeClient.Listener { private var sending = false private var activePage = Page.CHAT + private var buildMode = true /** Count of tool events since last tab switch — shown as badge on Tools tab. */ private var toolCount = 0 @@ -86,17 +84,24 @@ class MainActivity : Activity(), JcodeClient.Listener { // ── Scroll throttle: at most one scroll per 150ms ─────────────────── private var scrollPending = false + private var scrollTarget = Page.CHAT private val scrollRunnable = Runnable { scrollPending = false - val sv = when (activePage) { + val sv = when (scrollTarget) { Page.CHAT -> chatScroll Page.TOOLS -> toolsScroll Page.MCP -> mcpScroll } - sv.fullScroll(View.FOCUS_DOWN) + sv.post { + val child = sv.getChildAt(0) ?: return@post + val viewportHeight = sv.height - sv.paddingTop - sv.paddingBottom + val bottom = (child.height - viewportHeight).coerceAtLeast(0) + sv.scrollTo(0, bottom) + } } - private fun throttledScroll() { + private fun throttledScroll(page: Page = activePage) { + scrollTarget = page if (!scrollPending) { scrollPending = true handler.postDelayed(scrollRunnable, 150) @@ -109,7 +114,7 @@ class MainActivity : Activity(), JcodeClient.Listener { tokenDirty = false val tv = currentAssistantText ?: return@Runnable tv.text = currentAssistantBuf.toString() - throttledScroll() + throttledScroll(Page.CHAT) } override fun onCreate(savedInstanceState: Bundle?) { @@ -118,10 +123,7 @@ class MainActivity : Activity(), JcodeClient.Listener { titleText = findViewById(R.id.title_text) cwdText = findViewById(R.id.cwd_text) - modeLabel = findViewById(R.id.mode_label) - modeSwitch = findViewById(R.id.mode_switch) - btnNewSession = findViewById(R.id.btn_new_session) - btnSettings = findViewById(R.id.btn_settings) + btnMenu = findViewById(R.id.btn_menu) chatScroll = findViewById(R.id.chat_scroll) chatContainer = findViewById(R.id.chat_container) toolsScroll = findViewById(R.id.tools_scroll) @@ -144,32 +146,12 @@ class MainActivity : Activity(), JcodeClient.Listener { infoConnections = findViewById(R.id.info_connections) infoUsage = findViewById(R.id.info_usage) - modeSwitch.setOnCheckedChangeListener { _, checked -> updateModeLabel(checked) } - updateModeLabel(modeSwitch.isChecked) - // Tab switching tabChat.setOnClickListener { switchTab(Page.CHAT) } tabTools.setOnClickListener { switchTab(Page.TOOLS) } tabMcp.setOnClickListener { switchTab(Page.MCP) } - btnSettings.setOnClickListener { - startActivity(Intent(this, SettingsActivity::class.java)) - } - - btnNewSession.setOnClickListener { - if (!client.isConnected()) return@setOnClickListener - chatContainer.removeAllViews() - toolsContainer.removeAllViews() - mcpContainer.removeAllViews() - toolCount = 0 - mcpCount = 0 - updateTabLabels() - currentAssistantText = null - currentAssistantMeta = null - currentAssistantBuf.setLength(0) - client.sendNewSession() - setStatus(getString(R.string.status_ready)) - } + btnMenu.setOnClickListener { showMainMenu() } btnProvider.setOnClickListener { showProviderPicker() } btnModel.setOnClickListener { showModelPicker() } @@ -258,15 +240,59 @@ class MainActivity : Activity(), JcodeClient.Listener { // ── UI helpers ────────────────────────────────────────────────────── - private fun updateModeLabel(build: Boolean) { - modeLabel.setText(if (build) R.string.mode_build else R.string.mode_plan) - val color = resources.getColor( - if (build) R.color.mode_build else R.color.mode_plan, theme - ) - modeLabel.setTextColor(color) + private fun currentMode(): String = if (buildMode) "build" else "plan" + + private fun resetSession() { + if (!client.isConnected()) return + chatContainer.removeAllViews() + toolsContainer.removeAllViews() + mcpContainer.removeAllViews() + toolCount = 0 + mcpCount = 0 + updateTabLabels() + currentAssistantText = null + currentAssistantMeta = null + currentAssistantBuf.setLength(0) + client.sendNewSession() + switchTab(Page.CHAT) + setStatus(getString(R.string.status_ready)) + } + + private fun showMainMenu() { + val popup = PopupMenu(this, btnMenu) + val menu = popup.menu + menu.add(0, MENU_CHAT, 0, "Chat") + menu.add(0, MENU_TOOLS, 1, if (toolCount > 0) "Tools ($toolCount)" else "Tools") + menu.add(0, MENU_MCP, 2, if (mcpCount > 0) "MCP ($mcpCount)" else "MCP") + menu.add(0, MENU_PROVIDER, 3, + if (currentProvider.isNotEmpty()) "Provider: $currentProvider" else "Provider") + menu.add(0, MENU_MODEL, 4, + if (currentModel.isNotEmpty()) "Model: ${shortMenuValue(currentModel)}" else "Model") + menu.add(0, MENU_MODE, 5, + if (buildMode) "Mode: Build" else "Mode: Plan") + menu.add(0, MENU_NEW_SESSION, 6, "New session") + menu.add(0, MENU_SETTINGS, 7, "Settings") + popup.setOnMenuItemClickListener { item -> + when (item.itemId) { + MENU_CHAT -> switchTab(Page.CHAT) + MENU_TOOLS -> switchTab(Page.TOOLS) + MENU_MCP -> switchTab(Page.MCP) + MENU_PROVIDER -> showProviderPicker() + MENU_MODEL -> showModelPicker() + MENU_MODE -> { + buildMode = !buildMode + setStatus(if (buildMode) "Mode: Build" else "Mode: Plan") + } + MENU_NEW_SESSION -> resetSession() + MENU_SETTINGS -> startActivity(Intent(this, SettingsActivity::class.java)) + } + true + } + popup.show() } - private fun currentMode(): String = if (modeSwitch.isChecked) "build" else "plan" + private fun shortMenuValue(value: String): String = + if (value.length > 28) value.take(28) + "\u2026" else value private fun setStatus(text: String) { statusLine.text = text @@ -308,7 +334,7 @@ class MainActivity : Activity(), JcodeClient.Listener { val view = layoutInflater.inflate(R.layout.item_chat_user, chatContainer, false) view.findViewById<TextView>(R.id.message_text).text = text chatContainer.addView(view) - throttledScroll() + throttledScroll(Page.CHAT) } private fun beginAssistant() { @@ -321,7 +347,7 @@ class MainActivity : Activity(), JcodeClient.Listener { currentAssistantText = tv currentAssistantMeta = meta currentAssistantBuf.setLength(0) - throttledScroll() + throttledScroll(Page.CHAT) } private fun appendAssistantToken(token: String) { @@ -342,7 +368,7 @@ class MainActivity : Activity(), JcodeClient.Listener { tokenDirty = false val tv = currentAssistantText ?: return tv.text = currentAssistantBuf.toString() - throttledScroll() + throttledScroll(Page.CHAT) } } @@ -368,7 +394,7 @@ class MainActivity : Activity(), JcodeClient.Listener { updateTabLabels() // Auto-scroll tools pane if it's visible if (activePage == Page.TOOLS) { - throttledScroll() + throttledScroll(Page.TOOLS) } } @@ -403,7 +429,7 @@ class MainActivity : Activity(), JcodeClient.Listener { mcpCount++ updateTabLabels() if (activePage == Page.MCP) { - throttledScroll() + throttledScroll(Page.MCP) } } @@ -411,7 +437,7 @@ class MainActivity : Activity(), JcodeClient.Listener { val view = layoutInflater.inflate(R.layout.item_chat_error, chatContainer, false) view.findViewById<TextView>(R.id.error_text).text = msg chatContainer.addView(view) - throttledScroll() + throttledScroll(Page.CHAT) } // ── provider/model pickers ──────────────────────────────────────── @@ -501,8 +527,8 @@ class MainActivity : Activity(), JcodeClient.Listener { override fun onConnected() { hideBanner() setInputEnabled(true) - providerRow.visibility = View.VISIBLE - infoRow.visibility = View.VISIBLE + providerRow.visibility = View.GONE + infoRow.visibility = View.GONE setStatus("Connected") } @@ -535,7 +561,7 @@ class MainActivity : Activity(), JcodeClient.Listener { view.findViewById<TextView>(R.id.message_text).text = content view.findViewById<TextView>(R.id.reply_meta).visibility = View.GONE chatContainer.addView(view) - throttledScroll() + throttledScroll(Page.CHAT) } override fun onHistoryTool(name: String, argsJson: String) { @@ -543,7 +569,7 @@ class MainActivity : Activity(), JcodeClient.Listener { } override fun onHistoryEnd() { - throttledScroll() + throttledScroll(Page.CHAT) } override fun onToken(text: String) { @@ -629,7 +655,7 @@ class MainActivity : Activity(), JcodeClient.Listener { } override fun onMcpCallsEnd() { - if (activePage == Page.MCP) throttledScroll() + if (activePage == Page.MCP) throttledScroll(Page.MCP) } private fun formatCount(n: Long): String = when { @@ -676,5 +702,13 @@ class MainActivity : Activity(), JcodeClient.Listener { companion object { private const val TAG = "jcode.Main" + private const val MENU_CHAT = 1 + private const val MENU_TOOLS = 2 + private const val MENU_MCP = 3 + private const val MENU_PROVIDER = 4 + private const val MENU_MODEL = 5 + private const val MENU_MODE = 6 + private const val MENU_NEW_SESSION = 7 + private const val MENU_SETTINGS = 8 } } --- a/android/app/src/main/res/layout/activity_main.xml +++ b/android/app/src/main/res/layout/activity_main.xml @@ -5,15 +5,15 @@ android:orientation="vertical" android:background="@color/bg_dark"> - <!-- Header: title, mode switch, new session, settings --> + <!-- Compact header: title, cwd, overflow menu --> <LinearLayout android:layout_width="match_parent" - android:layout_height="56dp" + android:layout_height="44dp" android:background="@color/bg_card" android:gravity="center_vertical" android:orientation="horizontal" - android:paddingStart="16dp" - android:paddingEnd="8dp"> + android:paddingStart="12dp" + android:paddingEnd="4dp"> <TextView android:id="@+id/title_text" @@ -22,7 +22,7 @@ android:layout_weight="1" android:text="@string/app_name" android:textColor="@color/text_primary" - android:textSize="18sp" + android:textSize="16sp" android:textStyle="bold" /> <TextView @@ -39,38 +39,13 @@ android:textColor="@color/text_muted" android:textSize="11sp" /> - <TextView - android:id="@+id/mode_label" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginEnd="4dp" - android:text="@string/mode_build" - android:textColor="@color/mode_build" - android:textSize="12sp" - android:textStyle="bold" /> - - <Switch - android:id="@+id/mode_switch" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginEnd="4dp" - android:checked="true" /> - - <ImageButton - android:id="@+id/btn_new_session" - android:layout_width="40dp" - android:layout_height="40dp" - android:background="?android:attr/selectableItemBackgroundBorderless" - android:contentDescription="@string/new_session" - android:src="@android:drawable/ic_menu_add" /> - <ImageButton - android:id="@+id/btn_settings" - android:layout_width="40dp" - android:layout_height="40dp" + android:id="@+id/btn_menu" + android:layout_width="44dp" + android:layout_height="44dp" android:background="?android:attr/selectableItemBackgroundBorderless" - android:contentDescription="@string/settings" - android:src="@android:drawable/ic_menu_preferences" /> + android:contentDescription="@string/menu" + android:src="@android:drawable/ic_menu_more" /> </LinearLayout> <!-- Connection banner (shown when disconnected) --> @@ -182,12 +157,14 @@ android:textSize="10sp" /> </LinearLayout> - <!-- Tab bar: Chat / Tools / MCP --> + <!-- Hidden tab controls; switching is exposed through the header menu. --> <LinearLayout + android:id="@+id/tab_bar" android:layout_width="match_parent" android:layout_height="40dp" android:background="@color/bg_card" - android:orientation="horizontal"> + android:orientation="horizontal" + android:visibility="gone"> <TextView android:id="@+id/tab_chat" --- a/android/app/src/main/res/layout/item_chat_assistant.xml +++ b/android/app/src/main/res/layout/item_chat_assistant.xml @@ -3,8 +3,8 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" - android:paddingStart="12dp" - android:paddingEnd="48dp" + android:paddingStart="8dp" + android:paddingEnd="8dp" android:paddingTop="4dp" android:paddingBottom="4dp"> @@ -14,10 +14,10 @@ android:layout_height="wrap_content" android:background="@color/bubble_assistant" android:fontFamily="monospace" - android:padding="12dp" + android:padding="10dp" android:textColor="@color/text_primary" android:textIsSelectable="true" - android:textSize="13sp" /> + android:textSize="15sp" /> <TextView android:id="@+id/reply_meta" --- a/android/app/src/main/res/layout/item_chat_user.xml +++ b/android/app/src/main/res/layout/item_chat_user.xml @@ -3,8 +3,8 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" - android:paddingStart="48dp" - android:paddingEnd="12dp" + android:paddingStart="32dp" + android:paddingEnd="8dp" android:paddingTop="4dp" android:paddingBottom="4dp"> @@ -14,7 +14,7 @@ android:layout_height="wrap_content" android:layout_gravity="end" android:background="@color/bubble_user" - android:padding="12dp" + android:padding="10dp" android:textColor="@color/white" - android:textSize="14sp" /> + android:textSize="15sp" /> </LinearLayout> --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -9,6 +9,7 @@ <string name="mode_build">Build</string> <string name="hint_message">Message jcode\u2026</string> <string name="new_session">New session</string> + <string name="menu">Menu</string> <string name="error">Error</string> <string name="status_ready">Ready</string> <string name="status_thinking">Thinking\u2026</string> --- a/docs/README.md +++ b/docs/README.md @@ -58,6 +58,9 @@ elevator pitch. - **[Architecture](architecture.md)** — the agent loop, the module map, and how a single turn flows from prompt to tool calls to final answer. +- **[Prompt caching](prompt-caching.md)** — handoff for implementing server-side + prefix caching with local ds4: verification evidence, the harness's existing + cache plumbing, gap analysis, and the phased implementation plan. ## Contributing --- a/docs/cli.md +++ b/docs/cli.md @@ -178,7 +178,9 @@ Available in the REPL and TUI. | `/tool off <name>` | Disable a tool for the current session. Hidden from the model and rejected at execute time. | | `/tool on <name>` | Re-enable a previously disabled tool. | | `/agents` | List named sub-agent roles for the `task` tool (built-ins + `jcode.json` variants). | -| `/mcp` | Toggle MCP tools on/off. | +| `/mcp` | In the TUI, open an MCP server/tool popup; use arrows to move and Space or Enter to toggle the selected row. In the line-mode REPL, show MCP status and usage. | +| `/mcp status` | List active MCP servers, enabled state, and tool counts. | +| `/mcp on\|off [name]` | Enable or disable all MCP servers, or one named MCP server. | | `/plugins` | List loaded plugins. | | `/skills` | List built-in and user skills. | | `/<skill-name> [args]` | Run a skill (built-in or a user `SKILL.md`). | --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -8,9 +8,8 @@ prerequisites, building on each platform, configuration, and your first session. `jcode` builds with the Jerboa toolchain. The build needs three things: 1. **A Jerboa toolchain.** The Makefile checks, in order: - project-local `./jerbuild` + `./jerboa`, `.jerboa/bin`, a sibling - `../jerboa/dist` build, `PATH`, then downloads the configured SourceHut - release artifact into `.jerboa/bin`. + project-local `./jerbuild`, `.jerboa/bin`, `PATH`, then downloads the + configured `git.jerboa.sh` release artifact into `.jerboa/bin`. ```bash make ensure-jerboa-tools @@ -46,9 +45,9 @@ commit and verified by Git tree ID from `vendor-lock.env`. | Dependency | Source | |---|---| -| `jsqlite` | `git.sr.ht/~lisp/jerboa-sqlite` — pure Scheme SQLite-compatible session storage | +| `jsqlite` | `https://git.jerboa.sh/ober/jerboa-sqlite` — pure Scheme SQLite-compatible session storage | | `termbox2` | `github.com/termbox/termbox2` — the TUI's terminal backend | -| `jerboa-websearch` | `git.sr.ht/~lisp/jerboa-websearch` — Websearch libraries, with the bounded worker overlay under `vendor-overrides/` | +| `jerboa-websearch` | `https://git.jerboa.sh/ober/jerboa-websearch` — Websearch libraries, with the bounded worker overlay under `vendor-overrides/` | `make build` runs `make vendor-deps` for you. To verify already populated sources without changing them: @@ -80,9 +79,59 @@ make linux-arm64 # on Linux arm64/aarch64 → jcode-linux-arm64 make freebsd # on FreeBSD amd64 → jcode-freebsd-amd64 ``` -These targets are native host builds through `jerbuild`; they do not need a -Jerboa source checkout or a separate Chez install. `make linux` aliases -`make linux-amd64`. +These targets create named platform outputs through `jerbuild`; they do not +need a separate Chez install. `make linux` aliases `make linux-amd64`. + +### FreeBSD amd64: install Jerboa, then build jcode + +Install the authenticated `freebsd-amd64` Jerboa artifact first by following +[Jerboa's FreeBSD artifact instructions](https://git.jerboa.sh/ober/jerboa/src/branch/main/docs/release-artifacts.md#freebsd-amd64). +The installed multicall binary provides both `jerboa` and `jerbuild`. + +On FreeBSD, use GNU Make as `gmake`: + +```sh +pkg install git gmake rust curl ncurses libiconv +export PATH="$HOME/.local/bin:$PATH" + +git clone https://git.jerboa.sh/ober/jerboa-code +cd jerboa-code +gmake ensure-jerboa-tools +gmake build +gmake test +gmake binary +./jcode --version +gmake install +``` + +`gmake ensure-jerboa-tools` uses the Jerboa commands on `PATH`; if they are not +installed globally, it downloads the pinned `git.jerboa.sh` artifact into +`.jerboa/bin`. To use a particular installed toolchain, pass its path +explicitly, for example +`gmake JERBUILD="$HOME/.local/bin/jerbuild" binary`. + +`gmake binary` is the native FreeBSD build and produces `./jcode`. The +`gmake freebsd` target is for cross-compiling `jcode-freebsd-amd64` with an +already configured FreeBSD sysroot; it is not needed when building on FreeBSD. + +### FreeBSD amd64: install a prebuilt jcode binary + +If you already have an authenticated `jcode-freebsd-amd64` binary, Jerboa is +not required just to run it. Install it under the normal command name: + +```sh +mkdir -p "$HOME/.local/bin" +install -m 0755 ./jcode-freebsd-amd64 "$HOME/.local/bin/jcode" +export PATH="$HOME/.local/bin:$PATH" +jcode --version +``` + +Authenticate the binary against the publisher's signed checksum or release +manifest before executing it. The FreeBSD binary is dynamically linked to +FreeBSD system libraries; use `ldd "$HOME/.local/bin/jcode"` to identify any +missing runtime package. A binary copied from a local `gmake binary` build may +also need the generated native/TUI shared libraries beside it; `gmake install` +copies those files and is the preferred installation route for local builds. ### Android new file mode 100644 --- /dev/null +++ b/docs/prompt-caching.md @@ -0,0 +1,449 @@ +# Prompt Caching with Local ds4 — Implementation Handoff + +**Status:** handoff / not yet implemented +**Verified:** 2026-07-27, against live `ds4` provider (`http://10.66.60.4:8000/v1`, model `deepseek-v4-pro`) +**Audience:** whoever implements efficient prompt caching for local ds4 (and other local OpenAI-compatible endpoints) + +--- + +## 1. TL;DR verdict + +**Does the harness do prompt caching with local ds4 today? Effectively NO.** + +- The **client side (jcode, this repo) is already wired**: it sends a + `prompt_cache_key` on every request to the `ds4` provider, and it parses + `usage.prompt_tokens_details.cached_tokens` / `cache_write_tokens` into the + usage accounting and TUI. Nothing more is required of a well-behaved client. +- The **server side is broken/absent**: live probes show the ds4 server + **never returns a single cached token** — `cached_tokens` is `0` on every + request, even for byte-identical 2807-token prompts repeated back-to-back + with a stable `prompt_cache_key`. `cache_write_tokens` always equals + `prompt_tokens` exactly, i.e. the server writes cache-accounting fields but + never hits (stub accounting, or prefix caching disabled/unimplemented). + +So: the harness "does" prompt caching in the sense of asking for it and +measuring it; **no cache hit has ever been observed**, and the TUI's +`cache N%` indicator has never had a reason to appear for ds4. + +The fix is primarily **server-side** (Phase 0), plus a set of **harness-side +hardening changes** so that (a) a cold cache is loudly detected instead of +silent, and (b) jcode's request construction maximizes prefix reuse once the +server cooperates (Phases 1–4). + +--- + +## 2. What "prompt caching" means here + +Server-side **prefix/KV caching**: the inference server keeps the KV blocks of +previously processed prompt prefixes and reuses them when the next request +shares a long enough identical prefix. An agent loop is the best-case workload +for this: + +``` +turn 1: [system][tools][user] → prefill everything +turn 2: [system][tools][user][asst][tool-result] → prefill only the suffix +turn 3: [...same prefix...][more] → prefill only the suffix +``` + +Each turn reuses the entire prior conversation's KV; only the new tail pays +prefill cost. On local hardware prefill dominates turn latency, so with a +30k-token session the difference is roughly "30k tokens of prefill per turn" +vs "~1–2k per turn". This is the single biggest latency lever available for +local ds4 agent sessions. + +Explicitly **not** in scope: response caching, semantic caches, the +deterministic eval harness in `eval/` (scripted, no live model), Anthropic's +explicit `cache_control` breakpoints (already implemented for the anthropic +provider; see §4.4), Gemini `cachedContent` (already implemented). + +--- + +## 3. Verification evidence (2026-07-27) + +All probes against the **live configured** ds4 endpoint from +`~/.jcode/config.json` (`providers.ds4`, `wire: "openai"`, +`base_url: http://10.66.60.4:8000/v1`). + +### 3.1 Server identity + +`GET /v1/models` returns `deepseek-v4-flash` and `deepseek-v4-pro`, +`owned_by: "ds4.c"`, `context_length: 100000`, `supported_parameters: +[tools, tool_choice, max_tokens, temperature, top_p, top_k, min_p, stop, +seed, stream, reasoning_effort]`. No `/version`, `/metrics`, `/health`, +`/stats` endpoints (all 404). + +### 3.2 Cache probes + +| # | Prompt | Hint sent | `cached_tokens` | `cache_write_tokens` | +|---|--------|-----------|-----------------|----------------------| +| 1 | 6 tokens | `prompt_cache_key: jcode-test-123` | 0 | 6 | +| 2 | 6 tokens, repeat of #1 | same key | 0 | 6 | +| 3 | 6 tokens, repeat | none | 0 | 6 | +| 4 | 6 tokens, repeat | different key | 0 | 6 | +| 5 | 2807 tokens | `prompt_cache_key: jcode-bigcache-test-1` | 0 | 2807 | +| 6 | 2807 tokens, exact repeat ×2 | same key | 0 / 0 | 2807 / 2807 | +| 7 | 2807 tokens ×2 | `cache_salt: jcode-salt-1` (vLLM-style) | 0 / 0 | 2807 / 2807 | +| 8 | 2807 tokens | both hints | 0 | 2807 | + +**Findings:** + +1. The server **accepts** `prompt_cache_key` and `cache_salt` without a 4xx + (they are ignored, not rejected). +2. It **reports** `prompt_tokens_details.{cached_tokens, cache_write_tokens}` + — the OpenAI/vLLM telemetry shape jcode already parses. +3. It **never hits**: `cached_tokens` is always `0`, and + `cache_write_tokens == prompt_tokens` on every request. A healthy + prefix-caching server shows `cached_tokens ≈ prompt_tokens` from the second + identical request onward. +4. Conclusion: prefix caching is disabled, unimplemented, or stubbed in the + `ds4.c` server build. `cache_write_tokens` as emitted today is cosmetic. + +### 3.3 Harness-side confirmation + +- `ds4` resolves to provider-kind `"openai"` via `wire: "openai"` + (`src/jcode/core/models.ss:107-118` — confirmed by the existing test at + `test/run.ss:12519`). +- `prompt-cache-key-provider?` allows kinds `openai`, `openrouter`, `mlx` + (`src/jcode/provider/openai.ss:770-771`), so **`prompt_cache_key` is sent + on every ds4 request already** (`apply-prompt-cache-controls!`, + `openai.ss:781-787`, called from `openai-request-body`). +- Usage parsing already extracts both cache fields + (`openai-cached-tokens`, `openai.ss` / `provider.ss:1798-1823`) → + `cache-read` / `cache-creation` → `usage.ss` snapshot → TUI status bar + (`tui-status.ss:35-41`). + +No harness change would have made the probes above hit; the miss is in the +server. + +--- + +## 4. Architecture map — what exists today + +### 4.1 Request path (OpenAI-wire providers, incl. ds4) + +``` +agent loop (src/jcode/core/agent.ss) + └─ refresh-system-prompt agent.ss:375 rebuilds system msg each turn + └─ provider-stream-chat-with-stats provider.ss + └─ openai-request-body provider.ss:1731 / openai.ss + ├─ apply-prompt-cache-controls! openai.ss:781 adds prompt_cache_key + ├─ apply-provider-options! openai.ss:803 config passthrough + └─ messages = [system, ...history], tools = "tools" field +``` + +### 4.2 Cache controls sent + +| Field | When | Code | +|---|---|---| +| `prompt_cache_key: "jcode-<real-time>"` | provider kind ∈ {openai, openrouter, mlx} | `openai.ss:768,781-784` | +| `prompt_cache_retention: "24h"` | provider name = `openai` **and** model starts `gpt-5`/`gpt-4.1` | `openai.ss:773-787` | + +The key is **per-process**: `(real-time)` is evaluated once at module load. +Within one jcode process it is stable; a new process (including a resumed +session) gets a new key. See gap **G3**. + +### 4.3 Telemetry path + +``` +usage.prompt_tokens_details.cached_tokens → openai-cached-tokens → 'cache-read +usage.prompt_tokens_details.cache_write_* → 'cache-creation + → usage.ss accumulate (cache_read_tokens / cache_creation_tokens snapshot) + → TUI status bar "cache N%" (hidden while cache-read = 0) tui-status.ss:35-41 + → streaming accumulation provider.ss:2736, 2886 +``` + +The telemetry chain is complete and works for any OpenAI-compatible server +that reports the fields honestly (vLLM does; the current ds4 server reports +the fields but always zero). + +### 4.4 Reference pattern: Anthropic explicit breakpoints + +`provider.ss:1887-1970` shows the *explicit* style: `cache_control` +breakpoints on the last tool block and the last two messages, with a comment +documenting the prefix-order invariant ("Tools render first in the cache +prefix and change rarely"). The OpenAI-wire style relies on the server's +automatic prefix matching instead — no breakpoints needed — but the same +invariant applies: **the bytes at the front of the request must be stable.** + +### 4.5 Prefix-stability properties today (mostly good, mostly accidental) + +| Component | Stable within process? | Stable across processes? | Notes | +|---|---|---|---| +| System prompt text | ✅ (rebuilt per turn but deterministic — no timestamps) | ✅ | `system-prompt`, `agent.ss:267-313` — keep it that way; see G6 | +| History | ✅ append-only | n/a | except compaction, which rewrites the prefix (G7) | +| Tool schema list | ✅ (cached, `registry.ss:192-228`) | ❌ order from `hash-keys` | `list-tools`, `registry.ss:230-232` (G4) | +| `prompt_cache_key` | ✅ | ❌ new per process | G3 | + +--- + +## 5. Gap analysis + +### Server gaps (the actual blocker) + +- **G1 — ds4 server does not prefix-cache.** Evidence in §3.2. Whether it is a + launch flag, a cache-size-0 config, or simply unimplemented in the `ds4.c` + server, nothing the client sends changes it. **This is the critical path.** +- **G2 — no server introspection.** No `/metrics`, no cache stats endpoint. + After enabling caching we need *some* way to confirm hit rates server-side + (even if only the per-request `cached_tokens` fields). + +### Harness gaps (latent — each one silently wastes the cache once G1 is fixed) + +- **G3 — per-process `prompt_cache_key`.** If the server namespaces its cache + by this key (OpenAI routing semantics; vLLM `cache_salt` semantics), then + every jcode restart / session resume is a guaranteed cold cache. The key + should be stable per *session* and persist across restarts.