picoclaw/CLAUDE.md
dj-oyu 3e482259dc docs: add third FEEDBACK round to implementation plan review
All G-1~G-4 confirmed resolved. New findings:
H-1 Phase 4-2 misidentifies Save() trigger - it is called from 5
explicit sites in loop.go (not AddFullMessage), with L996 being the
high-frequency per-turn save and L299/836/2339/2568 as semantic
checkpoints needing individual deferral decisions.
H-2 Phase 1 logger.go row has misplaced MarkStep note from Phase 3-2.
H-3 Phase 5 missing ParsedPlan in-memory model item despite Phase 3-2
referencing it as the prerequisite for mutation-path Split fix.
H-4 Phase 3 summary still shows 1 commit; FormatPlanDisplay addition
makes it 2 commits minimum.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 13:49:29 +09:00

41 KiB
Raw Blame History

CLAUDE.md

Project: picoclaw

Go-based AI agent with multi-channel messaging (Telegram, Discord, Slack, etc.) and a Telegram Mini App UI.

Build & Test

go build ./...
go test ./...
go vet ./...

Lint: golangci-lint run

Plan Mode

  • Interview tool filtering: interviewAllowedTools in pkg/agent/loop.go is the single source of truth for tools available during interview/review phases. Both filterInterviewTools (strips definitions before LLM call) and isToolAllowedDuringInterview (argument-level gating) reference this map.
  • History clear: /plan start clear wipes session history and summary on transition to executing. The Mini App review UI offers two sliders: standard approve and approve-with-clear.

Security TODOs

  • Log Fields masking: Done. SanitizeFields() in pkg/logger/logger.go masks keys matching token, key, secret, password, authorization, credential. Applied in RecentLogs() and wsLogs() stream.

Known Gaps

  • Mini App log viewer has no frontend tests: renderLogs() in pkg/miniapp/static/index.html is inline vanilla JS with no unit/E2E test coverage. Backend (Go) tests cover RecentLogs, SanitizeFields, and JSON serialization, but nothing verifies the JS rendering. This allowed the Fields display bug (fields sent but not rendered) to ship undetected.
  • No human intervention for heartbeat worktrees: Heartbeat sessions create git worktrees (.picoclaw/worktrees/heartbeat-YYYYMMDD/) but there is no CLI or Mini App command to list, inspect, or manually dispose them. Need a /plan worktrees command (or similar) that shows active worktrees with branch/commit info and allows manual merge/dispose. PruneOrphaned on startup only removes directories without auto-committing first, so uncommitted changes in orphaned worktrees are silently lost.

Memory Optimization Candidates

Reviewed 2026-02-24 on branch memory-optimization-review. False positives included intentionally. Legend: 🔎 High / 🟡 Medium / 🟢 Low

A. ホットパスでの文字列結合 (strings.Builder 未䜿甚)

重芁床 ファむル 行 内容
🔎 pkg/tools/web.go 73-85 BraveSearchProvider.Search() — slice append + Join を Builder に
🔎 pkg/tools/web.go 155-167 TavilySearchProvider.Search() — 同䞊パタヌン
🔎 pkg/tools/web.go 211-254 DuckDuckGoSearchProvider.extractResults() — ルヌプ内 append+Join
🔎 pkg/tools/web.go 592-617 WebFetchTool.extractText() — cleanLines を Builder で
🔎 pkg/skills/loader.go 234-250 BuildSkillsSummary() — []string + Join で XML 組み立お (芁玠数×アロケヌション) → Builder ぞ
🔎 pkg/channels/telegram.go 789-806 extractCodeBlocks() — codes スラむス無容量 + ReplaceAllStringFunc の fmt.Sprintf
🔎 pkg/channels/telegram.go 813-830 extractInlineCodes() — 同䞊パタヌン
🟡 pkg/agent/context.go 247 BuildSystemPrompt() — systemPrompt += で連結 → Builder ぞ
🟡 pkg/logger/logger.go 241-246 formatFields() — parts slice + Join → Builder ぞ
🟡 pkg/skills/loader.go 217-225 LoadSkillsForContext() — parts + Join → Builder ぞ
🟡 pkg/channels/discord.go 162-168 appendContent() — + 挔算子で結合 → Builder ぞ
🟡 pkg/channels/slack.go 234-272 handleMessageEvent() — ルヌプ内文字列連結 → Builder ぞ
🟢 pkg/git/worktree.go 61-63 SanitizeBranchName() — strings.ReplaceAll ルヌプ

B. スラむスの事前容量確保挏れ

重芁床 ファむル 行 内容
🟡 pkg/tools/toolloop.go 87-96 RunToolLoop() — normalizedToolCalls / toolNames を make([]T, 0, 掚定倀) に
🟡 pkg/config/config.go 628 findMatches() — var matches []ModelConfig → 容量ヒントを付䞎
🟡 pkg/config/migration.go 48 ConvertProvidersToModelList() — result に make([]ModelConfig, 0, 20)
🟡 pkg/skills/registry.go 183 SearchAll() — merged に make([]SearchResult, 0, len(regs)*limit)
🟡 pkg/skills/loader.go 73 ListSkills() — skills に make([]SkillInfo, 0, 20) 皋床
🟡 pkg/channels/telegram.go 832-861 extractMarkdownTables() — out / tables スラむスに容量ヒント
🟢 pkg/skills/search_cache.go 42-43 NewSearchCache() — entries map / order slice に maxEntries をヒント
🟢 pkg/agent/session_tracker.go 121 ListActive() — result スラむスに容量ヒント

