<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Building Aethas | A Proactive AI Assistant Built in Public]]></title><description><![CDATA[Follow the development of Aethas, a proactive AI executive assistant. Technical deep-dives, architecture decisions, and honest progress updates from a solo founder.]]></description><link>https://blog.aethas.ai</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1767853235779/21bb3dbc-84f6-40bf-94da-93ee84be71a6.png</url><title>Building Aethas | A Proactive AI Assistant Built in Public</title><link>https://blog.aethas.ai</link></image><generator>RSS for Node</generator><lastBuildDate>Fri, 17 Apr 2026 11:58:31 GMT</lastBuildDate><atom:link href="https://blog.aethas.ai/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Refactoring to a Hybrid Cloud Architecture]]></title><description><![CDATA[When I started building Aethas, I started with a purely local-first approach. Everything would run on the user's machine: their data, their embeddings, their conversations. No cloud required. This was great for learning Tauri and iterating quickly.
I...]]></description><link>https://blog.aethas.ai/refactoring-to-a-hybrid-cloud-architecture</link><guid isPermaLink="true">https://blog.aethas.ai/refactoring-to-a-hybrid-cloud-architecture</guid><category><![CDATA[Bun]]></category><category><![CDATA[Tauri]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[Build In Public]]></category><category><![CDATA[hono]]></category><dc:creator><![CDATA[Stephen Ashmore]]></dc:creator><pubDate>Tue, 20 Jan 2026 13:49:15 GMT</pubDate><content:encoded><![CDATA[<p>When I started building Aethas, I started with a purely local-first approach. Everything would run on the user's machine: their data, their embeddings, their conversations. No cloud required. This was great for learning Tauri and iterating quickly.</p>
<p>I knew that would be just the first step. I wanted proactivity, I wanted something to act on my behalf without a prompt. How could an AI prepare your meeting context if it only runs when you open your laptop? How could you access your knowledge base from your phone? How could the system surface relevant information throughout your day?</p>
<p>It's time to think about the server architecture.</p>
<hr />
<h2 id="heading-the-original-architecture">The Original Architecture</h2>
<p>The initial stack was elegant in its simplicity:</p>
<ul>
<li><strong>Tauri 2.0</strong> for the desktop app (Rust backend, native webview)</li>
<li><strong>React + TypeScript</strong> frontend</li>
<li><strong>SQLite</strong> for local storage</li>
<li><strong>FastEmbed (Rust)</strong> for local embeddings</li>
</ul>
<p>Everything ran locally. The Rust backend handled file indexing, vector search, and Claude API calls. My test users brought their own API keys. Data never left their machine.</p>
<p>This worked beautifully for the core use case: searching your knowledge base and chatting with an AI that understood your context. But, you can do that with MCP servers easily. Aethas was designed from the beginning for proactivity.</p>
<hr />
<h2 id="heading-why-i-needed-a-server">Why I Needed a Server</h2>
<p>Three requirements drove the change:</p>
<p><strong>Proactive AI.</strong> I want Aethas to prepare meeting briefs before calendar events, surface relevant context throughout the day, draft responses to incoming emails. This requires compute happening in the background, even when the desktop app is closed.</p>
<p><strong>Mobile access.</strong> Querying your knowledge base from your phone means the data needs to be accessible somewhere other than your laptop. Running Tauri on mobile isn't practical, and I didn't want to build two native apps.</p>
<p><strong>Continuous processing.</strong> Background agents that monitor for triggers, process new content, and maintain index freshness need a persistent runtime.</p>
<p>The solution: a hybrid architecture where the desktop app becomes a thin client that can sync local data, but a cloud server handles background processing and provides API access.</p>
<hr />
<h2 id="heading-choosing-the-stack-bun-hono">Choosing the Stack: Bun + Hono</h2>
<p>I needed a backend stack with fast cold starts (for serverless later), TypeScript-native (shared types with frontend), minimal boilerplate, and great DX.</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Option</td><td>Pros</td><td>Cons</td></tr>
</thead>
<tbody>
<tr>
<td>Express</td><td>Mature, huge ecosystem</td><td>Verbose, slow cold starts</td></tr>
<tr>
<td>Fastify</td><td>Fast, schema validation</td><td>Still needs Node</td></tr>
<tr>
<td>Hono</td><td>Ultra-light, runs anywhere</td><td>Newer, smaller ecosystem</td></tr>
<tr>
<td>tRPC</td><td>Type-safe RPC, great DX</td><td>Overkill for REST-ish API</td></tr>
</tbody>
</table>
</div><p><strong>Bun + Hono won.</strong></p>
<h3 id="heading-buns-built-in-sqlite">Bun's Built-in SQLite</h3>
<p>Bun ships with native SQLite bindings:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">import</span> { Database } <span class="hljs-keyword">from</span> <span class="hljs-string">'bun:sqlite'</span>;

