スクラッチからCLIのAIコーディングアシスタントを作った——学んだこと

Dev.to / 2026/4/8

💬 オピニオンDeveloper Stack & InfrastructureIdeas & Deep AnalysisTools & Practical Usage

要点

  • 著者は Claude Code のアーキテクチャを研究した上で、Anthropic APIへのロックインや、セッションメモリ、キャッシュ、サンドボックスの欠如といった制約を乗り越えたいと考え、TypeScript製のCLIコーディングアシスタント「Seed AI」を作成した。
  • Seed AIは、ユーザーにとって理解しやすい許可/UXのフローを保ちながら、並列ツール実行を行うといった主要な実装上の課題を解決することに重点を置いている。
  • この取り組みでは、セッション内での同一ファイル読み取りをキャッシュによって減らし、さらに単一ベンダーAPIにとどまらないより柔軟なツール統合の選択肢を提供するなど、パフォーマンス面の改善が強調されている。
  • 全体として、この投稿は Seed AI のCLIアシスタントのワークフローにおいて行った設計上のトレードオフと、実際に実装した独自の改善を、技術的な「脳内メモ/ブレインダンプ」として提示している。

スクラッチからCLI AIコーディングアシスタントを作った — 学んだこと

TL;DR: 私はClaude Codeのアーキテクチャを数か月かけて調べ、その後Seed AIを構築しました。TypeScriptのCLIアシスタントで、14個のオリジナル改善を加えています。この記事は、私が解決した中でも特に面白かった技術的課題の頭の中のメモ(ブレインダンプ)です。

なぜそんなことを?

Claude Codeはとても優秀です。しかし、いくつかの厳しい制約があります:

  • AnthropicのAPIに固定(DeepSeekなし、ローカルのOllamaなし)
  • セッション間でのメモリがない
  • ツール結果のキャッシュがない(セッション内で同じファイルを3回読み込む)
  • Dockerサンドボックスがない

これらはいずれも不満ではありません。プロダクト上の意思決定です。ですが、その分、探る余地が残りました。

面白かった課題

1. UXを壊さずにツールを並列実行する

Claude Codeはツールを直列に実行します:permission(A) → exec(A) → permission(B) → exec(B)

LLMが同時に3つのファイル読み込みを要求すると、遅延は3倍になります。

雑な解決策は完全な並列化ですが、その場合、3つの許可ダイアログが同時に飛び出してきて、混乱します。正しい分割はこうです:

// 権限:直列(ユーザーが1つずつレビューする)
const approvedCalls: ToolCall[] = [];
for (const call of toolCalls) {
  const approved = await askPermission(call);
  if (approved) approvedCalls.push(call);
}

// 実行:並列(UX上の対話は不要)
const results = await Promise.allSettled(
  approvedCalls.map(call => tools.execute(call.name, call.input))
);

結果:遅延がN×Tから、ほぼ1.2×T(同規模の読み込みN件の場合)まで下がります。

2. セッション単位のツールキャッシュと「書き込み前に無効化」順序

LLMはファイルを頻繁に読み直します。file_readglobgrepはいずれも冪等です。明らかな最適化は、セッション内でキャッシュすることです。

難しいのは、書き込みに対する無効化の順序です。例えばこうすると:

file_edit(foo.ts) → invalidate cache → read foo.ts (新しい内容を取得) ✓

しかし、無効化が実行の後で起きるなら:

file_edit(foo.ts) → read foo.ts (キャッシュヒット、古い内容を取得) → invalidate ✗

修正は:cache.invalidateForWrite()execute()の後ではなく、実行前に行うことです。後から考えれば明らかですが、間違えるとコストが高いです。

キャッシュキーの形式は"file_read:{"path":"/foo/bar.ts"}"です。ツール名に加えて、JSON.stringify(input)を組み合わせます。

典型的なセッションキャッシュヒット率は20〜40%です。

3. LLMによるコンテキスト圧縮

会話が長くなるとClaude Codeは切り詰めます。古いメッセージは消えてしまいます。

より良いアプローチは、利用可能な最も安価なモデル(Haiku、約$0.0002/コール)で、削られた内容を要約し、その要約をシステムプロンプトに注入することです:

const SUMMARY_MODEL = "claude-haiku-4-5-20251001";