C. 䞍芁な []byte ↔ string 倉換 / 重耇倉換

重芁床 ファむル 行 内容
🔎 pkg/channels/telegram.go 1071-1111 wrapByDisplayWidth() — ルヌプ内で string(r) (rune→string) を毎むテレヌション実行
🟡 pkg/tools/web.go 545-562 WebFetchTool.Execute() — string(body) を最倧5回呌び出し → 1回に集玄
🟡 pkg/tools/web.go 289 PerplexitySearchProvider.Search() — string(payloadBytes) + strings.NewReader → bytes.NewReader を盎接䜿甚
🟢 pkg/utils/string.go 50 wrapLine() — ASCII 䞻䜓なのに []rune(line)
🟢 pkg/utils/string.go 100 Truncate() — 長さ確認前に []rune(s)
🟢 pkg/git/worktree.go 71-75 SanitizeBranchName() — ASCII 切り詰めなのに []rune
🟢 pkg/providers/claude_cli_provider.go 133 string(paramsJSON) 埌に Builder ぞ曞き蟌み → bytes.Write

D. JSON Marshal/Unmarshal の重耇・ホットパス

重芁床 ファむル 行 内容
🔎 pkg/providers/openai_compat/provider.go 274, 362, 621 ストリヌミングルヌプ内でツヌル匕数を耇数回 Unmarshal
🟡 pkg/providers/anthropic/provider.go 213 json.Unmarshal(tu.Input, &args) — map にサむズヒントなし
🟡 pkg/providers/codex_cli_provider.go 154-155 ツヌル定矩ルヌプ内で json.Marshal(parameters)

E. 倧きな struct の倀枡し / ルヌプ内コピヌ

重芁床 ファむル 行 内容
🔎 pkg/agent/session_tracker.go 125 ListActive() — *entry を倀コピヌしお append → ポむンタ slice に
🟡 pkg/session/manager.go 98-100 GetHistory() — messages 党コピヌ (スレッド安党のため意図的。COW 怜蚎)
🟡 pkg/session/manager.go 187-188 Save() — messages 党コピヌ (同䞊)
🟡 pkg/skills/registry.go 132-133 SearchAll() — []SkillRegistry を党コピヌしおからロック解陀
🟢 pkg/logger/logger.go 88-92 recent() — LogEntry を倀コピヌしお返华 → ポむンタ slice 怜蚎

F. sync.Pool / バッファ再利甚の怜蚎

重芁床 ファむル 行 内容
🟡 pkg/tools/web.go 592-617 extractText() — HTML 解析甚 Builder を sync.Pool で再利甚
🟡 pkg/channels/telegram.go 757-861 Markdown 倉換系関数矀 — メッセヌゞ毎に倚数のバッファを生成 → Pool 化
🟢 pkg/utils/download.go 43 DownloadToFile() — ゚ラヌ読み取り甚 make([]byte, 512) → 共有バッファ

G. LRU / アルゎリズムレベルの最適化

重芁床 ファむル 行 内容
🟡 pkg/skills/search_cache.go 161 moveToEndLocked() — slice slicing で O(n) LRU 曎新 → doubly-linked list で O(1) に

H. パッケヌゞレベル倉数化 (関数呌び出しのたびに再生成)

重芁床 ファむル 行 内容
🟢 pkg/utils/media.go 18-19 IsAudioFile() — audioExtensions / audioTypes スラむスを毎回生成 → var に
🟢 pkg/skills/clawhub_registry.go 114 fmt.Sprintf("%d", limit) → strconv.Itoa(limit)

H. 重耇 strings.Split / Join (memory.go)

重芁床 ファむル 行 内容
🟡 pkg/agent/memory.go 233, 285, 352, 381 extractPhaseContent / GetPlanPhases / MarkStep / AddStep — 同䞀 MEMORY.md を関数毎に Split → 統合 or キャッシュ

蚭蚈レベルの根本原因 — 「芋萜ずし」ではなく「構造的に䞍可避」な問題

個別の最適化候補の倚くは、曞いた人の䞍泚意ではなく、蚭蚈䞊の遞択が特定のアロケヌションパタヌンを必然的に匕き起こしおいるこずが読み取れる。以䞋はその根本原因を蚭蚈レベルで敎理したもの。

D-1. MemoryStore が「ファむル = 正」の蚭蚈で、パヌス枈み衚珟をキャッシュできない

MemoryStore の各メ゜ッドはほが党員が ReadLongTerm() → strings.Split() → scan → strings.Join() を独立しお実行する。GetMemoryContext() を1回呌ぶだけで、内郚で ReadLongTerm() が3回以䞊呌ばれる連鎖が起きる。

GetMemoryContext()
  └─ HasActivePlan()    → ReadLongTerm() → ファむルI/O
  └─ GetPlanStatus()   → ReadLongTerm() → ファむルI/O
  └─ GetPlanContext()  → ReadLongTerm() → ファむルI/O
       └─ GetCurrentPhase() → ReadLongTerm() → ファむルI/O
       └─ GetTotalPhases()  → ReadLongTerm() → ファむルI/O

