// extract.go — 可插拔本地萃取器(daemon-beta task 3/4,四步定稿第 3 步)。 // // leo 定稿:「由現在的 gemma4 幫你本地萃,或是用你自己的訂閱帳號,例如叫起 claude 幫你萃。」 // - claude 路(task 3):執行 `claude -p "/rag-extract-file <檔>"`,cwd=監看根—— // template 既有萃取流**原樣重用**(prompt 都不搬),卡片由 CC 寫進 system-dev/wiki/cards/。 // 用戶自己的訂閱=我們零 API 成本。 // - gemma 路(task 4):Go 內直呼 Gemini API,卡片由 daemon 自己寫進 cards/。 // // 兩路的產出契約相同:回傳「本次新增/變動的卡片相對路徑」,交 task 6 推 ingest。 // 原文永不出本函式(claude 路整段在用戶機器;gemma 路內容過境 API 但不落任何雲儲存)。 package collector import ( "context" "fmt" "os" "os/exec" "path/filepath" "strings" "time" ) // claudeFallbackPaths 是 PATH 找不到 claude 時依序嘗試的絕對路徑清單。 // Finder 啟動的 GUI app 只拿最小 PATH(/usr/bin:/bin 等),brew 裝的 claude 在此找不到(t92)。 // 測試可覆蓋此變數注入暫存目錄,無需真的安裝 Claude Code。 var claudeFallbackPaths = defaultClaudeFallbackPaths() func defaultClaudeFallbackPaths() []string { home, _ := os.UserHomeDir() paths := []string{ "/opt/homebrew/bin/claude", "/usr/local/bin/claude", } if home != "" { paths = append(paths, filepath.Join(home, ".local", "bin", "claude"), filepath.Join(home, ".claude", "local", "claude"), ) } return paths } // FindClaudeBin 找可執行的 claude 二進位:先試 hint(空="claude")在 PATH; // 找不到再依序掃 claudeFallbackPaths(解決 Finder 啟動 app PATH 不含 /opt/homebrew/bin 的問題)。 // 回傳完整絕對路徑,或「全都找不到」的白話錯誤。 func FindClaudeBin(hint string) (string, error) { if hint == "" { hint = "claude" } if p, err := exec.LookPath(hint); err == nil { return p, nil } for _, p := range claudeFallbackPaths { info, err := os.Stat(p) if err == nil && !info.IsDir() && info.Mode()&0o111 != 0 { return p, nil } } tried := append([]string{hint}, claudeFallbackPaths...) return "", fmt.Errorf("找不到 Claude 指令(試了 %s)——請確認 Claude Code 已安裝,或改用 Gemma 萃取路", strings.Join(tried, "、")) } // cardsRelDir 是 template 規約的卡片產物區(相對監看根)——一般資料夾(非 vault)用這個, // 行為與改 Go 版之前完全一致。 const cardsRelDir = "system-dev/wiki/cards" // vaultCardsRelDir 是監看根**落在** Logseq/Obsidian vault 範圍內時,卡片改落的位置(arcrun-rag#60)。 // 刻意用點開頭的隱藏目錄:Logseq/Obsidian 預設都不掃描點開頭的資料夾(跟 .git/.obsidian // 同待遇),daemon 自己的 Scan() 也一樣(見 scan.go「隱藏目錄整棵跳過」)——三邊一致, // 卡片仍然落在監看根底下(呼叫端「路徑相對監看根」的假設不用改),但不會被筆記軟體 // 收編成新頁面,vault 的頁面數不會因為 daemon 跑過而增加。 const vaultCardsRelDir = ".arcrun-rag/wiki/cards" // cardsRelDirFor 決定某次萃取的卡片該落在哪個相對路徑: // 監看根既不在筆記庫、也不在版控裡=cardsRelDir(一般資料夾行為,零改變); // 落在筆記庫或版控範圍內=vaultCardsRelDir(隱藏目錄)。 // // 🔴 arcrun-rag#60 第三輪:判準從 IsVault(只看監看根**這一層**)換成 DetectVaultContext // (監看根**在不在**某個筆記庫裡)。前者在「監看根是 vault 底下的子資料夾」時回 false, // 於是卡片落回 `<監看根>/system-dev/wiki/cards/`——那個位置就在使用者的 vault 裡面, // 而且不是隱藏目錄,Logseq/Obsidian 會把每一張卡收編成一頁。全文見 vault.go 第三輪那段。 // // 🔴 arcrun-rag#105:**版控中的資料夾與 vault 同待遇**。理由是同一個形狀—— // `system-dev/wiki/cards/` 在 repo 裡不是空地,那是 template 規約路徑, // 使用者自己的卡就住在那裡;而且不管撞不撞名,往那裡寫東西就是往他的 git status // 裡塞 untracked 檔。落進 `.arcrun-rag/`(配 EnsureWorkspaceIgnored 自我忽略) // =寫得進去、又不弄髒他的版控。 func cardsRelDirFor(absRoot string) string { if DetectVaultContext(absRoot).InVault() || UnderVersionControl(absRoot) { return vaultCardsRelDir } return cardsRelDir } // cardRelFor 回傳「這份原稿萃出來的卡片」相對監看根的完整路徑。 // // 🔴 arcrun-rag#60 第二輪:**daemon 在監看根底下產生的檔名一律經過這裡**, // 誰都不准自己拼 `pageName + ".md"`。目前三個呼叫端必須拿到同一個答案, // 少一個對齊就會留下孤兒檔或清不掉的卡: // // ① 落卡(extract_gemma.go/extract_workersai.go) // ② 下架時清本地卡(direct.go 的 removed 分支) // ③ 萃取前後的快照比對(snapshotCards,claude 路用) // // 檔名帶 MachineMark 前綴=Logseq/Obsidian 的頁名變成 `arcrun-<原頁名>`, // 與使用者自己的頁面**永不同名**(上一輪漏的正是這一層,見 machinemark.go 開頭)。 func cardRelFor(absRoot, pageName string) string { return filepath.ToSlash(filepath.Join(cardsRelDirFor(absRoot), MarkName(pageName+".md"))) } // snapshotCards 記卡片產物區目前每檔 mtime+size(偵測萃取後的新卡/變卡)。 // // 🔴 這裡原本硬寫 cardsRelDir 一個目錄,兩邊都不對: // - vault 上,落卡其實去了 .arcrun-rag/wiki/cards/ ⇒ snapshot 到一個永遠是空的目錄, // diffCards 算不到新卡,claude 路必定誤報「跑完但沒有新卡片」。 // - 而 claude 路的**寫檔位置是 skill(prompt)決定的**,它硬寫 system-dev/wiki/cards/, // 連在 vault 上也一樣 ⇒ 只看 cardsRelDirFor 那一個目錄同樣會漏。 // // ⇒ 兩個目錄都掃。誰寫到哪裡都逃不掉,之後由 enforceCardMarks 統一歸位+補標記。 func snapshotCards(absRoot string) map[string]fileState { out := map[string]fileState{} for _, relDir := range []string{cardsRelDir, vaultCardsRelDir} { base := filepath.Join(absRoot, filepath.FromSlash(relDir)) _ = filepath.Walk(base, func(p string, info os.FileInfo, err error) error { if err != nil { return nil } // 🔴 inkstone/Arcrun#134(2026-08-28):`.wiki/` 整棵跳過。 // 那是規範定的卡片正式落點,不是「位置不對的卡」——而 enforceCardMarks // 會把這裡掃到的**新**檔搬去 cardRelFor(搬出隱藏目錄、加 arcrun- 前綴), // 也就是把產物送回 Scan 看得見的地方,下一輪被當新原稿再萃一次。 // 829da02 的 `before[rel]` 只擋得住「本來就在的檔」,擋不住這一輪剛寫的卡。 // 與 tidy.go collectCardItems 同一個洞,兩支都補才堵得住。 if info.IsDir() { if p != base && strings.HasPrefix(info.Name(), ".") { return filepath.SkipDir } return nil } if filepath.Ext(p) != ".md" { return nil } rel, rerr := filepath.Rel(absRoot, p) if rerr != nil { return nil } out[filepath.ToSlash(rel)] = fileState{size: info.Size(), mtime: info.ModTime().UnixNano()} return nil }) } return out } // diffCards 比對兩次快照,回傳新增或內容變動的卡片相對路徑(排序穩定交由呼叫端)。 func diffCards(before, after map[string]fileState) []string { var changed []string for p, st := range after { if b, ok := before[p]; !ok || b.size != st.size || b.mtime != st.mtime { changed = append(changed, p) } } return changed } // claudeExtractTimeout:單檔萃取上限。CC 冷啟+讀檔+寫卡實測分鐘級,給足裕度。 const claudeExtractTimeout = 10 * time.Minute // ExtractWithClaude 叫起用戶自己的 claude 跑 template 的 /rag-extract-file(task 3)。 // binPath 空或 PATH 找不到時,自動掃 claudeFallbackPaths(t92 Finder 啟動 PATH 不完整)。 // 回傳本次產出的卡片相對路徑。 func ExtractWithClaude(binPath, absRoot, relPath string) ([]string, error) { resolved, err := FindClaudeBin(binPath) if err != nil { return nil, err } before := snapshotCards(absRoot) ctx, cancel := context.WithTimeout(context.Background(), claudeExtractTimeout) defer cancel() // /rag-extract-file=template 既有萃取 skill;cwd=監看根(template 已代裝,skill 就在 .claude/commands/)。 // --permission-mode acceptEdits:headless 無人按核准,寫卡會卡在權限確認(07-24 真機 e2e 實撞); // acceptEdits 只自動放行檔案編輯、不放行任意 Bash,範圍侷限在監看根(cwd)。 cmd := exec.CommandContext(ctx, resolved, "-p", fmt.Sprintf("/rag-extract-file %s", relPath), "--permission-mode", "acceptEdits") cmd.Dir = absRoot out, err := cmd.CombinedOutput() if ctx.Err() == context.DeadlineExceeded { return nil, fmt.Errorf("claude 萃取逾時(%s)", claudeExtractTimeout) } if err != nil { snippet := string(out) if len(snippet) > 400 { snippet = snippet[len(snippet)-400:] } return nil, fmt.Errorf("claude 萃取失敗:%w;輸出尾段:%s", err, snippet) } cards := diffCards(before, snapshotCards(absRoot)) if len(cards) == 0 { return nil, fmt.Errorf("claude 跑完但 %s 沒有新卡片(輸出尾段:%.200s)", cardsRelDirFor(absRoot), string(out)) } return enforceCardMarks(absRoot, cards, before), nil } // enforceCardMarks 把「不是我們親手命名」的卡片**歸位並補上標記**。 // // 🔴 為什麼這條路需要它:claude 路的檔名與寫入目錄都是**skill 檔(prompt)決定的**, // 不是 Go 決定的(/rag-extract-file 自己寫 `system-dev/wiki/cards/<頁名>.md`, // 而且不認得 vault)。若放著不管,同一個 daemon 就會出現「gemma 路的卡有前綴、 // claude 路的卡沒有」,而且在 vault 上還會落在**可見目錄**——正好踩中 // 「一部分加一部分不加,比全不加更難分」那條紅線,且破口在 prompt 裡,改 Go 永遠修不到。 // // ⇒ **命名與落點的契約收回 Go 這一側強制執行**:不管上游寫成什麼、寫到哪, // 離開這個函式時一定是 cardRelFor 算出來的那個位置與名字。 // 目標已被佔用或搬不動時,照實回報原路徑(不覆蓋、不假裝成功),剩下的交給 tidy。 func enforceCardMarks(absRoot string, cards []string, before map[string]fileState) []string { want := cardsRelDirFor(absRoot) out := make([]string, 0, len(cards)) for _, rel := range cards { base := filepath.Base(rel) dir := filepath.ToSlash(filepath.Dir(rel)) if IsMarked(base) && dir == want { out = append(out, rel) continue } // 🔴 arcrun-rag#105 第二輪(2026-08-15):**本來就存在的檔案,一律不搬。** // // 這是 `cc6e500`(08-14,`MigrateCardNames`)同一個事故的第二次,連數字都一樣: // 那次「把 system-dev/wiki/cards/autonomy/ 整個子目錄壓平改名,16 個版控中的檔案 // 變成刪除」;今天換成 `cards/decisions/`,同樣 16 個。 // **上次只修了 MigrateCardNames,這支做一模一樣的事卻被漏掉。** // // 真兇在 `cards` 這份清單的來源:`diffCards` 把「**新增**」與「**內容變動**」 // 併成同一個回傳值,而 `snapshotCards` **同時掃 cardsRelDir 與 vaultCardsRelDir // 兩棵樹**。於是萃取 skill 正當地更新一張既有索引卡(`cards/decisions/00-INDEX.md`) // ⇒ mtime 變了 ⇒ 被當成「本次產出的卡」⇒ 判定「不在 want 目錄」⇒ 搬走改名。 // **更新一張索引卡,就會害它被踢出自己的桶子。** // 而子資料夾不是意外,是規約:`system-dev/wiki/INDEX.md` 寫著 // 「INDEX →『cards//00-INDEX.md』→ 概念原子卡」。壓平=把那條檢索鏈剪斷。 // // 🔴 分界是「**這次新生的,還是本來就在的**」,不是「在不在某個目錄底下」: // - 這次新生 ⇒ 就是 skill 剛寫的那張,歸位+加前綴(#60 防撞名、#105 落 .arcrun-rag, // 兩條保護都要留著,那是這支存在的理由) // - 本來就在 ⇒ **不管它在哪、叫什麼,都不准動**。它可能是使用者手寫的 wiki。 // `IsMarked` 分不出「上游寫錯位置的卡」與「使用者自己的檔案」,而猜錯的代價不對等: // 名字沒前綴只是難看,**搬走使用者的檔案是不可逆的**。 if _, existed := before[rel]; existed { out = append(out, rel) continue } newRel := cardRelFor(absRoot, UnmarkName(strings.TrimSuffix(base, ".md"))) if newRel == rel { out = append(out, rel) continue } from := filepath.Join(absRoot, filepath.FromSlash(rel)) to := filepath.Join(absRoot, filepath.FromSlash(newRel)) if _, err := os.Stat(to); err == nil { out = append(out, rel) // 新位置已被佔用,不覆蓋 continue } if err := os.MkdirAll(filepath.Dir(to), 0o755); err != nil { out = append(out, rel) continue } if err := os.Rename(from, to); err != nil { out = append(out, rel) // 搬不動就照實回報原路徑,不假裝成功 continue } out = append(out, newRel) } return out }