<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Manoj Shetty</title>
    <description>The latest articles on DEV Community by Manoj Shetty (@manoj_shetty).</description>
    <link>https://dev.to/manoj_shetty</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3913708%2Ff4a6e22b-1dea-4250-9abf-42859d132a7c.jpg</url>
      <title>DEV Community: Manoj Shetty</title>
      <link>https://dev.to/manoj_shetty</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/manoj_shetty"/>
    <language>en</language>
    <item>
      <title>My coding agent kept searching the same repo again and again, so I gave it a memory</title>
      <dc:creator>Manoj Shetty</dc:creator>
      <pubDate>Sun, 18 Oct 2026 11:11:26 +0000</pubDate>
      <link>https://dev.to/manoj_shetty/my-coding-agent-kept-searching-the-same-repo-again-and-again-so-i-gave-it-a-memory-481g</link>
      <guid>https://dev.to/manoj_shetty/my-coding-agent-kept-searching-the-same-repo-again-and-again-so-i-gave-it-a-memory-481g</guid>
      <description>&lt;p&gt;I use Claude Code every day. And one thing kept bugging me.&lt;/p&gt;

&lt;p&gt;I gave it a small bug in a CLI library. Before it touched anything, it made 32 tool calls. grep, sed, cat, grep again. That one run used 1,052,132 input tokens. For a tiny fix.&lt;/p&gt;

&lt;p&gt;So I went and counted my own sessions. 83 sessions, 32,713 tool calls. 31% of them were the agent just searching. The median was 24 searches before the first edit. And 44% of the time it opened a file, it had already read that file in the same session.&lt;/p&gt;

&lt;p&gt;The next day, similar question, same repo. It did the same search again.&lt;/p&gt;

&lt;p&gt;That's what I built Engram for, in the Graph Hacks hackathon (WeMakeDevs × FalkorDB).&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/jwso6cm9fqY" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;Engram turns a repo into a graph in FalkorDB. Three kinds of things go in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the code: files, functions, who calls whom, imports&lt;/li&gt;
&lt;li&gt;the git history: commits, authors, and which files usually change together&lt;/li&gt;
&lt;li&gt;the agents themselves: what they read, what they edited, what worked, and which file they're editing right now
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;:File&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:DEFINES&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;:Symbol&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:CALLS&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;:Symbol&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;:File&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:IMPORTS&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;:File&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;:Person&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:AUTHORED&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;:Commit&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:TOUCHED&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;:File&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;:File&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:CO_CHANGES&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;weight&lt;/span&gt;&lt;span class="ss"&gt;}]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;:File&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;:Agent&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:RAN&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;:Session&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:READ&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;EDITED&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;:File&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;:Session&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:LEARNED&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;:Lesson&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:ABOUT&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;:File&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="nc"&gt;:Symbol&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;:Session&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:CLAIMS&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="n"&gt;since&lt;/span&gt;&lt;span class="ss"&gt;}]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;:File&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then, before the agent makes its first tool call, it gets a short map. Around 200 tokens. Just where to look.&lt;/p&gt;

&lt;p&gt;This is the real map it gave Claude Code for one click bug ("&lt;code&gt;click.Choice(["Apple", "Banana"], case_sensitive=False)&lt;/code&gt; rejects &lt;code&gt;apple&lt;/code&gt;"):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;engram&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Likely&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;locations&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;this&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;task&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;(code&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;graph,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;confidence&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;high):&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;src/click/types.py:&lt;/span&gt;&lt;span class="mi"&gt;406-424&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Choice.normalize_choice&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;src/click/exceptions.py:&lt;/span&gt;&lt;span class="mi"&gt;51-52&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;ClickException.format_message&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;src/click/types.py:&lt;/span&gt;&lt;span class="mi"&gt;381-386&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Choice.to_info_dict&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;src/click/types.py:&lt;/span&gt;&lt;span class="mi"&gt;495-515&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Choice.shell_complete&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Tests:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;tests/test_types/test_Choice.py&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without Engram, the agent did grep, grep, sed, then the edit. 4 tool calls, 112,298 tokens.&lt;br&gt;
With Engram: read the file, edit, done. 2 tool calls, 75,733 tokens. Tests passed in both.&lt;/p&gt;
&lt;h2&gt;
  
  
  Things that went wrong on the way
&lt;/h2&gt;