// 注入先:システムプロンプトの動的セクション:
// ## 以前の会話の要約(圧縮済み)
// [Completed: refactored auth module, fixed token expiry bug]
// [Decided: use refresh tokens over session extension]
// [Current state: tests passing, ready for PR]

累積する要約は、複数回の圧縮ラウンドにわたって---区切りで追記されます。メインモデルは常に、先に何が起きたかを把握できます。

圧縮あたりのコスト:約$0.0002。無視できるほど小さいです。

4. セッション間のセマンティック・ベクトルメモリ

長期メモリの仕組みは3層構造です:

~/.seed/memory/
├── user.md                    ← プロジェクト横断のユーザープロファイル
└── projects/{sha1(path)[:12]}/
    ├── context.md             ← 技術スタック、アーキテクチャ
    ├── decisions.md           ← 重要な意思決定+推論
    └── learnings.md           ← 修正したバグ、うまくいったパターン

プロジェクトのフィンガープリント(sha1(normalize(path)).slice(0, 12))はパスを一貫した形にマップします。つまり、同じプロジェクトなら、パスの書き方がどうであっても同じメモリバケットになります。

セッション終了時に、Haikuは耐久性のある知識だけを抽出します(エフェメラルな変数名などは除外)。セッション開始時には、ローカルの埋め込みモデルがコサイン類似度により関連上位8チャンクを取得します。一定の注入コストは約800トークンで、メモリ全体のサイズに関係ありません。

埋め込みサービスが使えない場合はTF-IDFでフォールバックします。

5. Dockerサンドボックスと、ホストへのフォールバック(円滑な切替)

bashツール呼び出しは、新しいコンテナを立ち上げ、実行後に自動で削除します:

[
  "run", "--rm",
  "-v", `${mountPath}:/workspace`,
  "-w", "/workspace",
  "--network", "none",          // strict mode
  "--memory", "512m",
  "--cpus", "1",
  "--security-opt", "no-new-privileges",
  "alpine:latest", "sh", "-c", command
]

3つの隔離レベル:strict(読み取り専用FS + ネットワークなし)、standardpermissive

重要なのは:Dockerが動いていないときにクラッシュしないことです。docker infoは初回利用時にプローブを行い、結果をキャッシュし、システムはホスト上での実行にフォールバックしつつ、目に見える警告を表示します。ユーザーは常に自分がどのモードで動いているかを把握できます。

6. ローカルLLM対応:機能検出

Ollamaは「このモデルはツール呼び出しをサポートするか?」に対する標準的なエンドポイントを公開していません。私が辿り着いたプローブは以下です:

// 最小限のツール利用リクエストを送る
const res = await fetch(`${base}/v1/chat/completions`, {
  body: JSON.stringify({ model, messages: [...], tools: [PROBE_TOOL] })
});

if (!res.ok && res.status === 400) {
  const body = await res.text();
  // 能力のないモデルには「ツールをサポートしていません」を返す
  if (/not support tools/i.test(body)) return false;
}
return res.ok;

モデルがネイティブなツール呼び出しをサポートしていない場合は、プロンプト内でXML形式のツール呼び出しにフォールバックします:

<tool_call>
{"name": "file_read", "parameters": {"path": "src/index.ts"}}
</tool_call>

同じToolRegistryが、どちらの経路も扱います。qwen2.5-coderのようなモデルはネイティブ呼び出しをサポートします。DeepSeek-R1のような推論モデルはXMLへのフォールバックが必要です(それでも、まれにしかツール呼び出しを確実に出力しません)。

7. ストリーミングのジッター:React の setState 順序バグ

これは、完全に原因を特定するのに何回かのセッションを要しました。

Ink(CLI向けのReact)は、状態の更新のたびにターミナルを再描画します。ストリーミング中は、再描画をまとめるために80msごとにテキストのデルタをバッファしています。しかし、ストリーム終了時にReactのバッチ順序に関する微妙なバグがありました:

// 壊れていたシーケンス:
// イベント: "done" → finalizeLastMessage() → setMessages({isStreaming: false})
// 最後のブロック → updateLastAssistantMessage(remainingBuffer) → setMessages({isStreaming: true})
// React は両方をバッチする → 後から来た更新が勝つ → メッセージが permanently stuck(ストリーミングのまま固定)