<span class="hljs-keyword">const</span> sqlite = <span class="hljs-keyword">new</span> Database(<span class="hljs-string">'./data/aethas.db'</span>);
sqlite.exec(<span class="hljs-string">'PRAGMA journal_mode = WAL'</span>);
</code></pre>
<p>This eliminated my biggest Node.js pain point: cross-platform SQLite compilation. Anyone who's fought with <code>better-sqlite3</code> on different platforms knows the pain. We still might switch to postgres later, but this allows me to iterate quickly and leverage some really nice tools for RAG and tokens.</p>
<h3 id="heading-honos-simplicity">Hono's Simplicity</h3>
<p>The entire server setup:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">const</span> app = <span class="hljs-keyword">new</span> Hono();

app.use(<span class="hljs-string">'*'</span>, logger());
app.use(<span class="hljs-string">'*'</span>, cors({ origin: [<span class="hljs-string">'http://localhost:1420'</span>], credentials: <span class="hljs-literal">true</span> }));

app.route(<span class="hljs-string">'/api/conversations'</span>, conversationsRoutes);
app.route(<span class="hljs-string">'/api/chat'</span>, chatRoutes);
app.route(<span class="hljs-string">'/api/sources'</span>, sourcesRoutes);

<span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> { port: <span class="hljs-number">3000</span>, fetch: app.fetch };
</code></pre>
<p>The server starts in under 100ms. Hono's SSE streaming support is equally clean, which matters for chat applications where streaming UX is everything.</p>
<p>It's great to not have webpack and have near immediate hot-reloads from typescript:</p>
<pre><code class="lang-bash">bun run --hot src/index.ts
</code></pre>
<p>Development velocity depends on feedback loops, especially when using an AI tool like Claude to help you code, any longer than 2 seconds and you lose flow.</p>
<hr />
<h2 id="heading-database-layer-drizzle">Database Layer: Drizzle</h2>
<p>For the ORM, I chose Drizzle over Prisma or raw SQL.</p>
<p>Drizzle schemas are TypeScript-first, giving compile-time checks and autocompletion. The query syntax mirrors SQL directly. I <strong>hate</strong> "magic" in ORMs. I want to no surprises and to be as close to sql as possible. And unlike Prisma's query engine, Drizzle compiles to thin wrappers around your database driver. Docker images stay small, startup stays fast.</p>
<p>The tradeoff: Drizzle's ecosystem is smaller than Prisma's. So far it's been pretty good. It's probably one of the best ORM experiences I've had with SQL, though I'll probably never get used to importing operators and using them.</p>
<hr />
<h2 id="heading-monorepo-structure">Monorepo Structure</h2>
<p>I restructured to a pnpm workspace monorepo:</p>
<pre><code>aethas/
  apps/
    web/          # React frontend (Vite)
    desktop/      # Tauri app (wraps web)
  server/         # Bun + Hono backend
  packages/
    shared/       # TypeScript types shared across all
