I Built an AI Code Reviewer That Uses Any LLM to Review Claude Code Output — Zero Dependencies, 7 Commands, Infinite Engines

I Built an AI Code Reviewer That Uses Any LLM to Review Claude Code Output — Zero Dependencies, 7 Commands, Infinite Engines

TL;DR: I built cc-review — a pure bash Claude Code skill that spins up any external LLM (Gemini, Ollama, DeepSeek, OpenAI) to independently review Claude’s own code output. No npm. No pip. Just 7 slash commands, a YAML config, and an uncomfortable truth about trusting AI to review itself. Repo here.

Here’s the uncomfortable truth nobody talks about: Claude is reviewing Claude’s code.

You vibe-code a multi-phase feature. You run /review. Claude reads its own output and says “looks good!” You ship. Two days later you’re debugging a race condition that any second pair of eyes would have caught in 30 seconds.

You didn’t get a code review. You got a mirror.

I hit this exact wall while building a multi-phase AI Second Brain project — an agentic system with memory modules, knowledge indexing, scheduled tasks, and a self-learning loop. Each phase produced hundreds of lines of generated code. I was using Claude Code for everything: architecting, implementing, reviewing. The confirmation bias was baked in.

I needed a reviewer with zero loyalty to the original author.

So I built cc-review: an open-source Claude Code skill that outsources your code review to any external LLM engine. Gemini reviews Claude’s work. Ollama stays local and private. DeepSeek brings a different training distribution. The engine is pluggable. The bash is pure. The cost, with Gemini’s free tier, is zero.

Here’s exactly how it works and how you can add it to your own Claude Code setup in under 10 minutes.

What We’re Building

cc-review is a Claude Code skill — a bash-powered plugin that extends Claude Code with new slash commands. When you trigger a review, it:

  1. Grabs your recent git diff (staged + unstaged changes)
  2. Routes the diff to an external LLM engine of your choice
  3. Scores the code on four dimensions: Completeness, Correctness, Quality, Security
  4. Returns structured feedback with line-level comments
  5. Optionally runs an adversarial review mode that actively tries to break your assumptions
Your Code Changes (git diff)
        │
        ▼
┌───────────────────┐
│   cc-review skill │  ← Pure bash, reads engines.yaml
└────────┬──────────┘
         │
    ┌────▼─────────────────────────────────┐
    │         engines.yaml router          │
    └──┬──────────┬───────────┬────────────┘
       │          │           │
   ┌───▼───┐  ┌───▼───┐  ┌───▼───────┐
   │Gemini │  │Ollama │  │ DeepSeek  │  ← any LLM, zero code changes
   └───────┘  └───────┘  └───────────┘
       │          │           │
       └──────────┼───────────┘
                  │
         ┌────────▼────────┐
         │ Scored Report   │  Completeness / Correctness
         │ (4 dimensions)  │  Quality / Security
         └─────────────────┘

Seven commands ship out of the box:

Command What It Does
/review Standard review using your default engine
/review-adversarial Skeptic mode — actively challenges your code
/review-result Show full output of last review
/review-status Check running/recent jobs
/review-setup Verify engine auth and readiness
/review-cancel Kill a running background job
/review-rescue Delegate investigation/fix to external engine

Prerequisites

  • Claude Code installed and running (>= 0.2.x)
  • git available in your shell
  • At least one of: a Gemini API key (free tier), Ollama running locally, or an OpenAI/DeepSeek key
  • Basic comfort with YAML config files

Step-by-Step

1. Install the Skill

Clone cc-review into your Claude Code skills directory:

git clone https://github.com/mudavathsrinivas/cc-review ~/.claude/skills/cc-review

Claude Code auto-discovers skills in ~/.claude/skills/. No import step. No config file edit. The skill system reads the directory on startup.

Verify it loaded:

# Inside Claude Code
/review-setup

You’ll see output like:

