iT邦幫忙

2026 iThome 鐵人賽

DAY 7
0

「念頭稍縱即逝,捕捉它不該有摩擦——brain capture 讓一行指令就能把文字安全地寫成一篇合法筆記,標題、檔名與正文 H1 共用同一份字串,寫入時原子性地拒絕覆寫。」

捕捉摩擦:第二大腦最容易死掉的第一步

這個系列從 Day 01 就在強調一件事:知識庫會腐敗,不是因為工具不夠好,而是因為維護它的成本壓垮了使用者。而成本最先出現的地方,往往不是「整理」,是「捕捉」——腦中一閃而過的念頭,如果要先開 Obsidian、手動新建檔案、手動填五個必填的 Frontmatter 欄位、手動確保 title 跟檔名一致,那多數念頭根本撐不到寫完這些欄位就被忘記了,使用者最後只會回頭用手機備忘錄,或乾脆放棄記錄。

Day 06 讓 brain-cli 具備了「讀」的能力:internal/vault 能解析一篇筆記、能循序掃完一座 vault,brain scan 能把結果攤開來看。但讀之前得先有東西可以讀——目前 vault 裡新增一篇草稿仍然是純手動流程。今天要補上系列一開始就承諾的「寫」路徑:一行指令,把文字安全地變成一篇合法筆記。

標題、檔名、正文 H1:共用同一份字串,而不是分別產生再互相檢查

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.TitleNote.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 ./... 全數通過。

銜接後續 Day

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 逐步補上。


上一篇
brain-cli 登場:用 Go 打造高效率 Vault 處理器
系列文
打造 AI Agent 驅動的第二大腦:用 Go + Claude Code + Obsidian + Graphify 打造工程師知識作業系統7
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言