修正:バッファのドレインとisStreaming: falseを、単一の原子的なsetMessages呼び出しに統合します:

} finally {
  const remainingChunk = deltaBufferRef.current;
  deltaBufferRef.current = "";
  setMessages((prev) => {
    const copy = [...prev];
    const last = copy[copy.length - 1];
    // チャンクを追記し、isStreaming: false を1つの updater で設定する
    copy[copy.length - 1] = { ...last, content, isStreaming: false };
    return copy;
  });
}

別件:Ink の <Static> コンポーネントは、完了したメッセージをターミナルのスクロールバックに1回だけ書き込み、再描画はしません。生きたストリーミング領域(30行のターミナルで約22行)のみが、80msごとに再ペイントされます。これが、ターミナルUIでストリーミングのジッターを解消する方法です。

8. MCP サブプロセスのリーク

MCP(Model Context Protocol)サーバーは、stdio を介して通信する永続的な子プロセスとして動作します。最初のバージョンでは、ユーザーの送信のたびに新しい MCPRegistry を作成していました。これにより新しいサーバープロセスが起動され、古いプロセスがオーファンになって残ってしまいました。

直し方は簡単です。ポイントは、ref を使って submit 間でレジストリのインスタンスを保持することです:

const mcpRegistryRef = useRef<MCPRegistry | null>(null);

// 1回だけ接続して、ずっと再利用
if (!mcpConnectedRef.current) {
  mcpConnectedRef.current = true;
  const reg = new MCPRegistry();
  await reg.connect(settings.mcpServers);
  mcpRegistryRef.current = reg;
}

症状は微妙でした。クラッシュもエラーもない。ただ、背景でゾンビプロセスが徐々に増えていくだけ。永続的な子プロセスを管理するどんなシステムでも、確認しておく価値があります。

9. Ollama の <think> タグ形式の分岐

DeepSeek-R1 は思考トークンを生成します。呼び出し元によって、2つの異なる形式になります:

DeepSeek のクラウド API: thinking の内容は別の reasoning_content フィールドで届きます。クリーンで明確です。

Ollama の OpenAI 互換エンドポイント: 開始タグ <think> は取り除きますが、終了タグ </think> は保持します。思考の内容は、通常の content ストリームの先頭に表示され、 </think> で終わります。その後に実際の応答が続きます。

対応方法:

// DeepSeek クラウド:専用フィールド
if (chunk.choices[0].delta.reasoning_content) {
  onEvent({ type: "thinking_delta", delta: chunk.choices[0].delta.reasoning_content });
}

// Ollama:content ストリーム内で </think> の閉じタグだけを検出
if (rawContent.includes("</think>")) {
  const [thinking, response] = rawContent.split("</think>

");
  onEvent({ type: "thinking_delta", delta: thinking });
  onEvent({ type: "text_delta", delta: response });
}

ベンダーが「互換」APIを実装するとき、この種の形式の分岐はよくあります。基本ケースでは動くくらいに互換だが、エッジケースでは壊れるほど違う、という状況です。

私ならこうする

最初からスコープを絞る。 リポジトリには参考用としていくつかの CC のソースファイルが含まれていましたが、それが git で追跡されることになってしまいました。1日目から src/seed/src/vendor/ をはっきり分けていれば防げたはずです。

機能の前に統合テストを書く。 ユニットテストは、ツールキャッシュと権限システムの退行を検知してくれました。しかしエージェントループのエンドツーエンドには統合テストがありません。ここが、現時点で最大の品質ギャップです。

ターミナルと戦わない。 Ink の <Static> + プライマリバッファが正しいアーキテクチャだと気づく前に、カスタムのスクロールバーや alt-screen バッファに時間を使ってしまいました。ターミナルはそもそも、スクロールする方法をすでに知っています。

リポジトリ

GitHub: https://github.com/jiayu6954-sudo/seed-ai

TypeScript、MIT ライセンス。Node.js 20+ で動作します。Anthropic、OpenAI、DeepSeek、Groq、Gemini、Ollama、OpenRouter、Moonshot をサポートします。

もし同様のものを作っているなら、または上記の内容について質問があるなら、イシューを作成するかコメントしてください。

返却形式: {"translated": "翻訳されたHTML"}