cc-review v1.0.0
─────────────────────────────────
Engine Check:
  gemini    ✓  (GEMINI_API_KEY set)
  ollama    ✓  (http://localhost:11434 reachable)
  deepseek  ✗  (DEEPSEEK_API_KEY not set)
  openai    ✗  (OPENAI_API_KEY not set)

Default engine: gemini
Ready to review.

2. Configure Your Engines

This is the part I’m most proud of. Every engine lives in a single YAML file — engines.yaml. Adding a new LLM requires zero code changes. You just describe it:

# ~/.claude/skills/cc-review/engines.yaml

default: gemini

engines:
  gemini:
    provider: google
    model: gemini-2.0-flash
    api_key_env: GEMINI_API_KEY
    endpoint: https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent
    max_tokens: 8192
    temperature: 0.3
    enabled: true

  ollama:
    provider: ollama
    model: llama3.2
    endpoint: http://localhost:11434/api/generate
    max_tokens: 4096
    temperature: 0.2
    enabled: true
    private: true   # flag: never send to external APIs

  deepseek:
    provider: deepseek
    model: deepseek-coder
    api_key_env: DEEPSEEK_API_KEY
    endpoint: https://api.deepseek.com/v1/chat/completions
    max_tokens: 8192
    temperature: 0.2
    enabled: false  # flip to true when key is set

  openai:
    provider: openai
    model: gpt-4o
    api_key_env: OPENAI_API_KEY
    endpoint: https://api.openai.com/v1/chat/completions
    max_tokens: 8192
    temperature: 0.2
    enabled: false

The router reads this at runtime. Set default to swap your primary reviewer. Set enabled: false to disable an engine without deleting its config. The private: true flag on Ollama is a guardrail — the skill will refuse to send that diff to any external endpoint even if you fat-finger the engine flag.

Want to add a brand new LLM? Add a YAML block. Done.

3. Run Your First Review

Make some changes in your project, then:

/review

The skill captures git diff HEAD (staged + unstaged), structures a review prompt, sends it to your default engine, and streams back a scored report. A real output looks like this:

cc-review | engine: gemini-2.0-flash | 2026-04-11 09:14 CST
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

SCORES
  Completeness  8/10  — Core logic is present; error branches for
                        empty API response are missing.
  Correctness   7/10  — Line 84: off-by-one in pagination cursor.
                        Will silently drop the last record.
  Quality       9/10  — Clean separation of concerns. Good.
  Security      6/10  — API key interpolated directly into log
                        string at line 112. Rotate and fix.

CRITICAL (fix before merge)
  [security] src/client.ts:112
  → `console.log(`Auth: ${apiKey}`)` logs the raw key.
    Replace with a masked version: apiKey.slice(0,4) + '****'

  [correctness] src/paginator.ts:84
  → Cursor offset is `page * limit` but should be
    `(page - 1) * limit` for 1-indexed pagination.
    Current code skips page 1 entirely.

SUGGESTIONS
  [completeness] src/client.ts:67
  → No handling for HTTP 429 (rate limit). Add exponential
    backoff or surface the error to caller.

  [quality] src/types.ts:23
  → ApiResponse<T> type is wide. Consider discriminated union
    for success/error states.

SUMMARY
  Solid implementation with two ship-blockers. The security
  issue is trivial to fix. The pagination bug would have caused
  silent data loss in production. Review cost: $0.00 (free tier).

That pagination bug? 100% something Claude wrote and Claude would have rubber-stamped. Gemini caught it because it has no attachment to the original decision.

4. Use Adversarial Mode for Critical Phases

Standard review finds bugs. Adversarial mode finds assumptions you didn’t know you were making.

/review-adversarial

The prompt instructs the external engine to play the role of a skeptical senior engineer who actively looks for: race conditions, wrong abstractions, over-engineering, security footguns, and implicit dependencies that will break in production.

Real example output from my Second Brain project’s memory indexer:

ADVERSARIAL REVIEW | engine: gemini-2.0-flash
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CHALLENGED ASSUMPTIONS

  1. "Files are processed sequentially"
     → Your glob pattern returns files in filesystem order.
       On macOS this is usually alphabetical. On Linux ext4
       it's creation order. On network mounts, undefined.
       Your tests will pass locally and break in CI.

  2. "MEMORY.md is always writable"
     → No lock file, no atomic write. Two agents running
       concurrently will corrupt this file. You mentioned
       scheduled tasks — this WILL happen.

  3. "The embedding model is stable"
     → You hardcode 'text-embedding-3-small' but never pin
       the version. OpenAI has silently updated embeddings
       before. Your similarity scores will drift over time
       and you won't know why.

VERDICT
  Ship with fixes for #1 and #2. #3 is acceptable risk
  for a solo project but document the assumption explicitly.

None of those were in the standard review output. Adversarial mode thinks differently.

5. Keep Private Code Private with Ollama

If you’re working on proprietary code and can’t send diffs to Google or OpenAI, flip to Ollama:

/review --engine ollama

Or set default: ollama in engines.yaml for all reviews. Everything stays on your machine. The private: true config flag means the router will hard-fail rather than accidentally route to an external API.

# Verify your Ollama setup first
/review-setup --engine ollama

# Output:
# ollama: ✓ (llama3.2 loaded, 8B params, ~6GB RAM used)
# private mode: ON — external routing disabled for this engine

Quality is lower than frontier models, but for security-sensitive codebases or pure sanity checks, local Ollama at zero cost is a real option.

The Result

After integrating cc-review into my Second Brain project workflow, here’s what changed:

  • Gemini free tier handles ~1,000 reviews/day at $0.00. For a solo developer shipping phases one at a time, this is effectively unlimited.
  • 5 real bugs caught in 3 weeks that I confirmed would have reached production — 2 correctness issues, 2 security issues, 1 missing error handler.
  • Review latency: 8-14 seconds per phase diff using Gemini Flash. Fast enough to run after every significant change without breaking flow.
  • Adversarial mode changed how I think about code I generate. I now proactively consider race conditions and assumption brittleness because I’ve seen the engine surface them repeatedly.

The workflow became:

Implement phase with Claude → /review → fix blockers → /review-adversarial → fix assumptions → commit

Key Takeaway

An LLM cannot objectively review its own output. Not because the model is bad — because the training distribution, the context window, and the confirmation bias are all pointing the same direction. Independent review means a genuinely different model, with different training data, reading your code cold.

cc-review is a 20-minute setup that gives you that independence, at zero cost, with full control over which engine reviews which code and whether anything ever leaves your machine.

The irony is: the better your AI coding assistant gets, the more you need this. The faster Claude ships code, the faster bugs accumulate without a second opinion.

Star the repo if this is useful: github.com/mudavathsrinivas/cc-review

Pull requests welcome — especially new engine configs for engines.yaml. If you’ve got a working block for Mistral, Cohere, or any local model, open a PR and I’ll merge it.

Follow me here on Dev.to — I’m documenting the full AI Second Brain build in public, including the infrastructure, the failures, and the moments where the AI confidently wrote something completely wrong.

I Rewrote My Portfolio From Scratch — Here’s What Actually Changed (And Why)

My old portfolio wasn’t bad. It had all the things that feel polished when you first build them: card grids, gradient overlays, staggered animations, rounded-3xl corners everywhere. You look at it and think: yeah, that looks like a modern site.

Then six months pass and you keep opening it and something feels off. Nothing is obviously wrong. But every section is quietly competing for attention. There’s no hierarchy. It’s just noise.

So I started over on the design language, rewrote most of the components, added a couple of features I’d been putting off for too long, and somewhere in the middle of all this, switched my entire dev setup from Windows to macOS. This post is the full breakdown.

The Design Shift: From “Modern SaaS” to Editorial

The old design lived in a very specific aesthetic bucket I’d call modern SaaS: shadows on cards, borders everywhere, lots of colour, hover-lift animations. It’s a style that works great for product landing pages. For a personal portfolio it ends up feeling generic.

The new direction is closer to editorial design. Think tech publication meets printed magazine. The changes sound small individually but add up fast.

No more rounded corners. I removed rounded-3xl from basically everything. Flat, sharp edges give the layout a much more intentional, structured feel.

Left accent bars instead of cards. Instead of wrapping content in bordered card boxes, list rows now have a thin vertical bar on the left that scales in from the center on hover:

<div className="absolute left-0 inset-y-0 w-0.5 bg-gray-900 dark:bg-white origin-center scale-y-0 group-hover:scale-y-100 transition-transform duration-200 rounded-sm" />

Mono eyebrow labels. Every section heading now has a small uppercase label above it. The kind of detail you don’t consciously notice, but that makes everything feel considered:

<span className="font-mono text-[9px] tracking-[0.45em] uppercase text-gray-500">
  {eyebrow}
</span>

Section number watermarks. Big faded numbers (01, 02, 03) sit behind each section. More editorial energy, less web-app energy.

Invert-fill buttons. The hover state on CTAs now runs a fill layer up from the bottom of the button rather than just swapping the background colour:

<button className="group relative inline-flex items-center gap-2 px-7 py-3.5 border border-gray-400 overflow-hidden hover:text-white transition-colors duration-300">
  <span className="absolute inset-0 bg-gray-900 translate-y-full group-hover:translate-y-0 transition-transform duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]" />
  <span className="relative z-10">View Work</span>
</button>

Border dividers instead of card wrappers. Lists are flat rows with border-b separators. The content breathes. You can actually read it.

The whole palette simplified down to gray-900 and white in dark mode, with emerald only for “currently available” indicators.

The Navbar

Two things had been bugging me about the old navbar: it added a drop shadow on scroll, and the hamburger icon was just two static SVGs swapping in and out.

The scroll behaviour now switches to a border-b instead:

// Before
navRef.current!.classList.add("shadow", "backdrop-blur-xl", "bg-white/70");

// After
navRef.current!.classList.add(
  "border-b",
  "border-gray-200",
  "dark:border-neutral-700",
  "backdrop-blur-xl",
  "bg-white/80",
  "dark:bg-darkPrimary/90"
);

The hamburger became three motion.span lines that animate into an X:

<motion.span
  animate={open ? { rotate: 45, y: 7 } : { rotate: 0, y: 0 }}
  transition={{ duration: 0.2 }}
  className="block w-5 h-px bg-gray-900 dark:bg-white origin-center"
/>
<motion.span
  animate={open ? { opacity: 0, scaleX: 0 } : { opacity: 1, scaleX: 1 }}
  className="block w-5 h-px bg-gray-900 dark:bg-white"
/>
<motion.span
  animate={open ? { rotate: -45, y: -7 } : { rotate: 0, y: 0 }}
  className="block w-5 h-px bg-gray-900 dark:bg-white origin-center"
/>

It’s a detail that takes twenty minutes to implement and immediately makes the site feel more alive.

On desktop, nav items use layoutId="nav-underline" for a shared-element animated underline that slides between links. On mobile, the menu now has large numbered links and a “MENU” watermark sitting in the background.

The Hero Section (Built From Zero)

There wasn’t really a proper hero component before, just the first section with some text in it. I built HeroSection.tsx from scratch.

The dot grid background is a single CSS radial-gradient at 12% opacity:

<div
  style={{
    backgroundImage: "radial-gradient(circle, #6b7280 1px, transparent 1px)",
    backgroundSize: "28px 28px",
    opacity: 0.12,
  }}
/>

The “JS” watermark is my initials in massive gradient-clipped text, sitting to the right of the content area. It’s not readable, it’s just a shape:

<div
  className="absolute -right-4 top-1/2 -translate-y-1/2 font-black select-none pointer-events-none bg-gradient-to-b from-gray-200 to-gray-50 dark:from-[#232628] dark:to-darkPrimary bg-clip-text text-transparent"
  style={{ fontSize: "clamp(8rem, 24vw, 22rem)" }}
>
  JS
</div>

The profile image is grayscale at rest and transitions to full colour on hover. I genuinely love this one. It’s the kind of detail that makes a visitor do a double-take the first time they see it:

<Image
  className="grayscale hover:grayscale-0 transition-all duration-500"
  src="/profile.jpg"
  alt="Jatin Sharma"
  width={300}
  height={300}
/>

Corner cross-tick marks at the four corners of the content area give that blueprint feel without being heavy-handed about it.

Blog Cards: Stripping It All Back

This was probably the most dramatic visual change on the site.

The old blog card was tall, image-dominant, and wrapped in a rounded bordered box. It looked fine. But it was heavy. Reading the blog index felt like scrolling through a gallery.

// Before: big card with image and rounded corners
<motion.article className="group bg-white dark:bg-darkSecondary rounded-3xl overflow-hidden border-2 border-gray-100">
  <div className="grid md:grid-cols-2 gap-6 p-6">
    {/* image + content */}
  </div>
</motion.article>

// After: flat row with accent bar
<motion.article className="group relative border-b border-gray-300 dark:border-neutral-700 last:border-0">
  <div className="absolute left-0 inset-y-0 w-0.5 bg-gray-900 dark:bg-white origin-center scale-y-0 group-hover:scale-y-100 transition-transform duration-200" />
  <Link className="flex items-center gap-4 py-6 pl-4 pr-2">
    {/* index + title + arrow */}
  </Link>
</motion.article>

No images. No author avatar. No “Read more” button. Just the content. The result is a blog index you can actually scan. You can read ten titles in the time it used to take to read three.

Skills Section: Marquee and Denser Grid

The skill cards went from tall centered icon boxes to compact horizontal rows in a grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 grid.

The new addition I’m happiest about is the marquee ticker, a continuous horizontal scroll of all skills sitting between the section header and filter buttons:

<div className="flex animate-marquee gap-10 w-max">
  {[...skills, ...skills].map((skill, i) => {
    const Icon = skill.Icon;
    return (
      <div key={i} className="inline-flex items-center gap-2 text-gray-600 dark:text-gray-400 select-none">
        <Icon className="w-3.5 h-3.5 flex-shrink-0" />
        <span className="text-[10px] font-mono uppercase tracking-[0.25em] whitespace-nowrap">
          {skill.name}
        </span>
      </div>
    );
  })}
</div>

The trick is duplicating the array so the scroll loops seamlessly. No library needed, just a CSS @keyframes translate.

The filter also now shows a count next to each category name, and switching categories triggers a full stagger re-entry on the grid rather than trying to animate individual items in and out.

Table of Contents: Panel to Drawer

The old TOC was a fixed left panel on desktop. It worked, but it was always there, always occupying space, always creating layout tension with the article content.

The new version is a FAB button that opens a left-side drawer:

{/* FAB */}
<motion.button
  onClick={() => setOpen((o) => !o)}
  className="fixed bottom-6 left-6 z-40 flex items-center gap-2 px-3 h-9 bg-gray-900 dark:bg-white text-white dark:text-gray-900 font-mono text-[10px] tracking-[0.35em] uppercase"
>
  <BsListUl className="w-3.5 h-3.5" />
  <span className="hidden sm:inline">Contents</span>
</motion.button>

{/* Drawer */}
<motion.aside
  initial={{ x: "-100%" }}
  animate={{ x: 0 }}
  exit={{ x: "-100%" }}
  transition={{ type: "spring", stiffness: 340, damping: 32 }}
  className="fixed top-0 left-0 bottom-0 w-full sm:w-80 bg-white dark:bg-darkPrimary border-r border-gray-200 dark:border-neutral-700 flex flex-col"
>

Removing the fixed panel also let me drop four dependencies I didn’t need anymore: useScrollPercentage, useWindowSize, lockScroll, and removeScrollLock. The drawer just uses a backdrop click to close.

The Books Page

This is the biggest entirely new feature.

I’ve been tracking my reading on Hardcover and wanted to surface that data on the portfolio. Hardcover has a GraphQL API so I built a small client:

async function hardcoverQuery<T>(
  query: string,
  variables?: Record<string, unknown>,
): Promise<T> {
  const res = await fetch("https://api.hardcover.app/v1/graphql", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      authorization: `Bearer ${process.env.HARDCOVER_API_KEY}`,
    },
    body: JSON.stringify({ query, variables }),
  });
  const json = await res.json();
  return json.data as T;
}