なぜこうなったか: MEMORY.md をナヌザヌが盎接線集できる倖郚ファむルずしお蚭蚈したため、「ファむルが垞に最新の正」ずいう前提が成立しおいる。むンメモリキャッシュを持぀ず倖郚線集が反映されなくなる恐れがあり、キャッシュを自然に導入できない。

蚭蚈䞊の遞択肢: (a) content を匕数ずしお受け取る内郚 pure function 矀 + 高レベルメ゜ッドだけが1回 ReadLongTerm() を呌ぶ、(b) りォッチ付きキャッシュ (fsnotify)、(c) ゚ヌゞェントルヌプ内で1タヌンに1回だけ読む「タヌンスコヌプキャッシュ」。


D-2. FunctionCall.Arguments が JSON 文字列のたた型ずしお定矩されおいる

// protocoltypes/types.go
type FunctionCall struct {
    Name      string `json:"name"`
    Arguments string `json:"arguments"`  // ← ワむダフォヌマット (JSON文字列) をそのたたドメむン型に
}

ツヌル匕数はワむダ䞊 "arguments": "{\"key\":\"value\"}" の圢で届くが、この型定矩はその文字列をそのたた保持する。䜿う偎は毎回 json.Unmarshal([]byte(tc.Function.Arguments), &args) しなければならず、これがストリヌミングルヌプ内の重耇 Unmarshal の根本原因になっおいる。

察比: ToolCall.Arguments map[string]any json:"-" ずいうパヌス枈みフィヌルドは存圚するが、openai_compat の streaming path ではこの map[string]any フィヌルドではなく Function.Arguments string から盎接読んでいる。䞡方のフィヌルドが䞭途半端に共存しおいる。


D-3. ToolFunctionDefinition.Parameters が map[string]any で、シリアラむズ枈み圢匏を保持できない

type ToolFunctionDefinition struct {
    Name        string         `json:"name"`
    Description string         `json:"description"`
    Parameters  map[string]any `json:"parameters"`  // ← プロバむダヌぞ送るたびに Marshal が必芁
}

ツヌル定矩ぱヌゞェント起動時に䞀床決たり、実行䞭は倉化しない。しかし map[string]any ずしお保持しおいるため、各プロバむダヌぞの送信のたびに json.Marshal → string 倉換が発生する。json.RawMessage にしおおけば「䞀床 marshal したバむト列をそのたた耇数プロバむダヌぞ流す」蚭蚈が可胜になる。


D-4. 怜玢プロバむダヌ矀に共通フォヌマット抜象がなく、同じ欠陥が3箇所に耇補されおいる

BraveSearchProvider, TavilySearchProvider, DuckDuckGoSearchProvider は党お独立しお「結果 → 文字列」の倉換ロゞックを実装しおいる。共通の ResultFormatter むンタヌフェヌスや formatSearchResult(title, url, snippet string) ヘルパヌがないため、同じ []string + strings.Join パタヌンが3箇所に独立しおコピヌされた。最適化挏れも3箇所に同時に発生する。

蚭蚈の瀺唆: プロバむダヌの Search() 戻り倀を string にせず構造䜓 ([]SearchResult) にしお、フォヌマットを呌び出し偎に移譲する蚭蚈なら、フォヌマットロゞックは1箇所で枈む。


D-5. Session.Messages が可倉スラむスで、読み取りに構造的な党コピヌが必芁

type Session struct {
    Messages []providers.Message  // ← 可倉。append で远蚘される
}

func (sm *SessionManager) GetHistory(key string) []providers.Message {
    history := make([]providers.Message, len(session.Messages))
    copy(history, session.Messages)  // ← 安党のために必須
    return history
}

session.Messages は append で远蚘される可倉スラむスで、倖郚から参照を枡すず内郚状態が壊れるリスクがある。そのため GetHistory(), Save(), SetHistory() の党おでコピヌが必芁になる。コメントにも「to strictly isolate internal state from the caller's slice」ず明蚘されおおり、これは意図的な蚭蚈だがコピヌコストを構造的に固定しおいる。

代替蚭蚈: メッセヌゞログを append-only な䞍倉構造 ([]*Message のリンクリストや、むンデックスで管理するリングバッファ) にすれば、参照の共有が安党になりコピヌを排陀できる。


D-6. MemoryStore のメ゜ッド境界が「ファむル操䜜単䜍」で切られおおり、呌び出し偎が合成できない

// 呌び出し偎は content を持おないため、内郚で毎回 ReadLongTerm() を呌ぶ
phases := ms.GetPlanPhases()       // ReadLongTerm() 内包
current := ms.GetCurrentPhase()    // ReadLongTerm() 内包
status := ms.GetPlanStatus()       // ReadLongTerm() 内包

各 public メ゜ッドが「ファむルを読んでパヌスしお1぀の倀を返す」単䜍で蚭蚈されおいるため、呌び出し偎は耇数の倀が必芁なずきでもメ゜ッドを耇数回呌ぶしか遞択肢がない。content を受け取る private 関数矀 (extractPhaseContent(content, phase) など) は存圚するが、public API からは䜿えない。


コヌドのにおい — 芋萜ずしやすいパタヌン集

䞊蚘の個別発芋を暪断しお芋るず、このコヌドベヌスに繰り返し珟れる7぀の構造的なにおいがある。新しいコヌドを曞くずき・レビュヌするずきのチェックリストずしお䜿う。

1. 「先に集めおから結合」パタヌン ([]string + strings.Join)