</code></pre><p>The <code>@aethas/shared</code> package contains every API contract, every event type, every data structure. Changes propagate to both frontend and backend compilation automatically. The TypeScript compiler catches interface mismatches.</p>
<p>This was the biggest productivity win of the refactor. Start with shared types early. I've found over the years that shared types can be one of the largest pains with any system, especially javascript. The monorepo approach is really well-suited to avoiding importing yet another package from NPM.</p>
<hr />
<h2 id="heading-development-environment">Development Environment</h2>
<p>For local development, I use Docker Compose with Tilt for orchestration.</p>
<p>The Dockerfile is minimal thanks to Bun:</p>
<pre><code class="lang-dockerfile"><span class="hljs-keyword">FROM</span> oven/bun:<span class="hljs-number">1</span>
<span class="hljs-keyword">WORKDIR</span><span class="bash"> /app</span>
<span class="hljs-keyword">COPY</span><span class="bash"> package.json bun.lockb* ./</span>
<span class="hljs-keyword">RUN</span><span class="bash"> bun install</span>
<span class="hljs-keyword">COPY</span><span class="bash"> . .</span>
<span class="hljs-keyword">EXPOSE</span> <span class="hljs-number">3000</span>
<span class="hljs-keyword">CMD</span><span class="bash"> [<span class="hljs-string">"bun"</span>, <span class="hljs-string">"run"</span>, <span class="hljs-string">"src/index.ts"</span>]</span>
</code></pre>
<p>Tilt coordinates the server container and local web dev. Running <code>tilt up</code> starts everything with live reload and a dashboard showing all services. The whole stack comes up in seconds. Tilt allows me to have a lot of flexibility on top of docker-compose while remaining close to a live production deployment for easy deployment. Plus, I can switch it to a local kubernetes cluster later and get close to a mirror of my deployments.</p>
<hr />
<h2 id="heading-lessons-learned">Lessons Learned</h2>
<p><strong>Start with shared types.</strong> Every API contract in one package. TypeScript catches mismatches at compile time. This saved me hours of debugging.</p>
<p><strong>Design for streaming from the start.</strong> Don't bolt SSE onto a request/response API later. The event types, streaming routes, and frontend handlers should be designed together.</p>
<p><strong>SQLite scales further than you think.</strong> I debated PostgreSQL early on. For a single-user app with tens of thousands of documents, SQLite with WAL mode handles everything. The operational simplicity — one file, works everywhere — is worth more than theoretical scale.</p>
<p><strong>Monorepo friction is real.</strong> pnpm workspaces work well, but the mental model of "which package am I in?" takes adjustment. Clear naming conventions help: <code>@aethas/web</code>, <code>@aethas/server</code>, <code>@aethas/shared</code>.</p>
<p><strong>Hot reload or bust.</strong> Bun's instant restarts, Vite's HMR, and Tilt's live updates keep the feedback cycle under 2 seconds. Anything longer and you lose flow.</p>
<hr />
<h2 id="heading-what-this-unlocks">What This Unlocks</h2>
<p>The hybrid architecture unlocks the proactive AI roadmap:</p>
<ul>
<li>Calendar integration — prepare meeting briefs before events</li>
<li>Email triage — surface relevant context for incoming messages</li>
<li>Background agents — process new content continuously</li>
<li>Mobile access — same API, different client</li>
</ul>
<p>The foundation is set!</p>
]]></content:encoded></item><item><title><![CDATA[Building a Sync Status Indicator]]></title><description><![CDATA[Aethas indexes local files like Obsidian vaults, or markdown folders for context-aware conversations. This happens at startup, when users manually trigger a re-index, and continuously via file watchers. After some use I found it annoying to guess if ...]]></description><link>https://blog.aethas.ai/building-a-sync-status-indicator</link><guid isPermaLink="true">https://blog.aethas.ai/building-a-sync-status-indicator</guid><category><![CDATA[React]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[state-machines]]></category><category><![CDATA[Tauri]]></category><category><![CDATA[Build In Public]]></category><dc:creator><![CDATA[Stephen Ashmore]]></dc:creator><pubDate>Tue, 13 Jan 2026 15:00:16 GMT</pubDate><content:encoded><![CDATA[<p>Aethas indexes local files like Obsidian vaults, or markdown folders for context-aware conversations. This happens at startup, when users manually trigger a re-index, and continuously via file watchers. After some use I found it annoying to guess if a file was synced when I made changes.</p>
<p>This is a small feature, but I thought it would be fun to talk about the design challenge. The sync indicator should be informative but not intrusive. Ideally as I build out some of the later features in my roadmap, we'll see the sync indicator move around and become less prominent.</p>
<hr />
<h2 id="heading-state-machine-design">State Machine Design</h2>
<p>I love state machines, so I modeled sync status as a state machine with four states:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">export</span> <span class="hljs-keyword">type</span> SyncStatus = <span class="hljs-string">'idle'</span> | <span class="hljs-string">'syncing'</span> | <span class="hljs-string">'error'</span> | <span class="hljs-string">'watching'</span>;
</code></pre>
<div class="hn-table">
<table>
<thead>
<tr>
<td>State</td><td>Meaning</td><td>Visual</td></tr>
</thead>
<tbody>
<tr>
<td><code>idle</code></td><td>All sources up to date</td><td>Green checkmark, "Last sync: 2 min ago"</td></tr>
<tr>
<td><code>syncing</code></td><td>Startup or manual sync in progress</td><td>Spinner, "Syncing: Source Name"</td></tr>
<tr>
<td><code>watching</code></td><td>File watcher detected changes</td><td>Spinner, "Indexing: filename.md"</td></tr>
<tr>
<td><code>error</code></td><td>Sync completed with errors</td><td>Amber warning, "Sync error"</td></tr>
</tbody>
</table>
</div><p>The transitions:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768163058286/ef12a313-11ca-4604-b082-6716b351e004.png" alt class="image--center mx-auto" /></p>
<p>The key insight: <code>watching</code> is a transient state. When file watchers detect changes, I briefly show indexing activity, then automatically return to <code>idle</code> after 2 seconds of inactivity. This prevents the indicator from flickering during rapid edits while still providing feedback.</p>
<hr />
<h2 id="heading-the-debounce-pattern">The Debounce Pattern</h2>
<p>The <code>watching</code> state needs special handling. If a user is actively editing, I don't want the indicator flickering on every keystroke save (the file watches can be that fast).</p>
<p>The solution: a debounce timeout that clears activity after 2 seconds of silence.</p>
<pre><code class="lang-typescript">setFileIndexed: <span class="hljs-function">(<span class="hljs-params">sourceId, sourceName, filePath</span>) =&gt;</span>
  set(<span class="hljs-function">(<span class="hljs-params">state</span>) =&gt;</span> ({
    <span class="hljs-comment">// Only transition to 'watching' if not already in a full sync</span>
    status: state.status === <span class="hljs-string">'syncing'</span> ? <span class="hljs-string">'syncing'</span> : <span class="hljs-string">'watching'</span>,
    watcherActivity: { sourceId, sourceName, filePath },
  })),