The API route caches for 24 hours with stale-while-revalidate so it doesn’t hammer the endpoint:

res.setHeader(
  "Cache-Control",
  "public, s-maxage=86400, stale-while-revalidate=43200"
);

The page has reading stats, a year-goal progress bar that animates in on scroll, a tabbed shelf for the three reading statuses, and a debounced search across title and author. It’s the kind of page that only makes sense on a personal portfolio.

siteConfig.ts: One Source of Truth

My name, email, job title, section copy, and social links were scattered across maybe a dozen different files. When I needed to update something, even just my job title, I had to grep for it and hope I caught every instance.

Now there’s a single content/siteConfig.ts:

const siteConfig = {
  person: {
    name: "Jatin Sharma",
    email: "work.j471n@gmail.com",
    location: "Based in India",
  },
  home: {
    hero: {
      rolePrefix: "Tech Lead at",
      companyName: "KonnectNXT",
      primaryCta: { label: "Download Resume", url: "https://bit.ly/j471nCV" },
    },
  },
} as const;

socialMedia.ts and user.ts both derive from siteConfig now. One change propagates everywhere. It’s the kind of refactor you keep putting off until you finally do it and immediately wonder why you waited.

Syntax Highlighting: Light and Dark

Previously everything used one-dark-pro regardless of colour scheme. Now code blocks adapt properly:

// Before
[rehypePrettyCode, { theme: "one-dark-pro" }]

// After
[rehypePrettyCode, {
  theme: {
    dark: "one-dark-pro",
    light: "github-light",
  },
}]

For MDX local content I went with andromeeda (dark) and catppuccin-latte (light), a slightly warmer combination.

The CodeTitle component also got redesigned. The old bordered box is now a top accent line and a clean mono label:

// Before
<div className="bg-white rounded-tl-md rounded-tr-md p-3 border border-black">

// After
<div className="!mt-4 mb-[14px]">
  <div className="h-0.5 w-full bg-gray-900 dark:bg-white" />
  <div className="bg-white dark:bg-darkSecondary border border-b-0 border-gray-200 dark:border-neutral-700 px-4 py-2 flex items-center gap-2 font-mono overflow-x-auto">
    <Icon className="w-3.5 h-3.5 text-gray-400" />
    <span className="text-[10px] tracking-[0.35em] uppercase text-gray-600 dark:text-gray-400">
      {title || lang}
    </span>
  </div>
</div>

The Uses Page: Full Windows to macOS Migration

I switched from Windows to macOS this year and my /uses page was so out of date it was almost embarrassing.

Gone: Windows 11, Edge, Sublime Text, ShareX, Ditto, 7-Zip, Flameshot, Notepad++, Google Keep, Microsoft Todo.