&lt;p&gt;Honestly this part taught me more than the parts that worked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My first benchmark was fake.&lt;/strong&gt; The agent fixed every bug super fast. Too fast. Then I read one of its answers and it said it "reverted the uncommitted edit". It had just run &lt;code&gt;git diff&lt;/code&gt; and seen the bug I injected. So every run now gets a fresh copy with a single commit, the agent only sees a bug report like a user would write it, and a fix only counts if the whole test suite passes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;More context made it worse.&lt;/strong&gt; My first version gave the agent 1,600 tokens of code and history up front. Tokens went up, not down. Whatever you inject gets re-read on every turn after that, so in a 6-turn session that's around 10k tokens. I cut it down to a map of 6 lines. Claude Code has to Read a file before editing it anyway, so pasting code in never saved a single call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bugs hide where names don't match.&lt;/strong&gt; At first I matched words from the bug report to function names. That found the buggy function in the top 3 only 5 times out of 12. Bugs sit in big functions and private helpers. In networkx, the report says &lt;code&gt;dijkstra_path_length&lt;/code&gt; but the bug is in &lt;code&gt;_dijkstra_multisource&lt;/code&gt;, three calls down. That's not something grep finds. It's a graph walk:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="k"&gt;MATCH&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;s:&lt;/span&gt;&lt;span class="n"&gt;Symbol&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;id:&lt;/span&gt; &lt;span class="n"&gt;$api&lt;/span&gt;&lt;span class="ss"&gt;})&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:CALLS&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="m"&gt;4&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;n:&lt;/span&gt;&lt;span class="n"&gt;Symbol&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;all&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="ow"&gt;IN&lt;/span&gt; &lt;span class="nf"&gt;nodes&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="ss"&gt;)[&lt;/span&gt;&lt;span class="mf"&gt;1.&lt;/span&gt;&lt;span class="n"&gt;.size&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;nodes&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="ss"&gt;))&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="nf"&gt;size&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x.callNames&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="n"&gt;n.qualname&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;length&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="ss"&gt;))&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;hops&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After that, on networkx the buggy function was in the top 10 for all 6 bugs. No LLM involved.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Memory made it worse too, at first.&lt;/strong&gt; I stored "the test command that worked" after each task. But I stored the exact command ("pytest tests/test_types.py"), not the general one. So the next task ran the wrong tests, they passed, and the agent got confused. Now it stores the general command and the graph picks the right tests for each edit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The graph doubled itself.&lt;/strong&gt; On a bigger repo I saw the same function listed twice in a map. Two indexers had run at the same time and both inserted everything. I reproduced it: 19,508 symbols instead of 9,754. The fix ended up being one Cypher write, because FalkorDB runs writes on a graph one at a time, so only one process can win:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="k"&gt;MERGE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;l:&lt;/span&gt;&lt;span class="n"&gt;IndexLock&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;k&lt;/span&gt;&lt;span class="dl"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="ss"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;l.owner&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="ow"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;l.at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;$stale&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;free&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;l.owner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;free&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="n"&gt;$me&lt;/span&gt; &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="n"&gt;l.owner&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;l.at&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;free&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="n"&gt;$now&lt;/span&gt; &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="n"&gt;l.at&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt;
&lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="n"&gt;l.owner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;$me&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;got&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;p&gt;I ran Claude Code (claude-haiku-5-5) on 12 real bugs I injected into pallets/click, 3 times each, in 4 setups. 144 runs.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setup&lt;/th&gt;
&lt;th&gt;Fixed&lt;/th&gt;
&lt;th&gt;Input tokens&lt;/th&gt;
&lt;th&gt;Tool calls&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude Code&lt;/td&gt;
&lt;td&gt;36/36&lt;/td&gt;
&lt;td&gt;137.6k&lt;/td&gt;
&lt;td&gt;5.1&lt;/td&gt;
&lt;td&gt;16.4 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;+ codebase-memory-mcp (47k★)&lt;/td&gt;
&lt;td&gt;36/36&lt;/td&gt;
&lt;td&gt;126.9k&lt;/td&gt;
&lt;td&gt;4.7&lt;/td&gt;
&lt;td&gt;13.4 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;+ Engram&lt;/td&gt;
&lt;td&gt;35/36&lt;/td&gt;
&lt;td&gt;103.5k (−25%)&lt;/td&gt;
&lt;td&gt;3.6 (−29%)&lt;/td&gt;
&lt;td&gt;12.7 s (−23%)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On networkx (128k lines, 6 bugs): −24% tokens, −40% tool calls, −36% time.&lt;/p&gt;

&lt;p&gt;What actually goes away is the searching. Search calls per bug went from 3.4 to 1.7.&lt;/p&gt;

&lt;p&gt;And yes, it missed one fix out of 36. That was my fault. The map showed a 5-line wrapper function and only mentioned the real function as "the next call", so the agent fixed the wrapper. I changed it so the real function gets its own line. I didn't re-run the whole benchmark after that, so I'm keeping 35/36.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then I tried it on a messy real project
&lt;/h2&gt;

&lt;p&gt;Injected bugs are one thing. I wanted to see it on code I never touched, with bugs real people reported.&lt;/p&gt;

&lt;p&gt;yt-dlp: 1,128 Python files, 248k lines, years of community patches. I indexed it as of 30 April (took 4 seconds) and took every fix after that which closes a GitHub issue. Engram only gets the issue the user wrote, and I check it against what the maintainers actually changed.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;30 real issues: the right file was in the top 3 for 22 of them. No LLM calls at all.&lt;/li&gt;
&lt;li&gt;I ran Claude Code live on 4 of those issues, with and without Engram. Both found the right function all 4 times. With Engram it used 31% fewer tokens and 33% fewer tool calls.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One small thing I learned here: in a GitHub issue the title matters most. A long description full of words like "download" and "video" was drowning out the one useful word in the title. Now title words count double.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two agents, one file
&lt;/h2&gt;

&lt;p&gt;If two agents try to edit the same file, the second one gets stopped. This is the actual message Engram's hook sent back when a second agent session tried to edit a yt-dlp file the first one had claimed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Engram: yt_dlp/extractor/bandcamp.py is being edited by claude-code-agentC right now.
Work on something else or wait; do not edit this file.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same trick as the index lock, one Cypher write.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why FalkorDB
&lt;/h2&gt;

&lt;p&gt;I needed the graph to answer before every single prompt, without the agent waiting. Multi-hop walks and PageRank come back in milliseconds (my queries take 1 to 40 ms). One query after each edit joins git history, what this session edited, and the call graph, in about 11 ms. Locks between agents are just writes. One graph per repo, many repos in one instance. And FalkorDBLite, their embedded build, means people don't need Docker at all. It uses 43 to 60 MB of RAM with two repos loaded.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://raw.githubusercontent.com/ManojRakshu/engram/main/install.sh | sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No Docker, no API keys, and Engram itself never calls an LLM. It connects to Claude Code, Cursor, Codex, Gemini CLI, VS Code and any MCP client it finds.&lt;/p&gt;