clearWatcherActivity: <span class="hljs-function">() =&gt;</span>
  set(<span class="hljs-function">(<span class="hljs-params">state</span>) =&gt;</span> ({
    status: state.status === <span class="hljs-string">'watching'</span> ? <span class="hljs-string">'idle'</span> : state.status,
    watcherActivity: <span class="hljs-literal">null</span>,
  })),
</code></pre>
<p>The component resets the timeout on each file event:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">if</span> (watcherActivityTimeoutRef.current) {
  <span class="hljs-built_in">clearTimeout</span>(watcherActivityTimeoutRef.current);
}
watcherActivityTimeoutRef.current = <span class="hljs-built_in">setTimeout</span>(<span class="hljs-function">() =&gt;</span> {
  clearWatcherActivity();
}, <span class="hljs-number">2000</span>);
</code></pre>
<p>User saves a file, indicator shows "Indexing: notes.md", then fades back to idle. User rapid-fires saves while editing, indicator stays on "Indexing" until they pause.</p>
<hr />
<h2 id="heading-cross-platform-events">Cross-Platform Events</h2>
<p>Aethas runs in two modes: desktop (Tauri) with full file system access, and web connecting to a backend server. The sync status needs to work in both.</p>
<p>Tauri has its own event system: the Rust backend emits events, the frontend subscribes via <code>@tauri-apps/api/event</code>. But that doesn't exist in web mode.</p>
<p>I created an abstraction that mirrors Tauri's API:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">listen</span>&lt;<span class="hljs-title">T</span>&gt;(<span class="hljs-params">
  event: <span class="hljs-built_in">string</span>,
  callback: (event: { payload: T }) =&gt; <span class="hljs-built_in">void</span>