In: macOS, Homebrew, Raycast (the thing I miss most when I’m on any other machine), Rectangle, iTerm2, Warp, Oh My Zsh, Arc as my primary browser, CleanShot X, Obsidian.

The whole category structure got reworked too: System and OS, Terminal and CLI, Development, Design and Creativity, Productivity, Browsers, Communication.

The Smaller Stuff

A reusable PageHeader component: the watermark + eyebrow + title + description pattern was copy-pasted into every page. Extracted it once, imported it everywhere.

fallback: "blocking" on the blog: changed from fallback: false so newly published posts go live immediately without requiring a full rebuild.

BlogLayout cleanup: removed the Newsletter section, share buttons, and bookmark feature from individual post pages. Just the article now.

Animation cleanup: replaced dozens of imported FramerMotionVariants with simple inline transitions. Less code, same feel, easier to read:

// Before: importing complex variants from a separate file
import { FadeContainer, popUp } from "@content/FramerMotionVariants";

// After: simple inline
<motion.div
  initial={{ opacity: 0, y: 12 }}
  animate={{ opacity: 1, y: 0 }}
  transition={{ type: "spring", stiffness: 300, damping: 24 }}
>

What’s Next

The Projects page still needs the same treatment the blog got. I’ve already plumbed in the featured prop on Project.tsx, just haven’t gotten there yet. There’s also a /stats page refresh on the list.