// においのある曞き方
var parts []string
for _, x := range items {
    parts = append(parts, fmt.Sprintf("...%s...", x))
}
return strings.Join(parts, "\n")

var parts []string → ルヌプ内 append → 最埌に strings.Join ずいう3ステップの流れ。芋た目が敎理されおいるため気づきにくいが、䞭間スラむスず最終結合の2回アロケヌションが発生する。strings.Builder に䞀本化すれば1回で枈む。web.go の怜玢プロバむダヌ4箇所、logger.go、skills/loader.go など蚈10箇所以䞊で芳察された。

2. 「倉換しおから枡す」パタヌン ([]byte ↔ string の橋枡し)

// においのある曞き方
payload, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", url, strings.NewReader(string(payload)))
//                                    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
//                                    []byte → string → io.Reader ず2段倉換

json.Marshal は []byte を返すのに、盎埌に string() ぞキャストしお strings.NewReader に枡す。bytes.NewReader(payload) で倉換れロで枈む。web.go の Perplexity プロバむダヌ、各 CLI プロバむダヌで芳察された。

3. 「ルヌプ内で静的なものを毎回生成」パタヌン

// においのある曞き方
for _, tool := range tools {
    paramsJSON, _ := json.Marshal(tool.Parameters) // ← ルヌプ内 Marshal
    prompt += fmt.Sprintf("...", string(paramsJSON))
}

ルヌプ内で毎むテレヌション行われる凊理のうち、入力が倉わらないものが含たれおいないかを疑う。兞型䟋

  • ルヌプ内での json.Marshal (匕数が定数的なずき)
  • ルヌプ内での string(rune) 倉換 (1文字ず぀倉換)
  • ルヌプ内でのスラむス/マップリテラル生成

telegram.go の wrapByDisplayWidth、openai_compat の streaming ルヌプ、codex の tool 定矩ルヌプで芳察された。

4. 「防衛的コピヌが広すぎる」パタヌン (スレッド安党の過剰適甚)

// においのある曞き方
func (m *Manager) GetHistory() []Message {
    m.mu.RLock()
    defer m.mu.RUnlock()
    result := make([]Message, len(m.messages))
    copy(result, m.messages)   // ← 党件コピヌしおからロック解陀
    return result
}

䞊行安党のため slice 党䜓を防衛的にコピヌするのは正しいが、コピヌ範囲が呌び出し偎の実際の甚途より広いこずがある。読み取り専甚なら sync.RWMutex + ポむンタ返华 + immutable 制玄、たたは Copy-on-Write で代替できる堎合がある。session/manager.go の GetHistory・Save で芳察された。

5. 「ファむルを読むたびにパヌス」パタヌン (ステヌトレスな繰り返しパヌス)

// においのある曞き方
func GetPlanPhases(content string) []string {
    lines := strings.Split(content, "\n")   // ← 呌び出し毎にフルスキャン
    ...
}
func MarkStep(content, step string) string {
    lines := strings.Split(content, "\n")   // ← 同じ content を再床スキャン
    ...
}

同䞀のファむル内容を受け取る耇数の関数がそれぞれ独立しお strings.Split → スキャン → strings.Join しおいる。呌び出し偎でパヌス枈み衚珟行スラむスなどを保持しお枡すか、パヌス結果をキャッシュする蚭蚈にするず耇数回のアロケヌションを削枛できる。memory.go の4関数で芳察された。

6. 「var x []T から始たる容量なし append」パタヌン

// においのある曞き方
var result []ModelConfig          // cap=0 から開始
for _, p := range providers {
    result = append(result, ...)  // 倍々に再アロケヌション
}

var x []T や make([]T, 0) で始たり、ルヌプ内で append を重ねる。゜ヌスの長さが事前にわかっおいる堎合別スラむスの len、定数䞊限などは make([]T, 0, n) で初期容量を䞎えれば再アロケヌションをれロにできる。芋萜ずされやすい理由は「append は自動で䌞びるから倧䞈倫」ずいう習慣。config/migration.go、skills/registry.go、skills/loader.go ほか6箇所で芳察された。

7. 「Unicode 安党のための過剰な []rune 倉換」パタヌン

// においのある曞き方
func Truncate(s string, max int) string {
    runes := []rune(s)       // ← 党文字を倉換しおから長さ確認
    if len(runes) <= max {
        return s
    }
    return string(runes[:max])
}

文字数を正しく数えるために []rune ぞ倉換するのは正しい。しかし ①倉換前に len(s) で byte 長をチェックしお早期 return できるASCII なら byte 長 == rune 長、②実際の入力が ASCII 䞻䜓であれば utf8.RuneCountInString + utf8.RuneError チェックでアロケヌションなしに凊理できる。[]rune(s) は文字列党䜓をヒヌプにコピヌするため、長い文字列では無芖できないコストになる。utils/string.go の2関数、git/worktree.go で芳察された。


ストレヌゞ保護蚭蚈 — 曞き蟌みの遅延・バッチ化

远蚘 2026-02-24。microSD䞊で動䜜する前提でのFS曞き蟌み最適化。

「誰がこのデヌタを必芁ずするか」マップ

珟状の氞続化デヌタを消費者ず曞き蟌み頻床で敎理するず、曞き蟌みを遅延できる䜙地が倧きく異なる。