&lt;p&gt;Code, the benchmark harness and all the raw results are here: &lt;a href="https://github.com/ManojRakshu/engram" rel="noopener noreferrer"&gt;github.com/ManojRakshu/engram&lt;/a&gt;. There's also a &lt;a href="https://youtube.com/shorts/aSS2mSwJzmI" rel="noopener noreferrer"&gt;30-second version of the video&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I'm curious: what does your agent waste the most calls on? Searching, re-reading files, running the wrong tests? Tell me, that's what I want to measure next.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I used Claude Code as a coding assistant while building this.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>falkordb</category>
      <category>opensource</category>
      <category>devtools</category>
    </item>
    <item>
      <title>5 things `flutter_gemma` doesn't tell you about shipping Gemma 4 on Android</title>
      <dc:creator>Manoj Shetty</dc:creator>
      <pubDate>Sun, 24 May 2026 04:59:41 +0000</pubDate>
      <link>https://dev.to/manoj_shetty/5-things-fluttergemma-doesnt-tell-you-about-shipping-gemma-4-on-android-2koj</link>
      <guid>https://dev.to/manoj_shetty/5-things-fluttergemma-doesnt-tell-you-about-shipping-gemma-4-on-android-2koj</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/google-gemma-2026-05-06"&gt;Gemma 4 Challenge: Write About Gemma 4&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I shipped a Gemma 4 assistant on Android in 17 days. Voice, vision, RAG, eight device actions, everything offline once the model is on device. The project is called PocketClaw, and you can read about it &lt;a href="https://dev.to/manoj_shetty/how-i-built-a-fully-offline-ai-assistant-on-android-with-gemma-4-e2b-5a19"&gt;here&lt;/a&gt; if that's what you came for.&lt;/p&gt;

&lt;p&gt;This post is about the 5 things I had to figure out the hard way. Not in the &lt;code&gt;flutter_gemma&lt;/code&gt; README. Not in Google's MediaPipe docs. Not in any of the half-dozen "run Gemma on Android" tutorials I read this week.&lt;/p&gt;

&lt;p&gt;If you're about to ship Gemma 4 on Android, I hope this saves you a weekend.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;📱 &lt;strong&gt;Companion post:&lt;/strong&gt; &lt;a href="https://dev.to/manoj_shetty/how-i-built-a-fully-offline-ai-assistant-on-android-with-gemma-4-e2b-5a19"&gt;How I built PocketClaw — a fully offline AI assistant on Android with Gemma 4 E2B&lt;/a&gt;. Demo video, architecture deep-dive, full source code.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  1. Small models drop facts buried mid-prompt. Put what matters at the top.
&lt;/h2&gt;

&lt;p&gt;I've been building agents on Claude and GPT-4 for about 18 months. Both of them handle a long system prompt fine. You can mix instructions and facts in any order, and the model figures out what's a fact versus what's a behavior rule.&lt;/p&gt;

&lt;p&gt;Gemma 4 E2B doesn't.&lt;/p&gt;

&lt;p&gt;My first system prompt for PocketClaw looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You are Claw. You run locally and offline. You are talking to Manoj Shetty.
Match your answer length to the question. Prefer plain answers over preambles.
Never restate the question. If unsure, say so briefly.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;User asks "what's my name?" Claw answers "I do not know your name." Every time. The name is sitting right there in sentence three.&lt;/p&gt;

&lt;p&gt;I spent about an hour staring at this. My theory after the fact is that "never restate the question" was acting as a dominant instruction, and the model generalized it to "don't reference any user context." That's the kind of overgeneralization a 2B-effective model does. Cloud LLMs don't.&lt;/p&gt;

&lt;p&gt;The fix was structural, not lexical:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;namePart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userName&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;userName&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isNotEmpty&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s"&gt;"The name of the user is &lt;/span&gt;&lt;span class="si"&gt;${userName.trim()}&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;
    &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;systemPreamble&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="si"&gt;${namePart}&lt;/span&gt;&lt;span class="s"&gt;You are Claw, ...'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same fact. Moved to the first line of the prompt. On its own. Flat declarative sentence. Worked the first time I tried it.&lt;/p&gt;

&lt;p&gt;The lesson generalizes. When you're working with a 2B-class model, anything you actually want the model to remember goes at the front of the prompt, in simple sentences, with no competing instructions in the same paragraph. The model is paying way more attention to the opening than to the middle. Treat the system prompt like a slot-filling template, not a paragraph.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Vanilla RAG breaks on the queries users actually type.
&lt;/h2&gt;

&lt;p&gt;If you've worked with RAG before, you know the textbook setup. Chunk the document, embed the chunks, store the vectors, embed the query at retrieval time, find the closest matches. Works great on benchmarks where the queries are specific.&lt;/p&gt;

&lt;p&gt;It doesn't work on "summarise the document."&lt;/p&gt;

&lt;p&gt;I caught this on Friday. I'd been heads-down for two weeks. I thought I had a working build. I uploaded a PDF I had lying around (a paper on edge LLMs), typed "summarise the document", hit send. Claw said "Summarize the document." back to me. Just that. Like an echo. I tried twice more with different phrasing. Same answer.&lt;/p&gt;

&lt;p&gt;Eventually I typed "summarise llmaiedge.pdf" with the actual filename. Got a real summary.&lt;/p&gt;

&lt;p&gt;The problem is obvious once you see it. "Summarise the document" has zero semantic overlap with the document text. The PDF doesn't contain the words "summarise" or "this document." Cosine similarity returns nothing useful. The retrieved chunk list is empty. Gemma gets the user's question with no actual document context attached. So it does what any LLM does when starved for context. It hallucinates a generic answer from its training data.&lt;/p&gt;