But the core is done and it finally feels like mine.

All the code is open source at github.com/j471n/j471n.in. If you want to lift any of the patterns, the watermark technique, the invert-fill buttons, the marquee, the accent bars, go for it.

FlutterとSupabaseでNotion風ノートコメント機能を実装した話

FlutterとSupabaseでNotion風ノートコメント機能を実装した話

はじめに

自分株式会社(https://my-web-app-b67f4.web.app/)はFlutter Web + Supabaseで構築したAI統合ライフマネジメントアプリです。Notionの機能ギャップを埋めるべく、今回「コメント機能」を実装しました。

Notionではページに対してコメントを残す機能が標準搭載されています。自分のノートに「後で確認」「ここ重要」「アイデアメモ」といった付箋代わりのコメントを追加できるあの機能です。

実装内容

1. DBスキーマ(マイグレーション)

CREATE TABLE IF NOT EXISTS note_comments (
  id         uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  note_id    bigint NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
  user_id    uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
  content    text NOT NULL CHECK (length(trim(content)) > 0),
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);

ALTER TABLE note_comments ENABLE ROW LEVEL SECURITY;

-- RLS: ユーザーは自分のコメントのみ操作可能
CREATE POLICY "Users can view own note comments"
  ON note_comments FOR SELECT USING (auth.uid() = user_id);

CREATE POLICY "Users can insert own note comments"
  ON note_comments FOR INSERT WITH CHECK (auth.uid() = user_id);

CREATE POLICY "Users can update own note comments"
  ON note_comments FOR UPDATE
  USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id);

CREATE POLICY "Users can delete own note comments"
  ON note_comments FOR DELETE USING (auth.uid() = user_id);

CREATE INDEX IF NOT EXISTS note_comments_note_id_created_at_idx
  ON note_comments (note_id, created_at ASC);

ポイントは RLS(Row Level Security)。SupabaseのRLSを使うと、SQLレイヤーで「自分のデータしか見えない」を強制できます。フロントエンドでのフィルタリングに頼らず、DBレベルで保護されるのが安心です。

2. Supabase Edge Function (Deno)

// note-comments/index.ts
serve(async (req) => {
  const userId = getUserIdFromJwt(req);
  if (!userId) return json({ error: "unauthorized" }, 401);

  if (req.method === "GET") {
    // note_id でコメント一覧取得(ノート所有確認付き)
    const { data } = await client
      .from("note_comments")
      .select("id, content, created_at, updated_at")
      .eq("note_id", noteId)
      .eq("user_id", userId)
      .order("created_at", { ascending: true });
    return json({ comments: data ?? [] });
  }

  if (req.method === "POST") {
    // コメント追加(内容2000字制限)
    const { data } = await client
      .from("note_comments")
      .insert({ note_id: noteId, user_id: userId, content })
      .select("id, content, created_at, updated_at")
      .single();
    return json({ ok: true, comment: data });
  }

  if (req.method === "DELETE") {
    // コメント削除(自分のコメントのみ)
    await client.from("note_comments")
      .delete()
      .eq("id", commentId)
      .eq("user_id", userId);
    return json({ ok: true });
  }
});

Edge Functionではサービスロールキーを使いつつ、JWTからuser_idを取り出してすべての操作に .eq("user_id", userId) を付与しています。二重の安全策です。

3. Flutter UI

// NoteEditorPage の AppBar に追加
if (_currentNoteId != null)
  Stack(
    alignment: Alignment.topRight,
    children: [
      IconButton(
        icon: const Icon(Icons.comment_outlined),
        onPressed: _showComments,
        tooltip: 'コメント',
      ),
      if (_commentCount > 0)
        Positioned(
          top: 6, right: 6,
          child: Container(
            // バッジ表示
            child: Text('$_commentCount', ...),
          ),
        ),
    ],
  ),

コメント数をバッジ表示することで、ノートにコメントがあることを一目で把握できます。

BottomSheetでコメント一覧と入力フィールドを表示し、DraggableScrollableSheetで高さ調整可能にしています。

詰まったポイント

withOpacitywithValues への移行

Flutter 3.x では withOpacity が deprecated になっており、withValues(alpha: x) を使う必要があります。flutter analyze で検出して修正しました。

// ❌ deprecated
color: Colors.indigo.withOpacity(0.05),

// ✅ 正しい
color: Colors.indigo.withValues(alpha: 0.05),

unawaited の活用

バックグラウンドでコメント数をロードする際、unawaited() を使って非同期処理を明示的に「待たない」ことを示します。