デヌタ プロセス内読者 プロセス倖読者 曞き蟌み頻床(珟状) 損倱蚱容床
sessions/*.json AgentLoop (タヌン毎 GetHistory) なし起動時ロヌドのみ メッセヌゞ毎 䞭䌚話消倱は困るが臎呜ではない
state/stats.json StatusAPI, Mini Appin-process CLI cmd_status LLM呌び出し毎 + ナヌザヌメッセヌゞ毎 䜎数件のロスは蚱容
memory/MEMORY.md AgentLoop (タヌン毎) CLI, Mini App, 倖郚゚ディタ ステップ完了毎・LLM edit_file 高プラン状態が倱われるず埩垰䞍胜
memory/YYYYMM/DD.md AgentLoopプランなし時 倖郚゚ディタ 日次ノヌト远蚘時䜎頻床 䜎

重芁な芳察: セッションファむルはプロセス内専甚デヌタ

sessions/*.json は皌働䞭に倖郚プロセスが読たない。唯䞀の利甚タむミングは起動時の loadSessions()。぀たり曞き蟌みの目的は「クラッシュリカバリ」だけであり、メッセヌゞ毎の即時曞き蟌みは過剰。

同様に state/stats.json も、Mini App や CLI はプロセス内の Tracker.GetStats() 経由でメモリから読む。ファむルはプロセス再起動時の匕き継ぎ専甚。

掚奚曞き蟌み戊略

sessions/*.json — Write-behind (ダヌティフラグ + 定期フラッシュ)

AddFullMessage() → in-memory のみ曎新、dirty フラグ立お
                                ↓
                  定期タむマヌ (5分) or メッセヌゞ数閟倀 (20ä»¶)
                  たたはシャットダりンフック → Save()
  • リカバリりィンドり: 最倧5分 or 20メッセヌゞ分
  • 曞き蟌み回数削枛率: 䌚話速床次第だが 10〜50倍
  • 実装: SessionManager に dirtyKeys map[string]bool + バックグラりンドフラッシャヌgoroutine

state/stats.json — 定期フラッシュのみ

RecordUsage() / RecordPrompt() → in-memory のみ曎新
                                       ↓
                         定期タむマヌ (5分) → save()
                         + シャットダりンフック
  • 損倱リスク: 最倧5分分の統蚈カりント蚱容範囲
  • 曞き蟌み回数削枛率: LLM呌び出し頻床 × 5分 = 数十〜数癟倍

memory/MEMORY.md — タヌンスコヌプキャッシュ (曞き蟌みは即時維持)

曞き蟌みは珟状通り即時。読み取りの問題だけ解決する。

゚ヌゞェントタヌン開始 → content := ReadLongTerm() を1回だけ
                         ↓ content を匕数ずしお党ヘルパヌに枡す
                         (HasActivePlan(content), GetPlanStatus(content), ...)
゚ヌゞェントタヌン終了 → content キャッシュ砎棄
  • 倖郚゚ディタずの敎合: タヌン境界でリフレッシュされるので1タヌン以内の倖郚線集のみ芋逃す蚱容範囲
  • LLM の edit_file 経由の曞き蟌み: ファむルシステムに即座に曞かれるため次タヌンで自動反映
  • 読み取り回数削枛: 1タヌンあたり 5回以䞊 → 1回

microSD 寿呜ぞの圱響詊算

䞀般的な䌚話セッション1時間、60メッセヌゞ、10 LLM呌び出し/分の堎合:

デヌタ 珟状の曞き蟌み回数/時 改善埌 削枛率
sessions/*.json ~60回 (メッセヌゞ毎) ~12回 (5分毎) 80%æž›
stats.json ~660回 (LLM呌+prompt毎) ~12回 (5分毎) 98%æž›
MEMORY.md ステップ数分倉わらず 同巊 —
合蚈 720+ 回/時 ~24回/時 97%æž›

実装䞊の泚意点

  • シャットダりンフック必須: SIGTERM / SIGINT で dirty なデヌタを匷制フラッシュ。フラッシュ倱敗時はログに蚘録。
  • クラッシュ埌のリカバリ: dirty デヌタが倱われた堎合、セッション履歎は最埌のチェックポむント以降が消える。ナヌザヌぞの通知が必芁か怜蚎。
  • フラッシュ䞭の競合: フラッシュgoroutineず Save() の同時呌び出しを防ぐため、既存の mutex を流甚。
  • MEMORY.md のタヌンキャッシュ: edit_file ツヌルが MEMORY.md を曞き蟌んだ堎合、同タヌン内のキャッシュを無効化する仕組みが必芁MemoryStore.InvalidateCache() を edit_file のコヌルバックから呌ぶなど。そうしないず同タヌン内の埌続の GetPlanStatus() などが叀いキャッシュを読む。

RAM が最沢な堎合の蚭蚈倉曎

察象デバむスは RAM 7GB / available 5GB 超䟋: free -m で available ~5260MB。 この前提が䞊蚘の各戊略に䞎える圱響を敎理する。

読み取りレむテンシの実態

buff/cache が 4.6GB 皋床を占めるずいうこずは、OS のペヌゞキャッシュが空き RAM をほが党お䜿い切っおいる状態。ReadLongTerm() の耇数回呌び出しは実際にはディスクアクセスしおいない2回目以降はペヌゞキャッシュヒット、マむクロ秒オヌダヌ。

読み取りの実コストは「ディスクI/O」ではなく「syscall + 文字列 Split/Join のアロケヌション」。タヌンスコヌプキャッシュの䞻な効果はレむテンシ削枛よりGC 圧力の軜枛に倉わる。

曞き蟌み寿呜はRAMに圱響されない

曞き蟌みは O_SYNC ではなくおも os.Rename でアトミックに曞かれるが、カヌネルはラむトバックキャッシュを経由しお最終的に SD に曞く。ペヌゞキャッシュが曞き蟌みを吞収しおも最終的な NAND ぞの曞き蟌み回数は倉わらない。曞き蟌み削枛の優先床は倉わらず高い。

むンメモリ衚珟の垞駐が珟実的になる

RAM が逌迫しおいない堎合、MemoryStore に *ParsedPlan をフィヌルドずしお持たせる蚭蚈D-1, D-6 の解決策のメモリコストは無芖できる。MEMORY.md が数KB〜数十KB であっおも、パヌス枈み構造䜓ずしお垞駐させお差し支えない。

// 蚭蚈案: MemoryStore がパヌス枈み状態を保持
type MemoryStore struct {
    workspace  string
    memoryFile string
    mu         sync.RWMutex
    cached     *ParsedPlan  // nil = 未ロヌド
    cachedAt   time.Time
}
// edit_file ツヌルが曞き蟌んだ埌に InvalidateCache() を呌ぶこずで
// 同タヌン内の再読み蟌みをトリガヌできる

これにより GetMemoryContext() 内の ReadLongTerm() 倚重呌び出し問題D-1ず、 public メ゜ッドが content を隠す問題D-6が同時に解消される。

write-behind 窓をさらに広げられる

RAM が十分にあるため、セッションデヌタを長時間むンメモリに保持するリスクがない。 write-behind の戊略を「5分 or 20件」から**「グレヌスフルシャットダりン時のみ + 30分タむマヌ」**に緩和しおも、 クラッシュ時の損倱最倧30分の䌚話ず実装の単玔さのトレヌドオフずしお蚱容できる可胜性がある。 プロゞェクトの可甚性芁件に応じお刀断する。

優先実装順の修正

RAM 制玄がない前提での掚奚順:

  1. stats.json の write-behind — 実装が最も単玔タむマヌ1本远加、曞き蟌み削枛率が最倧98%
  2. sessions/*.json の write-behind — セッション単䜍の dirty フラグ + シャットダりンフック
  3. MemoryStore ぞの *ParsedPlan 垞駐 — D-1/D-6 を根本解決、読み取りアロケヌションをれロに
  4. タヌンスコヌプキャッシュ — 3 が実装されれば自然に解決するため䞍芁になる可胜性あり

改修蚈画 — メモリ最適化の実装フェヌズ

䜜成 2026-02-24。レビュヌ結果 (A〜H + D-1〜D-6 + ストレヌゞ保護) を実装可胜な単䜍に分割。 各フェヌズは go build ./... && go test ./... && go vet ./... が通る状態で完結する。

フェヌズ 0: 機械的な眮き換え (䜎リスク・高カバレッゞ)

目的: コヌド構造を倉えず、同じ関数内でパタヌンを眮き換えるだけの修正。レビュヌが容易で回垰リスクが最小。

0-1. strings.Builder 眮き換え (カテゎリ A 残り)

ファむル 関数 優先床
pkg/tools/web.go BraveSearchProvider.Search() L73-85 🔎
pkg/tools/web.go TavilySearchProvider.Search() L155-167 🔎
pkg/tools/web.go DuckDuckGoSearchProvider.extractResults() L211-254 🔎
pkg/tools/web.go WebFetchTool.extractText() L592-617 🔎
pkg/skills/loader.go BuildSkillsSummary() L234-250 🔎
pkg/agent/context.go BuildSystemPrompt() L247 — += を Builder に 🟡
pkg/logger/logger.go formatFields() L241-246 🟡
pkg/skills/loader.go LoadSkillsForContext() L217-225 🟡
pkg/channels/telegram.go extractCodeBlocks() L789-806 — codes 無容量 + ルヌプ内 fmt.Sprintf 🔎
pkg/channels/telegram.go extractInlineCodes() L813-830 — 同䞊パタヌン 🔎
pkg/channels/discord.go appendContent() L162-168 🟡
pkg/channels/slack.go handleMessageEvent() L234-272 🟡

0-2. スラむス事前容量 (カテゎリ B 残り)

ファむル 倉曎
pkg/skills/loader.go:73 make([]SkillInfo, 0) → make([]SkillInfo, 0, 20)
pkg/config/config.go:628 var matches → make([]ModelConfig, 0, 4)
pkg/config/migration.go:48 var result → make([]ModelConfig, 0, 20)
pkg/skills/registry.go:183 var merged → make([]SearchResult, 0, len(regs)*limit)
pkg/skills/search_cache.go:42-43 map/slice に maxEntries ヒント

0-3. byte/string 倉換の削枛 (カテゎリ C)

ファむル 倉曎
pkg/tools/web.go:289 strings.NewReader(string(payloadBytes)) → bytes.NewReader(payloadBytes)
pkg/tools/web.go:545-562 耇数の string(body) → 1回だけ倉換しお倉数に保持
pkg/providers/claude_cli_provider.go:133 string(paramsJSON) → sb.Write(paramsJSON)
pkg/utils/string.go:100 Truncate() — len(s) <= max で早期 return (ASCII fast path)
pkg/utils/string.go:50 wrapLine() — 同䞊 ASCII fast path
pkg/git/worktree.go:71-75 []rune → byte 長チェックで早期 return
pkg/channels/telegram.go:1071-1111 wrapByDisplayWidth() — ルヌプ内 string(r) を displayWidth の匕数を rune に倉曎しお排陀

0-4. パッケヌゞ倉数化 (カテゎリ H)

ファむル 倉曎
pkg/utils/media.go:18-19 audioExtensions/audioTypes を関数倖の var に
pkg/skills/clawhub_registry.go:114 fmt.Sprintf("%d", limit) → strconv.Itoa(limit)

コミット単䜍: 0-1, 0-2, 0-3, 0-4 をそれぞれ個別コミット。


フェヌズ 1: 倀枡し・コピヌの最適化 (カテゎリ E)

目的: struct の䞍芁なコピヌを削枛。型シグネチャが倉わるため呌び出し偎の修正が必芁。

ファむル 倉曎 泚意
pkg/agent/session_tracker.go:125 ListActive() の戻り倀を []*SessionEntry に 呌び出し偎 (cmd_gateway.go, miniapp.go) の型合わせ
pkg/logger/logger.go:88-92 リングバッファ内郚型を []*LogEntry に倉曎 + visit(fn) メ゜ッド远加。RecentLogs() を visit ベヌスに曞き換え (フィルタで匟く゚ントリのコピヌを排陀) add() 毎に1ヒヌプアロケヌション増だがログI/Oパスなので蚱容。ミュヌテヌション系 (MarkStep 等) の Split は察象倖
pkg/skills/registry.go:132-133 SearchAll() 内の registries コピヌをポむンタスラむスに ロック範囲の再確認

コミット: 1぀にたずめる。


フェヌズ 2: JSON ホットパスの最適化 (カテゎリ D)

目的: ストリヌミングルヌプ内の重耇 Marshal/Unmarshal を排陀。

2-1. openai_compat streaming の Arguments 重耇 Unmarshal

pkg/providers/openai_compat/provider.go L274, 362, 621 — ストリヌム完了時に1回だけ Unmarshal するよう制埡フロヌを敎理。

2-2. codex CLI の Parameters 重耇 Marshal

pkg/providers/codex_cli_provider.go:154-155 — ツヌル定矩はルヌプ倖で1回 Marshal しおキャッシュ、たたはルヌプ内で json.RawMessage 盎接曞き蟌み。

※ claude_cli_provider.go:133 の string(paramsJSON) → sb.Write(paramsJSON) は byte/string 倉換の問題であり Phase 0-3 で察応枈み。

コミット: 2-1, 2-2 を個別。


フェヌズ 3: MemoryStore の読み取り最適化 (蚭蚈 D-1, D-6)

目的: GetMemoryContext() 1回で ReadLongTerm() が 5回以䞊呌ばれる問題を解消。

3-1. GetMemoryContext() を content パススルヌ方匏にリファクタ

既存の GetMemoryContext() を盎接曞き盎す新関数は远加しない。 内郚で ReadLongTerm() を1回だけ呌び、取埗した content を既存の private ヘルパヌ矀に枡す。

func (ms *MemoryStore) GetMemoryContext() string {
    content := ms.ReadLongTerm()
    if content == "" { return "" }
    hasPlan := hasActivePlanFrom(content)
    status  := getPlanStatusFrom(content)
    // ... content を各ヘルパヌに枡す
}

既存の public メ゜ッド (HasActivePlan(), GetPlanStatus() 等) は互換性のため残す単䜓テスト・CLI から個別に呌ばれる。

FormatPlanDisplay() も同じ倚重 ReadLongTerm() 問題を持぀HasActivePlan, GetPlanStatus, GetCurrentPhase, GetPlanPhases を個別に呌ぶ。同様に content パススルヌ方匏にリファクタする。

3-2. Split 重耇の統合 (読み取りパスのみ)

content パススルヌで解消されるのは ReadLongTerm() の倚重呌び出しのみ。 extractPhaseContent(), GetPlanPhases() 等が個別に strings.Split する問題は残る。

察応: GetMemoryContext() / FormatPlanDisplay() 内で1回 strings.Split(content, "\n") し、[]string (行スラむス) を受け取る内郚ヘルパヌを远加。既存の content string を受け取るヘルパヌは互換性のため残す。

効果範囲の限定: この統合が効くのは読み取り専甚メ゜ッド (GetPlanContext, GetReviewContext, FormatPlanDisplay 等) のみ。ミュヌテヌション系 (MarkStep, AddStep) は GetMemoryContext() を経由せず盎接 ReadLongTerm() + Split + WriteLongTerm() を実行するため、この Phase では察象倖。ミュヌテヌション系の Split 統合には ParsedPlan むンメモリモデル (Phase 5) が必芁。

コミット: 1぀。


フェヌズ 4: ストレヌゞ保護 — write-behind (蚭蚈セクション)

目的: microSD 曞き蟌み回数を 97% 削枛。

4-1. stats.json の write-behind

  • stats.Tracker に dirty フラグ + タむマヌ (5分)
  • RecordUsage() / RecordPrompt() はむンメモリのみ曎新
  • シャットダりンフックで匷制フラッシュ
  • 実装量: 最小。タむマヌ goroutine 1本 + Close() メ゜ッド

4-2. sessions/*.json の write-behind

  • SessionManager に dirtyKeys map[string]bool + バックグラりンドフラッシャヌ
  • AddFullMessage() → dirty 蚘録のみ、Save() はフラッシャヌから呌ぶ
  • フラッシュ間隔: 5分 or 20メッセヌゞ
  • シャットダりンフックで党 dirty セッションをフラッシュ

AppendToday() に぀いお

AppendToday() (日次ノヌト远蚘) は write-behind の察象倖ずする。理由:

  • 曞き蟌み頻床が䜎い日次ノヌト远蚘時のみ
  • 曞き蟌み内容がナヌザヌの手動確認察象であり、即時反映が望たしい
  • sessions/stats ず異なり、遅延のメリットが小さい

コミット: 4-1, 4-2 を個別。


フェヌズ 5: 発展的最適化 (任意)

実装コストが高い or 効果が限定的なもの。必芁に応じお着手。

項目 内容 芋送り理由
F: sync.Pool web.go extractText, telegram.go Markdown 倉換 呌び出し頻床が䜎く Pool の効果が薄い可胜性
G: LRU O(1) 化 search_cache.go を doubly-linked list に maxEntries=100 で O(n) でも十分高速
D-2: FunctionCall.Arguments 型倉曎 string → json.RawMessage 党プロバむダヌに波及、砎壊的倉曎
D-3: Parameters を RawMessage に 同䞊 同䞊
D-5: Session.Messages を immutable に COW or linked list セッション管理の根本再蚭蚈が必芁

実装順サマリヌ

Phase 0 ──→ Phase 1 ──→ Phase 2 ──→ Phase 3 ──→ Phase 4
 機械的      倀枡し       JSON       Memory      Storage
 眮き換え    最適化     ホットパス    読み取り    write-behind
 (4 commits) (1 commit) (2 commits) (1 commit)  (2 commits)
  • Phase 0〜2: アロケヌション削枛 (GC 圧力軜枛)
  • Phase 3: syscall + Split/Join 削枛 (CPU + アロケヌション)
  • Phase 4: ディスク曞き蟌み削枛 (microSD 寿呜保護)
  • Phase 5: 必芁に応じお個別刀断

FEEDBACK — 第3回レビュヌ

レビュヌ日 2026-02-24。G-1〜G-4 の反映を確認し、新たに発芋した問題を蚘茉。

前回指摘の反映確認: G-1 ✅ G-2 ✅ G-3 ✅ G-4 ✅ — å…š4件が正しく反映されおいる。


H-1. Phase 4-2: Save() の呌び出し元は AddFullMessage() ではなく loop.go の5箇所

蚈画には「AddFullMessage() → dirty 蚘録のみ」ずあるが、実際には AddFullMessage() は Save() を呌ばない。Save() は loop.go から盎接5箇所で呌ばれおいる。

行 文脈
L299 /plan start clear でセッション履歎をクリアした盎埌
L836 壊れた tool call グルヌプを sanitize した盎埌
L996 ゚ヌゞェントタヌン終了時 (最も頻繁、毎タヌン実行)
L2339 匷制履歎圧瞮の盎埌
L2568 サマリヌ生成・履歎トランケヌトの盎埌

L996 がほが毎タヌン実行される䞻芁な曞き蟌み源。残り4箇所は䜎頻床だが意味的に重芁な状態倉曎クリア・圧瞮・サマリヌのチェックポむント。

修正方針: 「AddFullMessage() → dirty 蚘録」ずいう蚘述を「loop.go の Save() 呌び出し箇所を dirty マヌクに眮き換える」に蚂正する。䜎頻床の4箇所L299/836/2339/2568は意味的なチェックポむントのため即時曞き蟌みを維持する遞択肢もあり、刀断が必芁。

H-2. Phase 1: logger.go の行の泚釈が誀配眮

Phase 1 の logger.go 行に「ミュヌテヌション系 (MarkStep 等) の Split は察象倖」ずいう泚釈があるが、これは logger.go の倉曎内容ず無関係で Phase 3-2 の泚意曞きが誀っお混入しおいる。削陀するこず。

H-3. Phase 5: ParsedPlan むンメモリモデルが項目ずしお存圚しない

Phase 3-2 が「ミュヌテヌション系の Split 統合には ParsedPlan むンメモリモデル (Phase 5) が必芁」ず参照しおいるにもかかわらず、Phase 5 の衚に該圓項目がない。以䞋を远加するこず。

| ParsedPlan むンメモリモデル | MemoryStore にパヌス枈み構造䜓を垞駐させ MarkStep/AddStep の Split 重耇を根本解消 | 蚭蚈倉曎が広範囲、D-1/D-6 の完党解決 |

H-4. 実装順サマリヌの Phase 3 コミット数が叀い

FormatPlanDisplay() が Phase 3-1 に远加されたためスコヌプが拡倧。GetMemoryContext() ず FormatPlanDisplay() を別コミットにするなら 2 commits になる。サマリヌの (1 commit) を曎新するこず。

たずめ: 今回の修正項目

# 察象 修正内容
H-1 Phase 4-2 AddFullMessage() → loop.go の5箇所 Save() に蚘述を蚂正。L996 を dirty マヌク化、残り4箇所は芁刀断
H-2 Phase 1 logger.go 行の MarkStep 蚀及を削陀
H-3 Phase 5 ParsedPlan むンメモリモデル 項目を远加
H-4 実装順サマリヌ Phase 3 のコミット数を (1 commit) → (2 commits) に曎新