f10d10747c
事故:daemon 完全沒有 vault 辨識,把萃取卡片寫進使用者的 Logseq vault (system-dev/wiki/cards/ 對一般資料夾沒事,對 vault=憑空多出頁面, 2026-08-10 leo 實撞:25 個簡體字卡片污染 ~/Documents/KB)。 修法(照票上指示搬現成的,不重新設計): - collector/vault.go:DetectVaultType/IsVault,判準逐條抄自 system-dev-template/scripts/install.sh:209-221(先查 logseq/、再查 .obsidian/)。 已用同一批 fixture 資料夾跑過 install.sh 與這支 Go 版,五種情境(logseq/ obsidian/一般資料夾/兩者皆有/空資料夾)IS_VAULT 判斷逐一比對一致。 - extract.go:cardsRelDirFor() 依 IsVault 決定卡片相對路徑——非 vault 不變 (system-dev/wiki/cards/),vault 改落 .arcrun-rag/wiki/cards/(點開頭隱藏 目錄,Logseq/Obsidian 預設不掃描,跟 daemon 自己 scan.go 的隱藏目錄跳過規則 一致)。extract_workersai.go/extract_gemma.go/direct.go 的落卡與下架清除 都改用這個函式,三處對同一個 absRoot 保證同一個答案。 - safewrite.go:safeWriteCard() 取代兩處無條件 os.WriteFile——目標已存在且 內容不同就先備份成 <dest>.bak-<unixnano> 才覆寫;內容相同則不動(不產生 垃圾備份);備份失敗就整個中止,不無聲蓋掉使用者機器上已有的東西。 驗證(見 PR/commit 說明附的實測輸出): - 用 fake Logseq vault fixture 重現舊行為(卡片確實落在 system-dev/wiki/cards/), 再用同一份 fixture 驗新行為(卡片改落 .arcrun-rag/wiki/cards/,vault 根目錄 非隱藏 .md 數量不變、journals/ 原稿位元不動)。 - 故意放同名既有卡片,跑完既有內容被備份、新內容確實寫入,未無聲遺失。 - 非 vault 既有測試(extract_gemma_test.go 原有三支)全數不動照過,確認 一般資料夾行為零改變。 範圍外(留給下一輪):ExtractWithClaude(extract.go 的 claude 路)與其 templatefs/.claude/commands/rag-extract-file.md 技能檔仍硬寫 system-dev/wiki/cards/, 但這條路目前在 direct.go 的 RunDirectOnce 是不支援狀態(cfg.Extractor 只認 workers-ai/gemma),非本次事故的作用路徑,故未動。 不影響:leo 機器上的 daemon(未重啟、未重新打包);此修復要生效還要 ①總管審過併 main ②重新打包桌面版裝上他機器 ③總管用新二進位實測, 三件缺一不可(見 issue #60 leo 的重啟條件)。
151 lines
6.2 KiB
Go
151 lines
6.2 KiB
Go
// 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 決定某次萃取的卡片該落在哪個相對路徑:非 vault 用 cardsRelDir(不變),
|
||
// vault 用 vaultCardsRelDir(arcrun-rag#60)。vault 判準見 vault.go,與 install.sh 對齊。
|
||
func cardsRelDirFor(absRoot string) string {
|
||
if IsVault(absRoot) {
|
||
return vaultCardsRelDir
|
||
}
|
||
return cardsRelDir
|
||
}
|
||
|
||
// snapshotCards 記 cards/ 目前每檔 mtime+size(偵測萃取後的新卡/變卡)。
|
||
func snapshotCards(absRoot string) map[string]fileState {
|
||
out := map[string]fileState{}
|
||
base := filepath.Join(absRoot, filepath.FromSlash(cardsRelDir))
|
||
_ = filepath.Walk(base, func(p string, info os.FileInfo, err error) error {
|
||
if err != nil || info.IsDir() || 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)", cardsRelDir, string(out))
|
||
}
|
||
return cards, nil
|
||
}
|