</span>): <span class="hljs-title">Promise</span>&lt;(<span class="hljs-params"></span>) =&gt; <span class="hljs-title">void</span>&gt; </span>{
  <span class="hljs-keyword">if</span> (isTauri()) {
    <span class="hljs-keyword">const</span> { listen: tauriListen } = <span class="hljs-keyword">await</span> <span class="hljs-keyword">import</span>(<span class="hljs-string">'@tauri-apps/api/event'</span>);
    <span class="hljs-keyword">return</span> tauriListen&lt;T&gt;(event, callback);
  } <span class="hljs-keyword">else</span> {
    <span class="hljs-keyword">return</span> chatEvents.on&lt;T&gt;(event, <span class="hljs-function">(<span class="hljs-params">payload</span>) =&gt;</span> callback({ payload }));
  }
}
</code></pre>
<p>Components just call <code>listen()</code> without knowing which mode they're in. The abstraction handles the rest. We'll be making major changes to this in the future, I've already had the need to use this on my phone and other clients. I fully expect to refactor Aethas to make Tauri a thin-client.</p>
<hr />
<h2 id="heading-animation-trick">Animation Trick</h2>
<p>One subtle detail in the UI component. I render all three icons and use CSS to show/hide them:</p>
<pre><code class="lang-tsx">&lt;Loader2 className={cn('animate-spin', status !== 'syncing' &amp;&amp; 'hidden')} /&gt;
&lt;AlertTriangle className={cn(status !== 'error' &amp;&amp; 'hidden')} /&gt;
&lt;Check className={cn((status === 'syncing' || status === 'error') &amp;&amp; 'hidden')} /&gt;
</code></pre>
<p>Why not conditionally render? Because React would unmount and remount the spinner on state changes, restarting the animation from the beginning. Jarring visual jump. There could be a better way to do this, but this one is quite smooth.</p>
<hr />
<h2 id="heading-race-condition-on-mount">Race Condition on Mount</h2>
<p>What if the UI mounts after startup sync has already begun? The "started" event already fired. We'd miss it.</p>
<p>I handle this by checking sync status on mount:</p>
<pre><code class="lang-typescript">useEffect(<span class="hljs-function">() =&gt;</span> {
  isSyncing().then(<span class="hljs-function">(<span class="hljs-params">syncing</span>) =&gt;</span> {
    <span class="hljs-keyword">if</span> (syncing) {
      setStarted({ source_count: <span class="hljs-number">0</span> });
    }
  });
}, []);
</code></pre>
<p>The Rust backend exposes this via an atomic boolean. Events may fire before your component mounts, always synchronize initial state.</p>
<hr />
<h2 id="heading-error-ux-philosophy">Error UX Philosophy</h2>
<p>Errors get collected during sync and displayed after completion. But I intentionally don't show a modal or toast for sync errors.</p>
<p>Why? Sync failures are usually recoverable. File temporarily locked, network hiccup, permission issue that resolves itself. They'll fix on the next sync. Intrusive error UX trains users to ignore notifications.</p>
<p>Instead: the indicator turns amber, the dropdown shows an error count, curious users can expand to see details. I might change this behavior in the future, but for debugging purposes it works pretty well. A resync usually fixes everything.</p>
<hr />
<h2 id="heading-key-takeaways">Key Takeaways</h2>
<ol>
<li><p><strong>Model sync as a state machine.</strong> Explicit states with defined transitions make the logic predictable, debuggable, and testable.</p>
</li>
<li><p><strong>Use transient states for ephemeral activity.</strong> The <code>watching</code> state with auto-timeout prevents flicker during rapid changes.</p>
</li>
<li><p><strong>Abstract platform differences early.</strong> A unified event system means components work identically in Tauri and web modes.</p>
</li>
<li><p><strong>CSS visibility over conditional rendering for animations.</strong> Keeps animations smooth across state transitions.</p>
</li>
<li><p><strong>Match error UX to error severity.</strong> Not every error needs a modal. Background errors deserve subtle indicators.</p>
</li>
<li><p><strong>Check state on mount.</strong> Events may fire before your component mounts. Always synchronize.</p>
</li>
</ol>
<hr />
<p><em>Building Aethas in public. Follow along at</em> <a target="_blank" href="https://aethas.ai"><em>aethas.ai</em></a></p>
]]></content:encoded></item><item><title><![CDATA[Building a File Upload System for an AI Assistant]]></title><description><![CDATA[Aethas indexes Obsidian vaults and other sources, making that content searchable via RAG. Users can @mention files from their indexed sources. But what about that PDF someone just sent you? Or a code file from a different project?
The friction: copy ...]]></description><link>https://blog.aethas.ai/building-a-file-upload-system-for-an-ai-assistant</link><guid isPermaLink="true">https://blog.aethas.ai/building-a-file-upload-system-for-an-ai-assistant</guid><category><![CDATA[React]]></category><category><![CDATA[Build In Public]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[AI]]></category><category><![CDATA[#ai-tools]]></category><dc:creator><![CDATA[Stephen Ashmore]]></dc:creator><pubDate>Thu, 08 Jan 2026 15:00:36 GMT</pubDate><content:encoded><![CDATA[<p>Aethas indexes Obsidian vaults and other sources, making that content searchable via RAG. Users can @mention files from their indexed sources. But what about that PDF someone just sent you? Or a code file from a different project?</p>
<p>The friction: copy file into indexed source, wait for indexing, then @mention it. We wanted: drop file into chat, ask your question.</p>
<p>This post walks through how we built the drag-and-drop file upload system.</p>
<hr />
<h2 id="heading-architecture">Architecture</h2>
<p>The system has three layers:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1767939208277/2f546c7e-3ebd-47cf-a1af-c70990d73c22.png" alt class="image--center mx-auto" /></p>
<hr />
<h2 id="heading-frontend-the-drop-zone">Frontend: The Drop Zone</h2>
<p>The heart of the UX is <code>FileDropZone.tsx</code>, a wrapper component that makes any area file-droppable. The component handles drag events, validates files, reads their contents, and adds them to state.</p>
<p>The interesting bits are in the details.</p>
<h3 id="heading-file-type-detection">File Type Detection</h3>
<p>We support text-based files using both extension and MIME type detection:</p>
<pre><code class="lang-typescript"><span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">isFileSupported</span>(<span class="hljs-params">file: File</span>): <span class="hljs-title">boolean</span> </span>{
  <span class="hljs-comment">// Check extension first (more reliable)</span>
  <span class="hljs-keyword">const</span> extension = getFileExtension(file.name);
  <span class="hljs-keyword">if</span> (extension &amp;&amp; SUPPORTED_EXTENSIONS.includes(extension)) {
    <span class="hljs-keyword">return</span> <span class="hljs-literal">true</span>;
  }
  <span class="hljs-comment">// Fall back to MIME type</span>
  <span class="hljs-keyword">if</span> (SUPPORTED_MIME_TYPES.includes(file.type)) {
    <span class="hljs-keyword">return</span> <span class="hljs-literal">true</span>;
  }
  <span class="hljs-comment">// Catch-all for anything text-like</span>
  <span class="hljs-keyword">if</span> (file.type.startsWith(<span class="hljs-string">'text/'</span>)) {
    <span class="hljs-keyword">return</span> <span class="hljs-literal">true</span>;
  }
  <span class="hljs-keyword">return</span> <span class="hljs-literal">false</span>;
}
</code></pre>
<p>Why the dual approach? Different browsers report different MIME types for the same file. A <code>.ts</code> file might be <code>application/typescript</code>, <code>text/typescript</code>, or empty depending on the browser and OS. Extension checking is more reliable, MIME types are the fallback, and <code>text/*</code> is the safety net.</p>
<h3 id="heading-visual-feedback">Visual Feedback</h3>
<p>Users see attached files through <code>UploadedFilesChips</code> including data such as file type emoji, name (truncated if long), size in human-readable format, and a remove button. Small thing, but immediate visual feedback makes drag-and-drop feel responsive.</p>
<div class="embed-wrapper"><div class="embed-loading"><div class="loadingRow"></div><div class="loadingRow"></div></div><a class="embed-card" href="https://youtu.be/YJl7LIA3ovw">https://youtu.be/YJl7LIA3ovw</a></div>
<p> </p>
<hr />
<h2 id="heading-state-management">State Management</h2>
<p>Uploaded files live in the chat store, separate from @mentioned files:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">interface</span> ChatState {
  <span class="hljs-comment">// @mentioned files - reference indexed content by ID</span>
  selectedFiles: SelectedFile[];

  <span class="hljs-comment">// Dropped files - ephemeral, contain full content</span>
  uploadedFiles: UploadedFile[];
}
</code></pre>
<p>The key distinction: selected files reference indexed content (we look up content via RAG), while uploaded files contain full content in memory. They're ephemeral, not persisted anywhere, just context for this conversation.</p>
<p>We enforce a combined limit of 5 files across both types. Users might @mention some indexed files and upload others in the same message, so the limit applies to the total.</p>
<hr />
<h2 id="heading-server-assembling-context">Server: Assembling Context</h2>
<p>When a user sends a message, the frontend sends both the message and any attached files. The server receives these and needs to build a prompt for Claude.</p>
<p>For now, we're keeping it simple: uploaded file contents get added directly to the prompt alongside the user's message. The format looks like:</p>
<pre><code class="lang-markdown"><span class="hljs-section">## Files Attached by User</span>

The user attached these files directly to this message:

<span class="hljs-section">### config.yaml</span>
yaml
[file contents here]


<span class="hljs-section">### utils.ts</span>
typescript
[file contents here]

---

[User's actual message here]
</code></pre>
<p>This works, but it's naive. We're not managing token budgets, not prioritizing content, not handling the case where files are too large. The files just get concatenated.</p>
<p>In a future post on Context Architecture, we'll revisit this and show how uploaded files fit into a larger system, one that manages RAG results, conversation history, tool outputs, and token budgets to prevent context rot. But that's getting ahead of ourselves. For now, concatenation gets us to a working feature.</p>
<hr />
<h2 id="heading-challenges-we-solved">Challenges We Solved</h2>
<h3 id="heading-drag-event-bubbling">Drag Event Bubbling</h3>
<p>HTML drag events bubble in unexpected ways. Dragging over a child element fires <code>dragLeave</code> on the parent even though you're still inside the drop zone. This causes the overlay to flicker as you move the cursor.</p>
<p>The fix: check if the event's <code>relatedTarget</code> is still inside the container before clearing the dragging state.</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">const</span> handleDragLeave = useCallback(<span class="hljs-function">(<span class="hljs-params">e: DragEvent&lt;HTMLDivElement&gt;</span>) =&gt;</span> {
  e.preventDefault();
  e.stopPropagation();

  <span class="hljs-comment">// Only clear if actually leaving the zone, not entering a child</span>
  <span class="hljs-keyword">if</span> (e.relatedTarget &amp;&amp; e.currentTarget.contains(e.relatedTarget <span class="hljs-keyword">as</span> Node)) {
    <span class="hljs-keyword">return</span>;
  }
  setIsDragging(<span class="hljs-literal">false</span>);
}, []);
</code></pre>
<p>Small fix, but without it the UX feels broken.</p>
<h3 id="heading-file-reading-errors">File Reading Errors</h3>
<p>Not all files read cleanly as text. Binary files, encoding issues, permission problems. We handle errors gracefully and still show the file so users know which one failed:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">try</span> {
  <span class="hljs-keyword">const</span> content = <span class="hljs-keyword">await</span> readFileContent(file);
  addUploadedFile({ ...file, content, status: <span class="hljs-string">'ready'</span> });
} <span class="hljs-keyword">catch</span> (error) {
  addUploadedFile({ ...file, content: <span class="hljs-string">''</span>, status: <span class="hljs-string">'error'</span>, error: error.message });
}
</code></pre>
<p>The file chip shows an error state. Users can remove it and try a different file.</p>
<h3 id="heading-size-limits">Size Limits</h3>
<p>We enforce a 50KB per-file limit client-side. Large files would blow up the context window and provide diminishing returns anyway. If you're uploading a 500KB log file, you probably want to search it, not stuff it into a prompt. In future blog posts, we will be handling large files differently.</p>
<p>The limit is generous enough for code files and notes, restrictive enough to prevent accidents.</p>
<hr />
<h2 id="heading-key-takeaways">Key Takeaways</h2>
<ol>
<li><p><strong>Keep upload state ephemeral.</strong> Uploaded files don't need permanent storage. They're context for this conversation only.</p>
</li>
<li><p><strong>Validate client-side, format server-side.</strong> Client validation for UX (immediate feedback), server formatting for consistent prompt structure.</p>
</li>
<li><p><strong>Visual feedback matters.</strong> The drop overlay, file chips, and error toasts make drag-and-drop feel responsive. Without them, users don't trust it.</p>
</li>
<li><p><strong>Browser inconsistencies are real.</strong> MIME type detection, drag event bubbling — test across browsers or you'll get bug reports.</p>
</li>
<li><p><strong>Start simple, refine later.</strong> Concatenating files into the prompt isn't sophisticated, but it works. We'll build proper token budgeting when we tackle Context Architecture.</p>
</li>
</ol>
<hr />
<p><em>Building Aethas in public. Follow along at</em> <a target="_blank" href="https://blog.aethas.ai"><em>blog.aethas.ai</em></a></p>
]]></content:encoded></item><item><title><![CDATA[Building the AI Assistant I Always Wanted]]></title><description><![CDATA[Back in 1996, when I was just six years old, I watched Star Trek for the first time. I can remember sitting on the floor of my father's study in front of his cathode-ray television. We watched all of the Star Trek TV shows and films that had come out...]]></description><link>https://blog.aethas.ai/building-the-ai-assistant-i-always-wanted</link><guid isPermaLink="true">https://blog.aethas.ai/building-the-ai-assistant-i-always-wanted</guid><category><![CDATA[AI]]></category><category><![CDATA[Rust]]></category><category><![CDATA[obsidian]]></category><category><![CDATA[buildinpublic]]></category><category><![CDATA[side project]]></category><category><![CDATA[Tauri]]></category><dc:creator><![CDATA[Stephen Ashmore]]></dc:creator><pubDate>Sun, 04 Jan 2026 02:12:07 GMT</pubDate><content:encoded><![CDATA[<p>Back in 1996, when I was just six years old, I watched Star Trek for the first time. I can remember sitting on the floor of my father's study in front of his cathode-ray television. We watched all of the Star Trek TV shows and films that had come out over the years, but I think my first enthrallment with the world was The Next Generation. I vividly remember watching Data and Geordi work technological miracles to save the Enterprise in countless episodes. Imagining what it would be like to have dinner with Captain Picard and ask him what it was like to be Captain of the Enterprise. But behind all of those characters and stories, there was one singular constant: the computer.</p>
<p>The computer of 1996 was nothing like the computer of the Enterprise. My father and I used to play Doom cooperatively at his work over the local-area network. Some days, I went to work with him and spent time away in the data entry room. After the office closed and everyone had left, we would race rolling office chairs back and forth while Doom installed, and then try to beat levels together until my mom arrived to eat dinner with us.</p>
<p>It was mesmerizing to see what the future might hold for humans in Star Trek. The computer of the Enterprise could not only navigate the ship but it could run an entire holodeck. It seemed to know everything that the characters knew and could respond whenever they needed it to. As I grew up and became more fascinated by computers, I saw how far off Gene Roddenberry's vision was from what we actually had.</p>
<p>Thirty years later, the world has dramatically shifted. Large language models have flooded the world with new capabilities and dangers. We may finally be on the cusp of a system that can be as useful as Jarvis from Iron Man or the Enterprise's computer.</p>
<hr />
<h2 id="heading-the-problem">The Problem</h2>
<p>I'm not one for idle hands. So when I found myself with a free two weeks of holiday time from my day job, I turned my attention to planning my 2026. The process was difficult. I had notes scattered in journals, my Obsidian vault, online, emails, chats, slack, everywhere.</p>
<p>I immediately thought about having Claude or ChatGPT try to parse through all my documents and get the context, but it wasn't quite that simple. For a long time, I've needed an executive assistant to help with all the product work, project management, and other parts of my day job. I needed something proactive though. A tool that could remind me, be autonomous, and know everything I needed to know.</p>
<p>I needed Tony Stark's Jarvis.</p>
<hr />
<h2 id="heading-so-i-started-building-it">So I Started Building It</h2>
<p>I named it <strong>Aethas</strong>, after one of my favorite Dungeons &amp; Dragons characters that I've played. Aethas was a fighter, but not without intellect. He was a tactician, prepared for every contingency, and carried multiple weapons designed to fell any enemy he came across. A system or AI that could do the crazy things that Jarvis could do would need to be equally well-prepared.</p>
<p>Here's where I got after about a week of work over the holidays.</p>
<hr />
<h2 id="heading-whats-working">What's Working</h2>
<p>To start, I revamped my Obsidian vault. I added new projects, archived old notes, and generally consolidated some of my disparate ideas. Then I built an app that could actually <em>use</em> all of that context.</p>
<p><strong>The core loop works like this:</strong></p>
<ol>
<li><p>Point Aethas at your Obsidian vault (or any folder of markdown files)</p>
</li>
<li><p>It indexes everything locally: parsing documents, chunking them intelligently, and generating embeddings</p>
</li>
<li><p>When you ask a question, it searches your knowledge base semantically</p>
</li>
<li><p>Relevant documents get injected into the conversation as context</p>
</li>
<li><p>The AI responds with actual knowledge of your notes</p>
</li>
</ol>
<div class="embed-wrapper"><div class="embed-loading"><div class="loadingRow"></div><div class="loadingRow"></div></div><a class="embed-card" href="https://youtu.be/xoO6KlQe9GA">https://youtu.be/xoO6KlQe9GA</a></div>
<p> </p>
<p>The UI shows matching notes with relevance scores. You can see which files the AI is drawing from and manually pin additional context using <code>@mentions</code>, similar to how Claude's file references work.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1767492406478/e78a337f-3db9-4bb7-8ab5-186e0ee6b13b.png" alt class="image--center mx-auto" /></p>
<p>I can ask things like <em>"What about the bugs we were looking at? Did we manage to fix the Lost Ability to Delete Chats issue?"</em> and Aethas pulls in the relevant files, shows me what it found, and gives me an answer grounded in my actual notes.</p>
<hr />
<h2 id="heading-the-technical-bits">The Technical Bits</h2>
<p>I'll write more detailed technical posts later, but here's the high-level stack:</p>
<p><strong>Desktop app built with Tauri 2.0</strong>: I wanted to try Rust for something real, and Tauri gives me a lightweight desktop app with a React frontend. The whole thing is under 20MB. I also chose Tauri because I want offline capability eventually, with my Obsidian vault staying local to my machine.</p>
<p><strong>Local embeddings</strong>: All the vector search happens on-device using a small embedding model. No API calls for indexing, which means it's fast and your notes never leave your machine.</p>
<p><strong>OpenRouter for LLM access</strong>: For LLMs, I hooked it up to OpenRouter. Mainly because I had $30 of credit still on my account. This way though I can switch models if I want to.</p>
<p><strong>SQLite for everything</strong>: I chose SQLite for simple speed. I’ve been putting everything in it including the conversations, indexed documents, and embeddings.</p>
<p>The interesting part is the context injection. When you send a message, Aethas:</p>
<ol>
<li><p>Searches your indexed vault semantically</p>
</li>
<li><p>Deduplicates to get the most relevant <em>files</em> (not just chunks)</p>
</li>
<li><p>Injects the full document content into the system prompt</p>
</li>
<li><p>Streams the response back in real-time</p>
</li>
</ol>
<p>You can also explicitly reference files with <code>@filename</code>, which pins them into context with maximum priority. This is useful when you know exactly what you want to discuss.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1767492467199/06690e35-6484-4165-9a66-6c0054ab9bce.png" alt class="image--center mx-auto" /></p>
<hr />
<h2 id="heading-whats-next">What's Next</h2>
<p>This is just the foundation. The vision for Aethas is an AI that can actually <em>act</em> on your behalf. I need Aethas to draft emails, create calendar events, file tickets, but only with my approval before execution. I’m going to be focusing on the first of these actions soon.</p>
<p>I'm also thinking about proactive behavior: an assistant that notices you have a meeting in 30 minutes and surfaces relevant context without being asked. Or one that detects you have free time and asks if you want to review your drafted actions.</p>
<p>But that's future work. For now, I have an AI that finally knows what I know, and that alone is already useful. Alongside centralizing my notes into my Obsidian vault, I’m expanding Aethas’ storage and ingestion integrations. I want to add Google Drive and Slack as inputs to Aethas, so it can search my documents and slack similar to other local LLM systems.</p>
<hr />
<h2 id="heading-follow-along">Follow Along</h2>
<p>I'm building Aethas in public. I'll be posting updates here as I ship new features, make architectural decisions, and inevitably break things.</p>
<p>If you want to follow along:</p>
<ul>
<li><p>Subscribe to this blog (button below)</p>
</li>
<li><p>Follow me on Twitter: [@_StephenAshmore]</p>
</li>
</ul>
<p>The code isn't public yet, but it might be eventually. We'll see.</p>
<hr />
<p><em>This is post #1 of building Aethas. Next up: we may focus on the action system and draft-approve-execute flow or the finer details of the context system.</em></p>
]]></content:encoded></item></channel></rss>