「念頭稍縱即逝,捕捉它不該有摩擦——
brain capture讓一行指令就能把文字安全地寫成一篇合法筆記,標題、檔名與正文 H1 共用同一份字串,寫入時原子性地拒絕覆寫。」
這個系列從 Day 01 就在強調一件事:知識庫會腐敗,不是因為工具不夠好,而是因為維護它的成本壓垮了使用者。而成本最先出現的地方,往往不是「整理」,是「捕捉」——腦中一閃而過的念頭,如果要先開 Obsidian、手動新建檔案、手動填五個必填的 Frontmatter 欄位、手動確保 title 跟檔名一致,那多數念頭根本撐不到寫完這些欄位就被忘記了,使用者最後只會回頭用手機備忘錄,或乾脆放棄記錄。
Day 06 讓 brain-cli 具備了「讀」的能力:internal/vault 能解析一篇筆記、能循序掃完一座 vault,brain scan 能把結果攤開來看。但讀之前得先有東西可以讀——目前 vault 裡新增一篇草稿仍然是純手動流程。今天要補上系列一開始就承諾的「寫」路徑:一行指令,把文字安全地變成一篇合法筆記。
note-metadata-schema 明確要求 title 欄位必須跟檔名一致,正文也應該有一個跟 title 一致的 H1。這裡有兩種寫法:一種是分別產生三份字串,再寫測試互相比對是否一致;另一種是讓「標題衍生」邏輯只產出一份字串,同時餵給三個地方。internal/capture 選了後者——DeriveTitle 是唯一的標題來源:
func DeriveTitle(content string) string {
runes := []rune(content)
if len(runes) > maxTitleRunes {
runes = runes[:maxTitleRunes]
}
sanitized := unsafeCharReplacer.Replace(string(runes))
return strings.TrimSpace(sanitized)
}
規則很單純:以 rune 為單位截短到 50 字(涵蓋中文等多位元組字元),把 / \ : * ? " < > | 這些檔案系統不安全字元換成 -,最後修剪前後空白。截短刻意不做「在詞邊界斷開」之類的智慧處理——過長的標題本來就不利於檔名跟後續瀏覽,簡單粗暴截短即可,而且截短只影響標題/檔名,完整內容仍然會原封不動出現在正文,不會遺失。
BuildNote 把這份字串同時交給 Frontmatter.Title、Note.FilePath(<Title>.md):
func BuildNote(content string, now time.Time) (*vault.Note, error) {
if strings.TrimSpace(content) == "" {
return nil, errors.New("捕捉內容不可為空")
}
title := DeriveTitle(content)
note := &vault.Note{
FilePath: title + ".md",
Frontmatter: vault.Frontmatter{
ID: now.Format("20060102-150405"),
Title: title,
Date: now.Format("2006-01-02"),
Type: "inbox-draft",
Status: "seed",
Tags: []string{},
Related: []string{},
Aliases: []string{},
},
Body: content,
}
return note, nil
}
Render 再把同一個 Frontmatter.Title 寫成正文的 H1:
buf.WriteString("# ")
buf.WriteString(note.Frontmatter.Title)
三處只有一份資料來源,結構上就不可能出現「檔名跟 title 對不上」這種問題,不需要額外寫一致性檢查測試。tags/related/aliases 也刻意明確序列化成 [] 而不是省略欄位——capture 產生的筆記是給人事後編輯的起點,明列欄位能讓使用者一眼看到「還有哪些可以填」,比要求使用者自己記得 schema 定義的欄位名稱更友善。
寫入用 os.OpenFile 搭配 O_CREATE|O_EXCL|O_WRONLY,這是一個原子操作——如果目標檔案已存在,OpenFile 直接回傳 os.IsExist 錯誤,不寫入、不覆寫:
file, err := os.OpenFile(targetPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
if err != nil {
if os.IsExist(err) {
return "", fmt.Errorf("筆記已存在,拒絕覆寫: %s", targetPath)
}
return "", fmt.Errorf("建立筆記檔案失敗: %w", err)
}
這裡有另一個選項:自動在檔名後面加流水號,例如衝突時改成 <Title>-2.md。但這樣一來「檔名」跟「標題」的一致性規則會變複雜——title 欄位這時候該填哪個?id 本身已經是秒級時間戳,內部識別碼基本不會衝突,只有「同一秒內以完全相同文字連續呼叫兩次 capture」這種極端情境才會撞名。直接拒絕、提示使用者稍等一秒或調整內容,是最簡單、也最不會不小心誤植資料的處理方式。
$ go run ./cmd/brain capture 今天 學到 goroutine 用法
已捕捉筆記:vault/00_Inbox/今天 學到 goroutine 用法.md(id: 20260816-205531)
產生的檔案內容:
---
id: 20260816-205531
title: 今天 學到 goroutine 用法
date: "2026-08-16"
type: inbox-draft
status: seed
tags: []
related: []
aliases: []
---
# 今天 學到 goroutine 用法
今天 學到 goroutine 用法
再用同樣的內容呼叫一次,第二次會被拒絕,第一次寫入的檔案內容不受影響:
$ go run ./cmd/brain capture 今天 學到 goroutine 用法
Error: 筆記已存在,拒絕覆寫: vault/00_Inbox/今天 學到 goroutine 用法.md
不帶任何參數執行會被 Cobra 的 Args: cobra.MinimumNArgs(1) 擋下,不會建立任何檔案。用 brain scan 重新掃描 vault,新捕捉的筆記能被 Day 06 的解析邏輯正確讀出,確認兩個指令之間互不衝突、可以組合使用。internal/capture 的 table-driven 測試涵蓋標題截短、不安全字元置換、Frontmatter 欄位正確性、空內容錯誤、檔案已存在時的保護行為,go test ./...、gofmt -l .、go vet ./... 全數通過。
brain capture 產生的草稿會持續堆積在 00_Inbox——這是刻意的行為,tags/related/aliases 留空、正文只是原始輸入,沒有做任何智慧分段或摘要。真正的結構化與歸檔留給 Day 15 的 /refine-inbox,讓 Claude Code Agent 讀取這些草稿並整理進 PARA 目錄。Day 18 則會讓 Agent 透過 subprocess 直接呼叫今天這個 capture 指令,把「捕捉」這個動作也變成 Agent 工作流的一部分,而不只是人手動在終端機打字。
internal/vault 負責「讀」,今天的 internal/capture 負責「寫」——兩個套件各自維持單一職責,共用同一份 Frontmatter/Note 資料結構,不重新定義。捕捉本身刻意做到極簡:不做互動編輯器、不做內容品質驗證、一次只處理一份輸入,把「讓念頭活下來」這件事的摩擦降到最低,其餘留給後面的 Day 逐步補上。