&lt;p&gt;The fix I shipped is two heuristics deep:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;isGenericIntent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;length&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;lower&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'summari'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="n"&gt;lower&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'tldr'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="n"&gt;lower&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'explain'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="n"&gt;lower&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'describe'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="n"&gt;lower&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'the document'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="n"&gt;lower&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'the pdf'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;isGenericIntent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;RagService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;instance&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getDocStarts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nl"&gt;conversationId:&lt;/span&gt; &lt;span class="n"&gt;_conversation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;getDocStarts&lt;/code&gt; is a small fallback method. It runs &lt;code&gt;searchSimilar&lt;/code&gt; once per indexed doc, using each doc's filename as the query. Filenames are rare distinctive tokens. Every chunk in the vector store has the filename in metadata. So this pulls back real chunks regardless of how the user phrased their question.&lt;/p&gt;

&lt;p&gt;Two lines of conditional logic. The difference between "Claw can summarise PDFs" and "Claw echoes your question back at you."&lt;/p&gt;

&lt;p&gt;If you're building RAG on Gemma 4 (or any small on-device model really), test it with generic queries before you ship. Your textbook similarity search will fail in ways that look like the model is broken.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The flutter_gemma plugin bundles ~33 MB of native libraries you probably don't use.
&lt;/h2&gt;

&lt;p&gt;Stock APK for PocketClaw came out at 185 MB. That felt heavy.&lt;/p&gt;

