Last active
July 15, 2026 00:56
-
-
Save ericboehs/6b20ebf83ff2e7dfb71cec785dc2610f to your computer and use it in GitHub Desktop.
Claude Browser — single-file Ruby Sinatra app to browse, search, and view Claude Code context surfaces: auto-memory markdown under ~/.claude/projects/*/memory/ and skills under ~/.claude/skills/, with always-loaded token cost estimates
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env ruby | |
| # frozen_string_literal: true | |
| # Claude Browser — browse, search, and view Claude Code context surfaces: | |
| # the auto-memory markdown files under ~/.claude/projects/*/memory/ and the | |
| # user skills under ~/.claude/skills/*/SKILL.md | |
| require 'bundler/inline' | |
| gemfile do | |
| source 'https://rubygems.org' | |
| gem 'sinatra' | |
| gem 'puma' | |
| gem 'rackup' | |
| gem 'redcarpet' | |
| end | |
| require 'sinatra' | |
| require 'redcarpet' | |
| require 'fileutils' | |
| MEMORY_ROOT = File.expand_path('~/.claude/projects') | |
| SKILLS_ROOT = File.expand_path('~/.claude/skills') | |
| END_OF_FILE = defined?(DATA) ? DATA.pos.freeze : 0 | |
| MARKDOWN = Redcarpet::Markdown.new( | |
| Redcarpet::Render::HTML.new(hard_wrap: false), | |
| autolink: true, | |
| tables: true, | |
| fenced_code_blocks: true, | |
| strikethrough: true, | |
| space_after_headers: true, | |
| no_intra_emphasis: true | |
| ) | |
| def load_templates | |
| return @templates if @templates | |
| DATA.pos = END_OF_FILE | |
| raw = DATA.read.force_encoding('UTF-8') | |
| parts = raw.split(/^@@(\w+)\s*\n/) | |
| parts.shift # leading empty string before first @@ | |
| @templates = {} | |
| parts.each_slice(2) { |name, body| @templates[name] = body } | |
| @templates | |
| end | |
| def template(name) | |
| load_templates.fetch(name.to_s) { raise "Unknown template: #{name}" } | |
| end | |
| helpers do | |
| def h(text) | |
| Rack::Utils.escape_html(text.to_s) | |
| end | |
| def render_page(name, locals = {}) | |
| locals.each { |k, v| instance_variable_set("@#{k}", v) } | |
| @_title = locals[:title] || 'Claude Browser' | |
| @_content = ERB.new(template(name)).result(binding) | |
| ERB.new(template('layout')).result(binding) | |
| end | |
| def md(text, link_prefix: nil) | |
| html = MARKDOWN.render(text.to_s) | |
| html.gsub(/href="([^"]+)"/) do |orig| | |
| url = Regexp.last_match(1) | |
| if url.start_with?('http://', 'https://') | |
| # External links open in a new tab; internal ones stay in-tab. | |
| %(href="#{url}" target="_blank" rel="noopener") | |
| elsif link_prefix.nil? || url.start_with?('/', '#', 'mailto:') | |
| orig | |
| else | |
| %(href="#{link_prefix}/#{url}") | |
| end | |
| end | |
| end | |
| def decode_path(encoded) | |
| @_decode_cache ||= {} | |
| @_decode_cache[encoded] ||= resolve_encoded_path(encoded) | |
| end | |
| def encode_name(name) | |
| name.gsub(%r{[/.\s]}, '-') | |
| end | |
| def resolve_encoded_path(encoded) | |
| remaining = encoded.sub(/^-/, '') | |
| current = '/' | |
| until remaining.empty? | |
| children = begin | |
| Dir.children(current).select { |c| File.directory?(File.join(current, c)) } | |
| rescue StandardError | |
| [] | |
| end | |
| best = children | |
| .select { |c| enc = encode_name(c); remaining == enc || remaining.start_with?("#{enc}-") } | |
| .max_by(&:length) | |
| break unless best | |
| current = File.join(current, best) | |
| enc = encode_name(best) | |
| remaining = remaining == enc ? '' : remaining[(enc.length + 1)..] | |
| end | |
| return encoded unless remaining.empty? && File.directory?(current) | |
| home = Dir.home | |
| current == home || current.start_with?("#{home}/") ? current.sub(home, '~') : current | |
| end | |
| def parse_frontmatter(content) | |
| return [{}, content] unless content.start_with?("---\n") | |
| _, fm, body = content.split(/^---\n/, 3) | |
| meta = {} | |
| # Accept both top-level keys and keys nested one level under a parent | |
| # like `metadata:` (the newer memory format), folding them into one flat | |
| # hash. Parent keys (empty value, e.g. `metadata:`) are skipped. | |
| fm.to_s.each_line do |line| | |
| next unless line =~ /^\s*([\w-]+):\s*(.*)$/ | |
| key = Regexp.last_match(1) | |
| value = Regexp.last_match(2).strip | |
| meta[key] = value unless value.empty? | |
| end | |
| [meta, body.to_s] | |
| end | |
| # Rough always-loaded cost of a skill: only the frontmatter `description` | |
| # is kept in context for matching (the body loads on demand). ~2.3 bytes | |
| # per token for Eric's prose/path-heavy content. | |
| def est_tokens(text) | |
| (text.to_s.bytesize / 2.3).round | |
| end | |
| def skills | |
| return [] unless File.directory?(SKILLS_ROOT) | |
| Dir.children(SKILLS_ROOT).sort.filter_map do |slug| | |
| md_path = File.join(SKILLS_ROOT, slug, 'SKILL.md') | |
| next unless File.file?(md_path) | |
| content = File.read(md_path) | |
| meta, body = parse_frontmatter(content) | |
| { | |
| slug: slug, | |
| name: meta['name'] || slug, | |
| description: meta['description'].to_s, | |
| allowed_tools: meta['allowed-tools'].to_s, | |
| path: md_path, | |
| mtime: File.mtime(md_path), | |
| content: content, | |
| body: body, | |
| meta: meta, | |
| tokens: est_tokens(meta['description']) | |
| } | |
| end.sort_by { |s| s[:name].downcase } | |
| end | |
| def type_color(type) | |
| { | |
| 'user' => 'bg-blue-500/15 text-blue-300 ring-blue-500/30', | |
| 'feedback' => 'bg-amber-500/15 text-amber-300 ring-amber-500/30', | |
| 'project' => 'bg-emerald-500/15 text-emerald-300 ring-emerald-500/30', | |
| 'reference' => 'bg-violet-500/15 text-violet-300 ring-violet-500/30' | |
| }[type.to_s] || 'bg-zinc-500/15 text-zinc-300 ring-zinc-500/30' | |
| end | |
| def relative_time(time) | |
| secs = Time.now - time | |
| case secs | |
| when 0..60 then 'just now' | |
| when 60..3600 then "#{(secs / 60).to_i}m ago" | |
| when 3600..86_400 then "#{(secs / 3600).to_i}h ago" | |
| when 86_400..(86_400 * 30) then "#{(secs / 86_400).to_i}d ago" | |
| else time.strftime('%Y-%m-%d') | |
| end | |
| end | |
| def project_summary(encoded) | |
| dir = File.join(MEMORY_ROOT, encoded, 'memory') | |
| files = Dir.glob("#{dir}/*.md") | |
| return nil if files.empty? | |
| memories = files.reject { |f| File.basename(f) == 'MEMORY.md' } | |
| mtime = files.map { |f| File.mtime(f) }.max | |
| { | |
| encoded: encoded, | |
| decoded: decode_path(encoded), | |
| count: memories.size, | |
| has_index: files.any? { |f| File.basename(f) == 'MEMORY.md' }, | |
| mtime: mtime | |
| } | |
| end | |
| def projects | |
| Dir.children(MEMORY_ROOT).sort.filter_map do |entry| | |
| next unless File.directory?(File.join(MEMORY_ROOT, entry, 'memory')) | |
| project_summary(entry) | |
| end.compact.sort_by { |p| -p[:mtime].to_i } | |
| end | |
| def project_files(encoded) | |
| dir = File.join(MEMORY_ROOT, encoded, 'memory') | |
| return [] unless File.directory?(dir) | |
| Dir.glob("#{dir}/*.md").sort.map do |path| | |
| content = File.read(path) | |
| meta, _body = parse_frontmatter(content) | |
| { | |
| name: File.basename(path), | |
| path: path, | |
| mtime: File.mtime(path), | |
| content: content, | |
| meta: meta | |
| } | |
| end | |
| end | |
| def extract_preview(content, query) | |
| idx = content.downcase.index(query.downcase) | |
| return content[0, 200].to_s unless idx | |
| start = [0, idx - 80].max | |
| stop = [content.length, idx + 200].min | |
| prefix = start.positive? ? '…' : '' | |
| suffix = stop < content.length ? '…' : '' | |
| "#{prefix}#{content[start...stop]}#{suffix}" | |
| end | |
| def highlight(text, query) | |
| return h(text) if query.to_s.empty? | |
| escaped = h(text) | |
| pattern = Regexp.new(Regexp.escape(h(query)), Regexp::IGNORECASE) | |
| escaped.gsub(pattern) { |m| "<mark class=\"bg-yellow-400/40 text-yellow-100 rounded px-0.5\">#{m}</mark>" } | |
| end | |
| end | |
| set :environment, ENV['RACK_ENV'] || :production | |
| set :bind, '127.0.0.1' | |
| set :port, ENV['PORT'] || 4823 | |
| set :protection, host_authorization: { permitted_hosts: [] } | |
| get '/' do | |
| @projects = projects | |
| render_page :index, projects: @projects, title: 'Claude Browser' | |
| end | |
| get '/p/:encoded' do | |
| encoded = params[:encoded] | |
| summary = project_summary(encoded) | |
| halt 404, 'project not found' unless summary | |
| files = project_files(encoded) | |
| index_file = files.find { |f| f[:name] == 'MEMORY.md' } | |
| memories = files.reject { |f| f[:name] == 'MEMORY.md' } | |
| render_page :project, | |
| encoded: encoded, | |
| summary: summary, | |
| index_file: index_file, | |
| memories: memories, | |
| title: summary[:decoded] | |
| end | |
| get '/p/:encoded/:file' do | |
| encoded = params[:encoded] | |
| file = params[:file] | |
| halt 400, 'bad filename' if file.include?('/') || file.include?('..') | |
| path = File.join(MEMORY_ROOT, encoded, 'memory', file) | |
| halt 404, 'not found' unless File.exist?(path) && path.start_with?(MEMORY_ROOT) | |
| content = File.read(path) | |
| meta, body = parse_frontmatter(content) | |
| render_page :file, | |
| encoded: encoded, | |
| decoded: decode_path(encoded), | |
| file: file, | |
| path: path, | |
| mtime: File.mtime(path), | |
| meta: meta, | |
| body: body, | |
| raw: content, | |
| title: meta['name'] || file | |
| end | |
| post '/p/:encoded/:file/delete' do | |
| encoded = params[:encoded] | |
| file = params[:file] | |
| halt 400, 'bad filename' if file.include?('/') || file.include?('..') | |
| halt 400, 'refusing to delete index' if file == 'MEMORY.md' | |
| path = File.join(MEMORY_ROOT, encoded, 'memory', file) | |
| halt 404, 'not found' unless File.exist?(path) && path.start_with?(MEMORY_ROOT) | |
| File.delete(path) | |
| # Keep MEMORY.md in sync: drop any index line linking to the deleted file. | |
| index = File.join(MEMORY_ROOT, encoded, 'memory', 'MEMORY.md') | |
| if File.file?(index) | |
| lines = File.readlines(index) | |
| kept = lines.reject { |line| line.include?("](#{file})") } | |
| File.write(index, kept.join) if kept.size != lines.size | |
| end | |
| redirect "/p/#{encoded}" | |
| end | |
| get '/skills' do | |
| all = skills | |
| render_page :skills, | |
| skills: all, | |
| total_tokens: all.sum { |s| s[:tokens] }, | |
| title: 'Skills' | |
| end | |
| get '/skills/:slug' do | |
| slug = params[:slug] | |
| halt 400, 'bad slug' if slug.include?('/') || slug.include?('..') | |
| path = File.join(SKILLS_ROOT, slug, 'SKILL.md') | |
| halt 404, 'not found' unless File.file?(path) && path.start_with?(SKILLS_ROOT) | |
| content = File.read(path) | |
| meta, body = parse_frontmatter(content) | |
| render_page :skill, | |
| slug: slug, | |
| path: path, | |
| mtime: File.mtime(path), | |
| meta: meta, | |
| body: body, | |
| raw: content, | |
| tokens: est_tokens(meta['description']), | |
| title: meta['name'] || slug | |
| end | |
| post '/skills/:slug/delete' do | |
| slug = params[:slug] | |
| halt 400, 'bad slug' if slug.include?('/') || slug.include?('..') | |
| dir = File.join(SKILLS_ROOT, slug) | |
| halt 404, 'not found' unless File.directory?(dir) && dir.start_with?("#{SKILLS_ROOT}/") | |
| FileUtils.rm_rf(dir) | |
| redirect '/skills' | |
| end | |
| get '/search' do | |
| query = params[:q].to_s.strip | |
| results = [] | |
| if query.length >= 2 | |
| Dir.glob("#{MEMORY_ROOT}/*/memory/*.md").each do |path| | |
| content = File.read(path) | |
| next unless content.match?(/#{Regexp.escape(query)}/i) | |
| encoded = File.basename(File.dirname(File.dirname(path))) | |
| results << { | |
| encoded: encoded, | |
| decoded: decode_path(encoded), | |
| name: File.basename(path), | |
| path: path, | |
| preview: extract_preview(content, query), | |
| mtime: File.mtime(path) | |
| } | |
| end | |
| results.sort_by! { |r| -r[:mtime].to_i } | |
| end | |
| render_page :search, query: query, results: results, title: "Search: #{query}" | |
| end | |
| Sinatra::Application.run! | |
| __END__ | |
| @@layout | |
| <!DOCTYPE html> | |
| <html lang="en" class="h-full"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title><%= h(@_title) %></title> | |
| <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%23d97706'%3E%3Cpath d='M10 2a6 6 0 00-6 6c0 1.887.89 3.56 2.27 4.63L6 18l4-2 4 2-.27-5.37A5.99 5.99 0 0016 8a6 6 0 00-6-6z'/%3E%3C/svg%3E" type="image/svg+xml"> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <style type="text/tailwindcss"> | |
| @layer base { | |
| .prose-memory h1 { @apply text-2xl font-semibold text-zinc-100 mt-6 mb-3; } | |
| .prose-memory h2 { @apply text-xl font-semibold text-zinc-100 mt-5 mb-2; } | |
| .prose-memory h3 { @apply text-lg font-semibold text-zinc-200 mt-4 mb-2; } | |
| .prose-memory p { @apply text-zinc-300 leading-relaxed my-3; } | |
| .prose-memory a { @apply text-amber-400 hover:text-amber-300 underline underline-offset-2; } | |
| .prose-memory ul { @apply list-disc list-outside ml-6 my-3 space-y-1 text-zinc-300; } | |
| .prose-memory ol { @apply list-decimal list-outside ml-6 my-3 space-y-1 text-zinc-300; } | |
| .prose-memory li { @apply text-zinc-300; } | |
| .prose-memory code { @apply bg-zinc-800 text-amber-200 px-1.5 py-0.5 rounded text-sm; } | |
| .prose-memory pre { @apply bg-zinc-900/70 border border-zinc-800 rounded-lg p-4 my-4 overflow-x-auto text-sm; } | |
| .prose-memory pre code { @apply bg-transparent p-0 text-zinc-200; } | |
| .prose-memory blockquote { @apply border-l-2 border-amber-500/40 pl-4 italic text-zinc-400 my-4; } | |
| .prose-memory hr { @apply border-zinc-800 my-6; } | |
| .prose-memory strong { @apply text-zinc-100 font-semibold; } | |
| .prose-memory table { @apply w-full border-collapse my-4; } | |
| .prose-memory th { @apply text-left text-zinc-200 font-semibold border-b border-zinc-700 px-3 py-2; } | |
| .prose-memory td { @apply border-b border-zinc-800 px-3 py-2 text-zinc-300; } | |
| } | |
| </style> | |
| </head> | |
| <body class="h-full bg-zinc-950 text-zinc-100 antialiased"> | |
| <header class="sticky top-0 z-10 border-b border-zinc-800 bg-zinc-950/80 backdrop-blur"> | |
| <div class="max-w-5xl mx-auto px-4 py-3 flex items-center gap-4"> | |
| <a href="/" class="flex items-center gap-2 font-semibold text-amber-400 hover:text-amber-300 shrink-0"> | |
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5"><path d="M10 2a6 6 0 00-6 6c0 1.887.89 3.56 2.27 4.63L6 18l4-2 4 2-.27-5.37A5.99 5.99 0 0016 8a6 6 0 00-6-6z"/></svg> | |
| Claude Browser | |
| </a> | |
| <% skills_active = request.path_info.start_with?('/skills') %> | |
| <nav class="flex items-center gap-1 rounded-lg border border-zinc-800 bg-zinc-900/60 p-0.5 text-sm shrink-0"> | |
| <a href="/" class="px-3 py-1 rounded-md <%= skills_active ? 'text-zinc-400 hover:text-zinc-200' : 'bg-zinc-800 text-zinc-100' %>">Memories</a> | |
| <a href="/skills" class="px-3 py-1 rounded-md <%= skills_active ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:text-zinc-200' %>">Skills</a> | |
| </nav> | |
| <form action="/search" method="get" class="flex-1 max-w-lg ml-auto"> | |
| <div class="relative"> | |
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4 absolute left-3 top-2.5 text-zinc-500"><path fill-rule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clip-rule="evenodd"/></svg> | |
| <input type="search" name="q" value="<%= h(@query) %>" placeholder="Search memories…" autocomplete="off" | |
| class="w-full bg-zinc-900 border border-zinc-800 rounded-lg pl-9 pr-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-500/40 focus:border-amber-500/40"> | |
| </div> | |
| </form> | |
| </div> | |
| </header> | |
| <main class="max-w-5xl mx-auto px-4 py-6"> | |
| <%= @_content %> | |
| </main> | |
| <footer class="max-w-5xl mx-auto px-4 py-8 text-xs text-zinc-600"> | |
| <code class="text-zinc-500"><%= h(MEMORY_ROOT.sub(Dir.home, '~')) %></code> | |
| </footer> | |
| </body> | |
| </html> | |
| @@index | |
| <div class="mb-6"> | |
| <h1 class="text-2xl font-semibold text-zinc-100">Projects with memory</h1> | |
| <p class="text-sm text-zinc-400 mt-1"><%= @projects.size %> project<%= 's' if @projects.size != 1 %> · sorted by most recently updated</p> | |
| </div> | |
| <% if @projects.empty? %> | |
| <div class="text-zinc-400 text-sm border border-zinc-800 rounded-lg p-6">No memory directories found under <code><%= h(MEMORY_ROOT) %></code>.</div> | |
| <% else %> | |
| <ul class="space-y-2"> | |
| <% @projects.each do |p| %> | |
| <li> | |
| <a href="/p/<%= h(p[:encoded]) %>" class="block rounded-lg border border-zinc-800 bg-zinc-900/40 hover:bg-zinc-900 hover:border-zinc-700 p-4 transition"> | |
| <div class="flex items-center justify-between gap-4"> | |
| <div class="min-w-0 flex-1"> | |
| <div class="font-medium text-zinc-100 truncate"><%= h(p[:decoded]) %></div> | |
| <div class="text-xs text-zinc-500 mt-0.5 truncate font-mono"><%= h(p[:encoded]) %></div> | |
| </div> | |
| <div class="flex items-center gap-3 text-xs text-zinc-400 shrink-0"> | |
| <span class="tabular-nums"><%= p[:count] %> memor<%= p[:count] == 1 ? 'y' : 'ies' %></span> | |
| <% unless p[:has_index] %><span class="text-zinc-600">no index</span><% end %> | |
| <span class="tabular-nums text-zinc-500"><%= relative_time(p[:mtime]) %></span> | |
| </div> | |
| </div> | |
| </a> | |
| </li> | |
| <% end %> | |
| </ul> | |
| <% end %> | |
| @@project | |
| <nav class="text-xs text-zinc-500 mb-4"><a href="/" class="hover:text-zinc-300">← All projects</a></nav> | |
| <div class="mb-6"> | |
| <h1 class="text-2xl font-semibold text-zinc-100 break-all"><%= h(@summary[:decoded]) %></h1> | |
| <div class="text-xs text-zinc-500 mt-1 font-mono break-all"><%= h(@summary[:encoded]) %></div> | |
| </div> | |
| <% if @index_file %> | |
| <section class="mb-8 rounded-lg border border-zinc-800 bg-zinc-900/40 p-5"> | |
| <div class="flex items-center justify-between mb-2"> | |
| <h2 class="text-sm font-semibold text-zinc-300 uppercase tracking-wide">MEMORY.md</h2> | |
| <a href="/p/<%= h(@encoded) %>/MEMORY.md" class="text-xs text-amber-400 hover:text-amber-300">view raw →</a> | |
| </div> | |
| <div class="prose-memory"><%= md(parse_frontmatter(@index_file[:content])[1], link_prefix: "/p/#{@encoded}") %></div> | |
| </section> | |
| <% end %> | |
| <h2 class="text-sm font-semibold text-zinc-300 uppercase tracking-wide mb-3">Memories (<%= @memories.size %>)</h2> | |
| <% if @memories.empty? %> | |
| <div class="text-zinc-500 text-sm">No individual memory files.</div> | |
| <% else %> | |
| <ul class="space-y-2"> | |
| <% @memories.each do |m| %> | |
| <li> | |
| <a href="/p/<%= h(@encoded) %>/<%= h(m[:name]) %>" class="block rounded-lg border border-zinc-800 bg-zinc-900/40 hover:bg-zinc-900 hover:border-zinc-700 p-4 transition"> | |
| <div class="flex items-start justify-between gap-4"> | |
| <div class="min-w-0 flex-1"> | |
| <div class="flex items-center gap-2 flex-wrap"> | |
| <span class="font-medium text-zinc-100"><%= h(m[:meta]['name'] || m[:name]) %></span> | |
| <% if m[:meta]['type'] %> | |
| <span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset <%= type_color(m[:meta]['type']) %>"><%= h(m[:meta]['type']) %></span> | |
| <% end %> | |
| </div> | |
| <% if m[:meta]['description'] && !m[:meta]['description'].empty? %> | |
| <div class="text-sm text-zinc-400 mt-1"><%= h(m[:meta]['description']) %></div> | |
| <% end %> | |
| <div class="text-xs text-zinc-600 mt-1 font-mono"><%= h(m[:name]) %></div> | |
| </div> | |
| <div class="text-xs text-zinc-500 shrink-0 tabular-nums"><%= relative_time(m[:mtime]) %></div> | |
| </div> | |
| </a> | |
| </li> | |
| <% end %> | |
| </ul> | |
| <% end %> | |
| @@file | |
| <nav class="text-xs text-zinc-500 mb-4"> | |
| <a href="/" class="hover:text-zinc-300">All projects</a> | |
| <span class="mx-1 text-zinc-700">/</span> | |
| <a href="/p/<%= h(@encoded) %>" class="hover:text-zinc-300"><%= h(@decoded) %></a> | |
| </nav> | |
| <div class="mb-6"> | |
| <div class="flex items-center gap-2 flex-wrap"> | |
| <h1 class="text-2xl font-semibold text-zinc-100"><%= h(@meta['name'] || @file) %></h1> | |
| <% if @meta['type'] %> | |
| <span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset <%= type_color(@meta['type']) %>"><%= h(@meta['type']) %></span> | |
| <% end %> | |
| <form method="post" action="/p/<%= h(@encoded) %>/<%= h(@file) %>/delete" | |
| onsubmit="return confirm('Delete this memory?\n\n<%= h(@file) %>\n\nThis permanently removes the file and cannot be undone.');" | |
| class="ml-auto shrink-0"> | |
| <button type="submit" title="Delete this memory" | |
| class="inline-flex items-center gap-1.5 rounded-lg border border-red-900/60 bg-red-950/40 px-3 py-1.5 text-xs font-medium text-red-400 hover:bg-red-900/40 hover:text-red-300 hover:border-red-800 transition"> | |
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4"><path fill-rule="evenodd" d="M8.75 1a1 1 0 00-.95.68L7.42 3H4.5a.75.75 0 000 1.5h.29l.7 10.55A2.25 2.25 0 007.73 17h4.54a2.25 2.25 0 002.24-1.95L15.21 4.5h.29a.75.75 0 000-1.5h-2.92l-.38-1.32A1 1 0 0011.25 1h-2.5zM9 6.75a.75.75 0 00-1.5 0v6.5a.75.75 0 001.5 0v-6.5zm3.5 0a.75.75 0 00-1.5 0v6.5a.75.75 0 001.5 0v-6.5z" clip-rule="evenodd"/></svg> | |
| Delete | |
| </button> | |
| </form> | |
| </div> | |
| <% if @meta['description'] && !@meta['description'].empty? %> | |
| <p class="text-zinc-400 mt-2"><%= h(@meta['description']) %></p> | |
| <% end %> | |
| <div class="flex items-center gap-3 text-xs text-zinc-500 mt-3 font-mono"> | |
| <span><%= h(@file) %></span> | |
| <span>·</span> | |
| <span><%= relative_time(@mtime) %></span> | |
| </div> | |
| </div> | |
| <article class="rounded-lg border border-zinc-800 bg-zinc-900/40 p-6"> | |
| <div class="prose-memory"><%= md(@body, link_prefix: "/p/#{@encoded}") %></div> | |
| </article> | |
| <details class="mt-4"> | |
| <summary class="text-xs text-zinc-500 cursor-pointer hover:text-zinc-300">raw source</summary> | |
| <pre class="mt-2 bg-zinc-900 border border-zinc-800 rounded-lg p-4 overflow-x-auto text-xs text-zinc-300"><%= h(@raw) %></pre> | |
| </details> | |
| <div class="mt-4 text-xs text-zinc-600 font-mono break-all"><%= h(@path.sub(Dir.home, '~')) %></div> | |
| @@search | |
| <div class="mb-6"> | |
| <h1 class="text-2xl font-semibold text-zinc-100">Search</h1> | |
| <% if @query.empty? %> | |
| <p class="text-sm text-zinc-400 mt-1">Type a query in the search box above.</p> | |
| <% else %> | |
| <p class="text-sm text-zinc-400 mt-1"><%= @results.size %> result<%= 's' if @results.size != 1 %> for <span class="text-zinc-200">"<%= h(@query) %>"</span></p> | |
| <% end %> | |
| </div> | |
| <% if @results.any? %> | |
| <ul class="space-y-3"> | |
| <% @results.each do |r| %> | |
| <li> | |
| <a href="/p/<%= h(r[:encoded]) %>/<%= h(r[:name]) %>" class="block rounded-lg border border-zinc-800 bg-zinc-900/40 hover:bg-zinc-900 hover:border-zinc-700 p-4 transition"> | |
| <div class="flex items-center justify-between gap-3 mb-1"> | |
| <div class="font-medium text-zinc-100 truncate"><%= h(r[:name]) %></div> | |
| <span class="text-xs text-zinc-500 shrink-0 tabular-nums"><%= relative_time(r[:mtime]) %></span> | |
| </div> | |
| <div class="text-xs text-zinc-500 truncate mb-2"><%= h(r[:decoded]) %></div> | |
| <div class="text-sm text-zinc-300 leading-relaxed whitespace-pre-wrap break-words"><%= highlight(r[:preview], @query) %></div> | |
| </a> | |
| </li> | |
| <% end %> | |
| </ul> | |
| <% elsif !@query.empty? %> | |
| <div class="text-zinc-500 text-sm">No matches.</div> | |
| <% end %> | |
| @@skills | |
| <div class="mb-6"> | |
| <h1 class="text-2xl font-semibold text-zinc-100">Skills</h1> | |
| <p class="text-sm text-zinc-400 mt-1"> | |
| <%= @skills.size %> user skill<%= 's' if @skills.size != 1 %> | |
| · <span class="tabular-nums text-zinc-300">~<%= @total_tokens %></span> tokens always-loaded | |
| <span class="text-zinc-600">(descriptions only; bodies load on demand)</span> | |
| </p> | |
| </div> | |
| <% if @skills.empty? %> | |
| <div class="text-zinc-400 text-sm border border-zinc-800 rounded-lg p-6">No skills found under <code><%= h(SKILLS_ROOT.sub(Dir.home, '~')) %></code>.</div> | |
| <% else %> | |
| <ul class="space-y-2"> | |
| <% @skills.each do |s| %> | |
| <li> | |
| <a href="/skills/<%= h(s[:slug]) %>" class="block rounded-lg border border-zinc-800 bg-zinc-900/40 hover:bg-zinc-900 hover:border-zinc-700 p-4 transition"> | |
| <div class="flex items-start justify-between gap-4"> | |
| <div class="min-w-0 flex-1"> | |
| <div class="flex items-center gap-2 flex-wrap"> | |
| <span class="font-medium text-zinc-100"><%= h(s[:name]) %></span> | |
| <% unless s[:allowed_tools].empty? %> | |
| <span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset bg-sky-500/15 text-sky-300 ring-sky-500/30">scoped tools</span> | |
| <% end %> | |
| </div> | |
| <% unless s[:description].empty? %> | |
| <div class="text-sm text-zinc-400 mt-1"><%= h(s[:description]) %></div> | |
| <% end %> | |
| <div class="text-xs text-zinc-600 mt-1 font-mono"><%= h(s[:slug]) %>/SKILL.md</div> | |
| </div> | |
| <div class="flex flex-col items-end gap-1 text-xs shrink-0"> | |
| <span class="tabular-nums text-zinc-400">~<%= s[:tokens] %> tok</span> | |
| <span class="tabular-nums text-zinc-500"><%= relative_time(s[:mtime]) %></span> | |
| </div> | |
| </div> | |
| </a> | |
| </li> | |
| <% end %> | |
| </ul> | |
| <% end %> | |
| @@skill | |
| <nav class="text-xs text-zinc-500 mb-4"> | |
| <a href="/skills" class="hover:text-zinc-300">← All skills</a> | |
| </nav> | |
| <div class="mb-6"> | |
| <div class="flex items-center gap-2 flex-wrap"> | |
| <h1 class="text-2xl font-semibold text-zinc-100"><%= h(@meta['name'] || @slug) %></h1> | |
| <span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset bg-violet-500/15 text-violet-300 ring-violet-500/30">skill</span> | |
| <span class="ml-auto shrink-0 tabular-nums text-xs text-zinc-400 rounded-lg border border-zinc-800 bg-zinc-900/60 px-2.5 py-1">~<%= @tokens %> tokens always-loaded</span> | |
| <form method="post" action="/skills/<%= h(@slug) %>/delete" | |
| onsubmit="return confirm('Delete this skill?\n\n<%= h(@slug) %>/\n\nThis permanently removes the entire skill directory and cannot be undone.');" | |
| class="shrink-0"> | |
| <button type="submit" title="Delete this skill" | |
| class="inline-flex items-center gap-1.5 rounded-lg border border-red-900/60 bg-red-950/40 px-3 py-1.5 text-xs font-medium text-red-400 hover:bg-red-900/40 hover:text-red-300 hover:border-red-800 transition"> | |
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4"><path fill-rule="evenodd" d="M8.75 1a1 1 0 00-.95.68L7.42 3H4.5a.75.75 0 000 1.5h.29l.7 10.55A2.25 2.25 0 007.73 17h4.54a2.25 2.25 0 002.24-1.95L15.21 4.5h.29a.75.75 0 000-1.5h-2.92l-.38-1.32A1 1 0 0011.25 1h-2.5zM9 6.75a.75.75 0 00-1.5 0v6.5a.75.75 0 001.5 0v-6.5zm3.5 0a.75.75 0 00-1.5 0v6.5a.75.75 0 001.5 0v-6.5z" clip-rule="evenodd"/></svg> | |
| Delete | |
| </button> | |
| </form> | |
| </div> | |
| <% if @meta['description'] && !@meta['description'].empty? %> | |
| <p class="text-zinc-400 mt-2"><%= h(@meta['description']) %></p> | |
| <% end %> | |
| <% if @meta['allowed-tools'] && !@meta['allowed-tools'].empty? %> | |
| <div class="mt-2 text-xs text-zinc-500"> | |
| <span class="text-zinc-400">allowed-tools:</span> | |
| <code class="text-sky-300"><%= h(@meta['allowed-tools']) %></code> | |
| </div> | |
| <% end %> | |
| <div class="flex items-center gap-3 text-xs text-zinc-500 mt-3 font-mono"> | |
| <span><%= h(@slug) %>/SKILL.md</span> | |
| <span>·</span> | |
| <span><%= relative_time(@mtime) %></span> | |
| </div> | |
| </div> | |
| <article class="rounded-lg border border-zinc-800 bg-zinc-900/40 p-6"> | |
| <div class="prose-memory"><%= md(@body) %></div> | |
| </article> | |
| <details class="mt-4"> | |
| <summary class="text-xs text-zinc-500 cursor-pointer hover:text-zinc-300">raw source</summary> | |
| <pre class="mt-2 bg-zinc-900 border border-zinc-800 rounded-lg p-4 overflow-x-auto text-xs text-zinc-300"><%= h(@raw) %></pre> | |
| </details> | |
| <div class="mt-4 text-xs text-zinc-600 font-mono break-all"><%= h(@path.sub(Dir.home, '~')) %></div> |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Claude Code Memory Browser
A tiny single-file Ruby web app for browsing, searching, and viewing the auto-memory markdown files that Claude Code writes to
~/.claude/projects/*/memory/.Claude Code ships an auto-memory system that persists markdown files across sessions (user profile, feedback, project context, references), but there is no built-in UI for browsing them. This script fills that gap — a local webapp you can run with a single command.
Features
memory/subdirectory, sorted by most recently updated, with memory counts and relative timestamps./and.both as-, which is ambiguous to reverse. The decoder walks the actual filesystem to match encoded names to real directories (e.g.-Users-ericboehs-Code-va-ghe-com-software-eert→~/Code/va.ghe.com/software/eert).MEMORY.mdwith markdown, tables, fenced code, and relative-link rewriting so internal links resolve correctly.name,type, anddescriptionout of YAML frontmatter, with color-coded type badges (user,feedback,project,reference).Installation
That is it. No
bundle install— the script usesbundler/inlineto pull its gems on first run.Dependencies
bundler/inline)sinatra,puma,rackup,redcarpetUsage
Change the port:
Routes
//p/:encodedMEMORY.mdplus list of individual memory files/p/:encoded/:file/search?q=...How It Works
__END__in the same file, split on@@namemarkers and rendered with ERB.bundler/inline, so the script is self-contained — noGemfile, nobundle installstep.~once per uncached encoded project, comparing each directory entry.gsub(%r{[/.\s]}, "-")against the remaining encoded string to find the longest match./p/<encoded>/<file>so clicking[foo.md](foo.md)from a project page correctly navigates to the file view.Why
The auto-memory feature is powerful but invisible — the files live in a dot-directory most people never open, and there is currently no official or third-party browser for them. This is a ~460-line Ruby file that gives you one.
License
MIT. Do whatever you want with it.