// dart:async の unawaited で fire-and-forget
unawaited(_loadCommentCount());

まとめ

  • PostgreSQL RLS + Supabase で安全なマルチテナントデータ管理
  • Edge Function でノート所有権チェックを二重実装
  • Flutter の DraggableScrollableSheet でスムーズなBottomSheet UI
  • flutter analyze 0件 を維持しながら実装完了

次は チームワークスペース(リアルタイム共同編集)の基盤整備に取り組みます。

URL: https://my-web-app-b67f4.web.app/

FlutterWeb #Supabase #buildinpublic #Dart

Building a Local AI Assistant on Linux — Recent Progress on Echo

Building a Local AI Assistant on Linux — Recent Progress on Echo

Last week, I made significant strides in building my local AI assistant, Echo, on my Ubuntu machine. This article covers the recent updates, including how I refined my AI’s content strategy, improved my trading bots, and enhanced the session checkpoint system.

2026-04-01 — Publisher Wired to Content Strategy

I’ve been working on making my content more dynamic and relevant by integrating it with a content strategy file. Here’s how I did it:

# echo_devto_publisher.py
import json
import os

def read_content_strategy():
    with open('content_strategy.json', 'r') as f:
        return json.load(f)

def update_publisher():
    strategy = read_content_strategy()
    if strategy.get('next'):
        next_topic = strategy['next']
        # Use next_topic to set content for the publisher
    elif strategy.get('queued'):
        queued_topic = strategy['queued'][0]
        # Use queued_topic to set content for the publisher
    else:
        # Use generic content for the publisher
        pass

update_publisher()

After making this change, I reset my content queue to ensure all my topics are ready to publish. I also deleted any generic articles from March 31 to keep my feed fresh. Next Tuesday, I’ll be sharing how I built a two-way phone bridge for my AI using ntfy.sh.

2026-04-01 — Trade Brain v2

I’ve been working on my trading bots, specifically the Trade Brain, which has seen a few updates. Here are the key changes:

  • Increased Position Sizing: I’ve increased the position size to 10% per trend trade and 8% for momentum trades, with a maximum of 8 positions.
  • Added Trailing Stop: This feature protects gains after a 2% upward movement.
  • Sector Awareness: The bot now prevents over-concentration in the same sector.
  • Updated Watchlists: I’ve added XOM (energy), IWM (small cap), RKLB, and IONQ to the watchlist.
  • Fixed Take Profit: For trend trades, the take profit is set to 5%, and for momentum trades, it’s 3%.

The first v2 cycle saw the entry of XOM (energy trend) and RKLB (momentum).

2026-04-01 — Crypto Brain Live

I’ve also made progress on my Crypto Brain, a 24/7 trading bot for cryptocurrencies. Here are the details:

# core/crypto_brain.py
import alpaca_trade_api as tradeapi
import pandas as pd
import talib

API_KEY = 'your_api_key'
API_SECRET = 'your_api_secret'
BASE_URL = 'https://paper-api.alpaca.markets'

api = tradeapi.REST(API_KEY, API_SECRET, BASE_URL, api_version='v2')

def get_cryptos_data():
    assets = ['BTC/USD', 'ETH/USD', 'SOL/USD', 'AVAX/USD']
    dfs = [pd.DataFrame(api.get_bars(asset, '1H', limit=720).df) for asset in assets]
    return pd.concat(dfs, keys=assets)

def crypto_strategy(df):
    indicators = talib.RSI(df['close'], timeperiod=14)
    mean_reversion = (indicators < 30) & (df['close'].pct_change() > 0.04)
    momentum = (df['close'].pct_change() > 0.06)
    return mean_reversion & momentum

data = get_cryptos_data()
trades = data[data.apply(crypto_strategy, axis=1)]
print(trades)

This bot uses the RSI mean reversion strategy combined with a 6-hour momentum check. The take profit is set to 4%, and the stop loss is 2%. The first scan revealed that all coins were in the oversold RSI range (31-33).

2026-04-02 — Session Checkpoint Upgraded

To ensure a smooth session summary, I upgraded the session checkpoint system:

# session_checkpoint.py
import re

def collect_session_focus():
    headers = []
    with open('session_summary.json', 'r') as f:
        session_summary = json.load(f)
        if 'override_focus' in session_summary:
            return session_summary['override_focus']
        headers = [line.strip() for line in f.readlines() if re.match(r'^##s', line)]
        return ' + '.join(headers[:4])

focus = collect_session_focus()
print(focus)

This script now collects all ## headers from the session summary and filters out noise. The focus is then joined into a natural-sounding briefing by the LLM, which speaks the joined focus at 8am.

2026-04-02 — Briefing Fixed — Direct Ollama Call

Finally, I fixed the daily briefing by calling Ollama directly via HTTP:


python
# daily_briefing.py
import requests

