Files

274 lines
13 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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.goextract_workersai.go
// ② 下架時清本地卡(direct.go 的 removed 分支)
// ③ 萃取前後的快照比對(snapshotCardsclaude 路用)
//
// 檔名帶 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 路的**寫檔位置是 skillprompt)決定的**,它硬寫 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#1342026-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-filetask 3)。
// binPath 空或 PATH 找不到時,自動掃 claudeFallbackPathst92 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-filetemplate 既有萃取 skillcwd=監看根(template 已代裝,skill 就在 .claude/commands/)。
// --permission-mode acceptEditsheadless 無人按核准,寫卡會卡在權限確認(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/<bucket>/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
}