&lt;p&gt;When I unzipped it and looked at the native libs (arm64-v8a only, I was already shipping a single-arch build), this is what I saw:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;26 MB  libllm_inference_engine_jni.so       (needed)
24 MB  libLiteRtLm.so                       (needed)
17 MB  libgemma_embedding_model_jni.so      (don't use — using Gecko)
17 MB  libgecko_embedding_model_jni.so      (needed)
14 MB  libmediapipe_tasks_vision_jni.so     (needed — vision input)
14 MB  libmediapipe_tasks_vision_image_generator_jni.so  (NOT USED)
10 MB  libimagegenerator_gpu.so             (NOT USED)
8  MB  libLiteRtGpuAccelerator.so           (needed)
8  MB  libLiteRtWebGpuAccelerator.so        (NOT USED — Android has OpenCL)
9  MB  libtext_chunker_jni.so               (needed)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The image-generation libs are for using Gemma to &lt;em&gt;generate&lt;/em&gt; images. PocketClaw only &lt;em&gt;consumes&lt;/em&gt; images (vision input to multimodal Gemma). I'm never going to generate. The WebGPU accelerator is for browsers — Android uses OpenCL. None of it does anything on my target platform.&lt;/p&gt;

&lt;p&gt;Four lines in &lt;code&gt;android/app/build.gradle.kts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nf"&gt;packaging&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;jniLibs&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;excludes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;listOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;"**/libimagegenerator_gpu.so"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s"&gt;"**/libmediapipe_tasks_vision_image_generator_jni.so"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s"&gt;"**/libLiteRtWebGpuAccelerator.so"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s"&gt;"**/libLiteRtTopKWebGpuSampler.so"&lt;/span&gt;
        &lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;APK dropped from 185 MB to 152 MB. 33 MB cut. Vision input still works, embedder still works, inference still works.&lt;/p&gt;

&lt;p&gt;If your use case is similar (chat + vision input + RAG, no image generation), copy these excludes. If your use case is different — say you actually want Gemma to generate images — leave the image-gen libs in. The point is, audit what your plugin pulls in and exclude what you don't use. &lt;code&gt;flutter_gemma&lt;/code&gt; is built for general capability surface, not minimum-bytes-on-device.&lt;/p&gt;

&lt;p&gt;There's a second-order point here that matters more. MediaPipe is the reason &lt;code&gt;flutter_gemma&lt;/code&gt; is so big. It's also the reason it handles vision and (in 3n's case) audio. Text-focused alternatives like llama.cpp wrappers can ship at 30-60 MB on Android but with much more limited or no multimodal coverage today. So the choice is really: 152 MB with mature vision support, or 60 MB without. There's no free lunch where you get multimodal at the size of a text-only stack. Pick based on what your product actually needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Don't feed the 128K context window. Compact it.
&lt;/h2&gt;

&lt;p&gt;Gemma 4 has a 128K context window. Sounds great in theory. In practice it's a footgun.&lt;/p&gt;

&lt;p&gt;Every token in the prompt costs latency at decode time. Every token costs RAM. On a phone, both of those are tight. If you naively shove the whole chat history into context every turn, you'll find that turn 20 takes noticeably longer than turn 5, and turn 50 might OOM the app.&lt;/p&gt;

&lt;p&gt;PocketClaw keeps a sliding window of the most recent 24 messages in their raw form. Anything older runs through a compaction pass:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Extract explicit facts the user has stated ("I am X", "Remember Y", "My name is Z").&lt;/li&gt;
&lt;li&gt;Capture unresolved goals (keywords like "fix", "todo", "issue").&lt;/li&gt;
&lt;li&gt;Compile both into a single lightweight summary paragraph that gets prepended to the prompt as memory.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's the chat part. The aggressive part is image handling.&lt;/p&gt;

&lt;p&gt;A typical user-uploaded photo is roughly 1 MB. As base64 in a prompt, that's around 30,000 tokens. That's a quarter of the entire 128K context window for one image. If the user uploads three photos across a conversation, your context budget is in trouble.&lt;/p&gt;

&lt;p&gt;So PocketClaw does this: when an image message slides past the 24-message boundary, the raw image bytes get deleted from memory. What's preserved is the assistant's prior textual description of that image:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;_imageMemoryFromAssistant&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;imageName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;assistantText&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;imageName&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s"&gt;'uploaded image'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;'Assistant previously described &lt;/span&gt;&lt;span class="si"&gt;$label&lt;/span&gt;&lt;span class="s"&gt; as: '&lt;/span&gt;
         &lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="si"&gt;${_shorten(assistantText, 1000)}&lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So Claw still "remembers" what it saw, but only the description goes through the prompt. The 30,000-token base64 blob becomes a 100-token summary. That's roughly a 300x compression of image memory.&lt;/p&gt;

&lt;p&gt;The kicker: this works really well for the kind of follow-up questions users actually ask. "What was in that photo I sent earlier?" is answerable from the description alone. The model rarely needs the pixels back. If it does, the user can re-upload.&lt;/p&gt;

&lt;p&gt;The general pattern here is: don't think of the context window as "free space up to the limit." Think of it as a budget. Spend it on things the model needs &lt;em&gt;for the current turn&lt;/em&gt;. Everything else gets compacted to a textual summary.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Whether audio works in &lt;code&gt;flutter_gemma&lt;/code&gt; depends on your model file, not the Gemma version.
&lt;/h2&gt;

&lt;p&gt;This one I want you to know so you don't spend a day chasing the wrong thing like I did.&lt;/p&gt;

&lt;p&gt;Gemma 4 E2B's model card lists native audio as a supported modality. So I thought: cool, I'll skip the speech-to-text plugin entirely, feed raw audio bytes straight to Gemma, get a single multimodal call instead of an STT-then-LLM pipeline. Cleaner.&lt;/p&gt;

&lt;p&gt;I dug into &lt;code&gt;flutter_gemma&lt;/code&gt; v0.15.1 source. The plugin's documentation consistently frames audio as a Gemma 3n E4B feature:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;/// [supportAudio] — whether the model supports audio (Gemma 3n E4B only).&lt;/span&gt;
&lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;supportAudio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That phrasing shows up in eight different files in the plugin. The interface, the API docs, the example app, the native Android side — they all treat audio as 3n territory.&lt;/p&gt;

&lt;p&gt;But here's what's interesting once you read further. There is no hardcoded model-version check anywhere in the plugin. The actual gate is just &lt;code&gt;if (config.supportAudio == true)&lt;/code&gt;. So what's really limiting audio isn't the Dart code rejecting Gemma 4 — it's whether the model file you downloaded actually contains the audio encoder.&lt;/p&gt;

&lt;p&gt;The example app's &lt;code&gt;model.dart&lt;/code&gt; has the clearest hint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="nl"&gt;supportAudio:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// .litertlm files have TF_LITE_AUDIO_ENCODER&lt;/span&gt;
&lt;span class="nl"&gt;supportAudio:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// .task files don't have audio encoder&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the real question for any model you want to use with audio isn't "is it Gemma 3n?" — it's "does my &lt;code&gt;.litertlm&lt;/code&gt; file include the audio encoder for this model?" The plugin's docs assume the answer is yes only for Gemma 3n E4B because that's what's been tested and shipped that way. For Gemma 4 E2B, the model card says audio is supported by the model itself, but I haven't found a &lt;code&gt;.litertlm&lt;/code&gt; build of E2B that bundles the encoder. If one ships, the plugin should handle it — there's no version-gate to stop it.&lt;/p&gt;

&lt;p&gt;For PocketClaw I went with Android's system STT (the &lt;code&gt;speech_to_text&lt;/code&gt; package). Practical reasons. I get live transcription as the user speaks (text appears word by word while they're holding the mic). That's a noticeably better UX than the "hold, speak, release, wait" pattern you'd get from on-model audio. And it side-steps the question of whether my specific E2B &lt;code&gt;.litertlm&lt;/code&gt; file has the encoder.&lt;/p&gt;

&lt;p&gt;The takeaway: &lt;strong&gt;read your plugin's source before you trust its capability flags.&lt;/strong&gt; And read it carefully enough to separate documentation framing from actual gating logic. The plugin's docs say "Gemma 3n E4B only" eight times — but the code itself doesn't enforce that. If you have an E2B build with the audio encoder, it's worth testing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;Five patterns. None of them are in the README. None of them are in Google's docs. I learned all of them by shipping something real and watching it fail in interesting ways.&lt;/p&gt;

&lt;p&gt;If you're building on Gemma 4 for Android, these will save you time. If you want to see all five running together in a real app, &lt;a href="https://dev.to/manoj_shetty/how-i-built-a-fully-offline-ai-assistant-on-android-with-gemma-4-e2b-5a19"&gt;PocketClaw&lt;/a&gt; is fully open source, MIT licensed.&lt;/p&gt;

&lt;p&gt;The thing I keep coming back to, after 17 days with Gemma 4 E2B on a mid-range Android phone, is how capable a 2B model can be when it's running fast on the user's device. The latency feels different from cloud. There's no perceived "AI thinking" delay because there's no network. It just answers at the speed of your phone.&lt;/p&gt;

&lt;p&gt;That's worth optimizing for.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Editorial assistance from Claude. All code, decisions, bugs, and engineering choices in this post are mine.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>gemmachallenge</category>
      <category>gemma</category>
      <category>flutter</category>
    </item>
    <item>
      <title>How I built a fully offline AI assistant on Android with Gemma 4 E2B</title>
      <dc:creator>Manoj Shetty</dc:creator>
      <pubDate>Sun, 24 May 2026 04:43:53 +0000</pubDate>
      <link>https://dev.to/manoj_shetty/how-i-built-a-fully-offline-ai-assistant-on-android-with-gemma-4-e2b-5a19</link>
      <guid>https://dev.to/manoj_shetty/how-i-built-a-fully-offline-ai-assistant-on-android-with-gemma-4-e2b-5a19</guid>
      <description>&lt;h1&gt;
  
  
  How I built a fully offline AI assistant on Android with Gemma 4 E2B
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/google-gemma-2026-05-06"&gt;Gemma 4 Challenge: Build with Gemma 4&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built
&lt;/h2&gt;

&lt;p&gt;PocketClaw is an Android assistant that runs entirely on your phone. You can chat with it, talk to it (press and hold the mic, it transcribes live), show it photos, hand it a PDF and ask questions about it, or tell it to turn on the flashlight, set an alarm, open the dialer, send an SMS, drop something on the calendar, search the web, or fire a notification. All of that runs on a 1.5 GB model that lives on the device.&lt;/p&gt;

&lt;p&gt;Once the model is downloaded the first time, you can switch on airplane mode. Nothing breaks.&lt;/p&gt;

&lt;p&gt;I built it solo, 17 days, for this challenge.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/CknufqBTQtQ"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repo:&lt;/strong&gt; &lt;a href="https://github.com/ManojRakshu/pocketclaw" rel="noopener noreferrer"&gt;github.com/ManojRakshu/pocketclaw&lt;/a&gt; (MIT)&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fed2afz0izxhlk9asbbqh.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fed2afz0izxhlk9asbbqh.jpg" alt="Onboarding" width="800" height="1787"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why on-device and why E2B
&lt;/h2&gt;

&lt;p&gt;I've been building agents on cloud LLMs for about a year and a half. Claude mostly, GPT-4 for a few things. Every agent I've put in front of users has had the same set of problems sitting behind it. Latency adds up when you're chaining calls. The cost per call gets real once you have real traffic. And the whole thing stops the second the network goes down.&lt;/p&gt;

&lt;p&gt;Phones are interesting because they fix all three of those at once. Model lives on the device, so there's no per-call cost. No network in the loop, so latency is just silicon. And the network can disappear without anything breaking.&lt;/p&gt;

&lt;p&gt;The thing that constrains you is RAM. A mid-range Android phone gives an app something like 1.5 to 2 GB of usable working memory before the OS starts pushing back. That's enough to rule out most of the Gemma 4 family:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;E2B&lt;/strong&gt; at about 1.5 GB INT4. Fits. Has vision built in. This is what I shipped.&lt;/li&gt;
&lt;li&gt;E4B at about 2.5 GB. Tight on high-end phones, OOMs on lower-end ones.&lt;/li&gt;
&lt;li&gt;26B MoE. Workstation.&lt;/li&gt;
&lt;li&gt;31B dense. Server.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I built and tested on a OnePlus Nord CE 4 (Snapdragon 7s Gen 3, Adreno GPU, 8 GB RAM, Android 14). First-token latency is around 1 to 3 seconds for chat, around 5 for vision. Slower than cloud. But there's no network in the way.&lt;/p&gt;

&lt;p&gt;The 1.5 GB number is worth being precise about. Full precision E2B (fp32) is roughly 20 GB. fp16 is 10. INT8 is 5. INT4 with Google's litert-lm packaging gets you down to 1.5 GB. Same precision class as what Google ships in Pixel's Gemini Nano. For phones, INT4 is the only answer that makes sense.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzwi1uwe12th6rt88qjks.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzwi1uwe12th6rt88qjks.jpg" alt="Setup" width="800" height="1787"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How it's put together
&lt;/h2&gt;

&lt;p&gt;Three layers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The model.&lt;/strong&gt; I'm using &lt;code&gt;flutter_gemma&lt;/code&gt;, which wraps Google's MediaPipe LLM API and LiteRT-LM on Android. I picked it because among the Flutter plugins I evaluated, it had the most mature support for Gemma 4's vision input. There's a cost to this. The MediaPipe stack adds about 80 MB of native libraries to the APK. Lighter, text-focused alternatives (llama.cpp wrappers, etc.) can ship at 30-60 MB. My release APK is 152 MB, down from 185 after I trimmed image-generation and WebGPU runtimes via Gradle (the plugin bundles them, we never use them):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// android/app/build.gradle.kts&lt;/span&gt;
&lt;span class="nf"&gt;packaging&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;jniLibs&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;excludes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;listOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="c1"&gt;// We never generate images, only consume them.&lt;/span&gt;
            &lt;span class="s"&gt;"**/libimagegenerator_gpu.so"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s"&gt;"**/libmediapipe_tasks_vision_image_generator_jni.so"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="c1"&gt;// WebGPU is for browsers, useless on Android.&lt;/span&gt;
            &lt;span class="s"&gt;"**/libLiteRtWebGpuAccelerator.so"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s"&gt;"**/libLiteRtTopKWebGpuSampler.so"&lt;/span&gt;
        &lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you don't need vision you can probably get below 80 MB by switching to a text-focused stack. I needed vision so I'm at 152.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RAG on-device.&lt;/strong&gt; A second model handles embeddings. Gecko 110M, around 110 MB on disk. I went with Gecko over EmbeddingGemma 300M because Gecko is roughly 3x smaller and the retrieval quality on PDFs of a hundred pages or less was comparable. Could be different at larger corpora. The pipeline is Syncfusion for PDF extraction, my own chunker (paragraph split, merge tinies, sentence-aware sub-split for anything still over the threshold), Gecko for embedding, sqlite-vec with HNSW for the vector store. All on device.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Device actions.&lt;/strong&gt; Gemma's job here is intent classification. The user types or says "set an alarm for 7:30 AM". Gemma emits a structured JSON object that identifies the tool and parameters. Dart parses it. A native Kotlin MethodChannel (&lt;code&gt;pocketclaw/device&lt;/code&gt;) fires the right Android intent. Eight categories work this way. Flashlight via &lt;code&gt;CameraManager.setTorchMode&lt;/code&gt;. Alarms via &lt;code&gt;AlarmClock.ACTION_SET_ALARM&lt;/code&gt;. Dialer via &lt;code&gt;ACTION_DIAL&lt;/code&gt;. SMS via &lt;code&gt;ACTION_SENDTO&lt;/code&gt;. Calendar via &lt;code&gt;ACTION_INSERT&lt;/code&gt;. Location settings panel. Web search by handing the query to the default browser. Local notifications via &lt;code&gt;NotificationManager&lt;/code&gt;. Nothing in the loop touches the network. The LLM doesn't even know the network exists.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fszz2qcic6hb0q4z3wmfc.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fszz2qcic6hb0q4z3wmfc.jpg" alt="Loading" width="800" height="1787"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Things that broke
&lt;/h2&gt;

&lt;h3&gt;
  
  
  RAG dies on generic queries
&lt;/h3&gt;

&lt;p&gt;Vanilla RAG works fine for specific questions. Someone uploads a PDF about PocketClaw, asks "who built PocketClaw", retrieval picks up a chunk that contains my name, Gemma summarises, done.&lt;/p&gt;

&lt;p&gt;It falls over on the queries people actually type. I caught this Friday afternoon. I'd shipped what I thought was a working build. I uploaded &lt;code&gt;llmaiedge.pdf&lt;/code&gt; (a PDF about edge LLMs I had lying around), typed "summarise the document", hit send. Claw answered with "Summarize the document." That's it. I tried twice more with different phrasing. Same answer. Eventually I typed "summarise llmaiedge.pdf" and got a real response. The filename was doing the work, not my retrieval.&lt;/p&gt;

&lt;p&gt;The problem is that "summarise this doc" has no semantic overlap with the actual document text. The doc doesn't contain the words "summarise" or "this doc." Cosine similarity returns nothing useful, the prompt goes to Gemma with no real context, and Gemma fills in with whatever its training data feels like saying about generic documents.&lt;/p&gt;

&lt;p&gt;The fix runs two heuristics:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;isGenericIntent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;length&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;lower&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'summari'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="n"&gt;lower&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'tldr'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="n"&gt;lower&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'explain'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="n"&gt;lower&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'describe'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="n"&gt;lower&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'the document'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="n"&gt;lower&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'the pdf'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;// ... a few more&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;isGenericIntent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Fall back to searching with each indexed doc's filename as the query.&lt;/span&gt;
    &lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;RagService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;instance&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getDocStarts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nl"&gt;conversationId:&lt;/span&gt; &lt;span class="n"&gt;_conversation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;getDocStarts&lt;/code&gt; runs &lt;code&gt;searchSimilar&lt;/code&gt; once per indexed doc, using the filename itself as the query. Filenames are rare distinctive tokens. Every chunk in the vector store has the filename in metadata. So this pulls back real chunks regardless of how the user phrased their question. Two lines of conditional logic, and the difference between a broken demo and a working one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Small models drop facts buried mid-prompt
&lt;/h3&gt;

&lt;p&gt;I wanted Claw to remember the user's name. Onboarding asks for it, prefs stores it, the system prompt includes it. User asks "what's my name?" Claw says "I do not know your name."&lt;/p&gt;

&lt;p&gt;The first version of my system prompt looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You are Claw. You run locally and offline. You are talking to Manoj Shetty.
Match your answer length to the question. Prefer plain answers over preambles.
Never restate the question. If unsure, say so briefly.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The third sentence has the name. Gemma 4 E2B dropped it completely. I burned about an hour staring at this before I figured out what was happening. My theory is that "never restate the question" was acting as a dominant instruction that generalized to "don't reference user context at all." Small models do that. Cloud LLMs don't.&lt;/p&gt;

&lt;p&gt;The fix was to move the fact to the top of the prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;namePart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userName&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;userName&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isNotEmpty&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s"&gt;"The name of the user is &lt;/span&gt;&lt;span class="si"&gt;${userName.trim()}&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;
    &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;systemPreamble&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="si"&gt;${namePart}&lt;/span&gt;&lt;span class="s"&gt;You are Claw, ...'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same information. First line of the prompt, on its own, in a flat declarative sentence. Worked first try.&lt;/p&gt;

&lt;p&gt;Lesson I've now learned twice. With small models, what you want the model to know goes at the front, in simple sentences, without competing instructions next to it. Cloud LLMs respect the whole prompt. 2B models don't.&lt;/p&gt;

&lt;h3&gt;
  
  
  Audio in flutter_gemma is framed for Gemma 3n, not Gemma 4
&lt;/h3&gt;

&lt;p&gt;I wanted to skip the speech-to-text plugin entirely. Just feed audio bytes directly to Gemma 4 E2B's audio modality. The model card says E2B supports audio. Cleaner architecture, one fewer dependency.&lt;/p&gt;

&lt;p&gt;I went to dig into &lt;code&gt;flutter_gemma&lt;/code&gt; v0.15.1 source. Eight different files in the plugin frame audio as a Gemma 3n E4B feature, including this in the interface:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;/// [supportAudio] — whether the model supports audio (Gemma 3n E4B only).&lt;/span&gt;
&lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;supportAudio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The example app makes the real constraint clearer: &lt;code&gt;.litertlm&lt;/code&gt; files that bundle &lt;code&gt;TF_LITE_AUDIO_ENCODER&lt;/code&gt; work with &lt;code&gt;supportAudio: true&lt;/code&gt;, &lt;code&gt;.task&lt;/code&gt; files don't. So the actual limit isn't the Gemma version — it's whether your specific model file contains the audio encoder. I haven't found a Gemma 4 E2B &lt;code&gt;.litertlm&lt;/code&gt; build that bundles the audio encoder, and the plugin's docs treat audio as 3n-territory.&lt;/p&gt;

&lt;p&gt;So PocketClaw uses Android system STT via the &lt;code&gt;speech_to_text&lt;/code&gt; package, which is a wrapper around &lt;code&gt;RecognizerIntent&lt;/code&gt;. Side benefit: I get live transcription as the user speaks, which is genuinely a better UX than the "hold, release, wait three seconds, see both the transcription and the response" pattern you'd get from on-model audio. When a Gemma 4 E2B &lt;code&gt;.litertlm&lt;/code&gt; build with audio encoder ships, the voice path collapses into a single multimodal call. Not blocking on v1.&lt;/p&gt;

&lt;h2&gt;
  
  
  Long chats and memory
&lt;/h2&gt;

&lt;p&gt;Gemma 4 has a 128K context window. That's plenty in theory. In practice, every token costs latency and RAM, so I'd rather not feed the whole history every turn.&lt;/p&gt;

&lt;p&gt;PocketClaw keeps the most recent 24 messages in full text. Anything older runs through a compaction pass:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Extract facts the user has stated explicitly ("I am X", "My name is Y", "Remember Z").&lt;/li&gt;
&lt;li&gt;Capture unresolved goals (keywords like "fix", "todo", "issue").&lt;/li&gt;
&lt;li&gt;Compile the whole thing into a single lightweight summary paragraph that gets prepended to the prompt as memory.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The more aggressive part: when an image message slides past the 24-message boundary, the raw image bytes get deleted. What stays is the assistant's prior textual description of that image:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;_imageMemoryFromAssistant&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;imageName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;assistantText&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;imageName&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s"&gt;'uploaded image'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;'Assistant previously described &lt;/span&gt;&lt;span class="si"&gt;$label&lt;/span&gt;&lt;span class="s"&gt; as: &lt;/span&gt;&lt;span class="si"&gt;${_shorten(assistantText, 1000)}&lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Claw still "remembers" what it saw, but without the bytes weighing on the prompt. This matters more than it sounds like it should. A 1 MB photo as base64 is around 30K tokens. The textual description of the same image is around 100. So this is roughly a 300x compression of image memory, with surprisingly little loss for the kinds of follow-up questions users actually ask.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stack
&lt;/h2&gt;

&lt;p&gt;Flutter 3.41 / Dart 3.11. Chat model is Gemma 4 E2B INT4 at 1.5 GB, loaded through &lt;code&gt;flutter_gemma&lt;/code&gt; on top of MediaPipe LLM and LiteRT-LM. Embedder is Gecko 110M (110 MB) for RAG. Vector store is sqlite-vec with HNSW, on device. Speech-to-text is the system STT via &lt;code&gt;speech_to_text&lt;/code&gt;. Eight device actions go through a Kotlin MethodChannel. Hive for state (separate boxes for conversations, documents, prefs). &lt;code&gt;syncfusion_flutter_pdf&lt;/code&gt; for PDF extraction. The theme is custom neobrutalistic dark with hard borders, monospace type, and cyan/purple/mint accents. I wanted it to feel like a piece of hardware, not a Material 3 chat app.&lt;/p&gt;

&lt;h2&gt;
  
  
  What didn't make v1
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;floating overlay bubble&lt;/strong&gt; (Android 13+ accessibility-style overlay) was in the original design. I cut it because the overlay permission UX has a long tail I didn't want to ship rough.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Direct Gemma 4 audio input.&lt;/strong&gt; Waiting on a Gemma 4 E2B &lt;code&gt;.litertlm&lt;/code&gt; build with the audio encoder. v2 when that's available.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Per-message RAG toggle.&lt;/strong&gt; Right now if you have a document attached, every message in that conversation retrieves against it. Sometimes users want to ask an unrelated follow-up without doc context. v2.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Chunk citations in responses.&lt;/strong&gt; Claw answers from retrieved chunks but doesn't surface which chunk. The retrieval data is sitting there. It's a UI add. v2.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;Most "run an LLM on your phone" tutorials I've read this year stop at model loading. &lt;code&gt;flutter_gemma&lt;/code&gt; handles loading in about 10 lines. That part is easy.&lt;/p&gt;

&lt;p&gt;The work is in everything around the model. The compaction so long chats don't OOM. The RAG fallbacks for queries that don't fit the textbook similarity-search assumption. The native channels for actual device actions. The Gradle excludes for plugin libs you don't need. The prompt structure that gets a 2B model to follow instructions reliably. The error handling for when the model file is half downloaded and the user opens the app anyway.&lt;/p&gt;

&lt;p&gt;What surprised me about Gemma 4 E2B, coming from cloud models, is how capable a 2B model can be when it's running fast on your own device. Vision captioning is genuinely useful. Intent classification across 8 tool categories works well enough to ship. There's no perceived "AI thinking" delay because there's no network. The model speaks at the speed of your phone.&lt;/p&gt;

&lt;p&gt;For a 1.5 GB download, that's a real deal.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Repo:&lt;/strong&gt; &lt;a href="https://github.com/ManojRakshu/pocketclaw" rel="noopener noreferrer"&gt;github.com/ManojRakshu/pocketclaw&lt;/a&gt;&lt;br&gt;&lt;br&gt;
&lt;strong&gt;License:&lt;/strong&gt; MIT&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Tested on:&lt;/strong&gt; OnePlus Nord CE 4 (Snapdragon 7s Gen 3, 8 GB RAM, Android 14)&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Demo recorded:&lt;/strong&gt; May 24, 2026&lt;/p&gt;

&lt;p&gt;If you want to look at the design system, RAG service, or compaction engine, the source is fully open. PRs welcome.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Editorial assistance from Claude. All code, decisions, bugs, and engineering choices in this post are mine.&lt;/em&gt;&lt;/p&gt;




</description>
      <category>gemmachallenge</category>
      <category>gemma</category>
      <category>flutter</category>
      <category>devchallenge</category>
    </item>
  </channel>
</rss>