def call_ollama():
    url = 'https://ollama.com/api/v1/brief'
    headers = {'Content-Type': 'application/json'}
    data = {

How I Built a Lightning-Fast Face Recognition Batch Processor using Python & Docker

Introduction

Have you ever tried to find a specific person in a folder containing thousands of event photos? Whether it’s a wedding, a graduation, or a corporate event, photographers spend hours manually sifting through galleries to deliver personalized photo sets to their clients.

I wanted to automate this, but I quickly ran into a wall: performing deep learning face recognition on thousands of high-resolution images is computationally expensive and memory-hungry.

So, I built Py_Faces, a batch face recognition system that solves this by separating the heavy lifting from the actual search process. Here is how I designed the architecture to search through thousands of photos in seconds, and how I tamed Docker memory limits along the way.

The Architecture: Calculate Once, Search Instantly
The biggest mistake I could have made was scanning the entire photo folder every time the user wanted to search for a new person. Instead, I split the system into three completely independent steps:

  • Step 1: The Heavy Lifting (Encoding Extraction)
    The first script (escaner_encodings.py) scans every photo in the batch just once. It detects the faces, applies a CLAHE filter (Contrast Limited Adaptive Histogram Equalization) to handle bad lighting, and extracts a 128-dimension facial encoding vector using the face_recognition library.

These vectors—along with metadata and file paths—are saved into a binary .pkl file. This process can take around an hour depending on the CPU, but it’s a one-time cost.

  • Step 2: Defining the Target
    When the user wants to find someone, they drop a few clear photos of that person into a persona_objetivo folder. The second script (definir_objetivo.py) extracts the encodings from these reference photos and averages them out to create a highly accurate “Target Profile”.

  • Step 3: The Lightning-Fast Search
    Here is where the magic happens. The third script (buscador_objetivo.py) doesn’t look at images at all. It simply loads the massive .pkl file from Step 1 and uses NumPy to calculate the Euclidean distance between the “Target Profile” and every face in the batch.

Because it’s just comparing arrays of numbers, searching through thousands of photos takes about 2 seconds. The script then automatically copies the matching photos into a new folder and generates a detailed Excel report using pandas.

Taming the Docker & Memory Beast
To make this tool accessible, I wrapped it in a Docker container (python:3.11-slim). This avoids the nightmare of making users install C++ build tools, CMake, and dlib natively on Windows.

However, this introduced a massive challenge: Memory Management.
Docker Desktop on Windows (WSL2) limits memory usage. Processing high-res images with HOG or CNN models in parallel quickly leads to BrokenExecutor crashes because the container runs out of RAM.

To fix this, I implemented a dynamic worker calculation function that checks the actual available RAM inside the Linux container (/proc/meminfo) before launching the ProcessPoolExecutor:

`def calcular_workers():
    """Estimates safe workers based on free memory in the container."""
    import os
    memoria_por_worker_gb = 1.2  # Estimated RAM per parallel process
    try:
        if os.path.exists('/proc/meminfo'):
            with open('/proc/meminfo') as f:
                for linea in f:
                    if linea.startswith('MemAvailable:'):
                        mem_kb = int(linea.split()[1])
                        mem_gb = mem_kb / (1024 * 1024)
                        # Reserve at least 0.8 GB for the OS and main orchestrator
                        mem_disponible_gb = max(0.5, mem_gb - 0.8)
                        workers_por_ram = int(mem_disponible_gb / memoria_por_worker_gb)
                        return max(1, min(workers_por_ram, os.cpu_count() or 4))
    except Exception:
        pass

    # Safe fallback
    cpus = os.cpu_count() or 2
    return max(1, int(cpus / 2))`

This ensures the script scales perfectly. If you run it on a 16GB machine, it maximizes the workers; if you run it on a constrained Docker environment, it dials it back to prevent crashes. Furthermore, images are resized (e.g., max 1800px or 2400px width) before processing to keep memory spikes in check.

Dealing with Real-World Dirty Data
When dealing with raw client photos, you learn quickly that data is never clean. I had to implement several fallbacks:

EXIF Orientations: Photos taken vertically often appear horizontal to dlib. I wrote a utility using Pillow (PIL) to read EXIF tags and physically rotate the arrays before detection.

Sequential Retries: If the multiprocessing pool does crash, the script catches the BrokenExecutor error, rescues the failed batch, and processes them sequentially so the user doesn’t lose an hour of progress.

Conclusion
Building Py_Faces taught me that sometimes the best way to optimize a slow process isn’t to write faster algorithms, but to change the architecture entirely. By decoupling the extraction from the comparison, a heavy machine-learning task became an instant search tool.

You can check out the full code on my GitHub: https://github.com/daws-4/pyfaces

Have you ever dealt with memory leaks or dlib crashes in Docker? I’d love to hear how you solved them in the comments!