Files
arcrun-collector/extract.go
T
Leo 4d3a6a09a6 v0.18.9:collector 併進同一支執行檔——磁碟上不再攤出第二支 exe
leo 08-06 裁決:「不要兩支,寫成一支檔案」。

## 為什麼
v0.18.7-8 的「單一 exe」其實是**一支包著另一支**:collector.exe 被 go:embed
進 Arcrun.exe,執行時攤到 ~/.arcrun-rag/bin/ 再跑。
那正是防毒軟體眼中的 dropper 特徵 —— 封測者實撞
`Trojan:Win32/Sabsik.FL.A!ml`,檔案當場被隔離、自動刪除。

⚠️ 誠實界定:`!ml` 結尾=**機器學習判定**,Sabsik 是最常見的通用誤判家族,
   主因是「未簽章+下載次數少」,**不是**特別指向 dropper 行為。
   所以本次改動**不保證**解除誤判——真正的解是上架 MS Store(微軟簽章)。
   但「執行時把第二支 PE 寫到磁碟再執行」本來就該拿掉,這是對的方向且順手變小。

## 怎麼做
- `collector/` 39 個檔 `package main` → `package collector`,`main()` → 匯出的 `Run(args) int`
- 新增 `collector/cmd/collector/`(薄殼 CLI,讓單獨跑 collector 這條路仍可用)
- App 直接 import 該套件;`main()` 第一件事就判 `--collector`,是的話走 `collector.Run` 不碰 GUI
- `supervisor` 加 `ArgPrefix`,App 把 `BinPath` 指向 `os.Executable()` 自己
- 刪掉 `bundled_collector_{windows,other}.go`(embed + 攤檔那套)
- 三支打包腳本不再編/複製第二支;版本注入同時打到兩個 package
- build-win.sh 的機械閘改成**直接問它**:`--collector --version` 回得出版本才放行
  (舊閘是比大小,只能證明「有 embed」,證明不了「分派是對的」)

## 驗(真機實跑)
· `.app/Contents/MacOS/` 只有 **一個** 執行檔(原本兩個)
· 跑起來兩個行程是**同一個 exe**:
    …/MacOS/arcrun-app
    …/MacOS/arcrun-app --collector direct --config …
· `~/.arcrun-rag/bin` **不存在**(沒有任何東西被攤出來)
· 端到端:丟檔進看守資料夾 → collector.log `"status":"ingested","http_status":200`
· `lsappinfo` 仍是 `type="UIElement"`、`Version="0.18.9"`
· collector 39 檔測試全過;app 測試過;go vet 全綠;mac + windows 交叉編譯皆過
· 單檔 26MB → 22MB(不再夾帶第二份完整程式)
2026-08-06 16:00:47 +08:00

134 lines
5.2 KiB
Go
Raw 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 規約的卡片產物區(相對監看根)。
const cardsRelDir = "system-dev/wiki/cards"
// 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-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", cardsRelDir, string(out))
}
return cards, nil
}