chore: 刪掉兩個死零件與其壞範例(leo:這裏的每個不要的零件還存在啊)
📋 SDD:workflow-discovery(active)/對應 CP arcrun-usable 步驟 5「死代碼清除」 刪除(git rm 保留歷史): - registry/components/km_writer + .component-builds/km_writer - registry/components/kbdb_upsert_block + .component-builds/kbdb_upsert_block - registry/examples/km-wiki-ingest(唯一引用 kbdb_upsert_block 的範例,Mira 時代同源產物) 為什麼刪(Arcrun 自己的 06-mindset.md §1 早已載明): 「mira 的 claude_api / km_writer 就是這樣被錯做成零件的(其實是自用服務膠水)」 + kbdb_upsert_block 是「一個 CRUD 操作一個零件」(leo:總不能每個都建一個零件) + 兩者綁的 Mira 已於 2026-06-29 蒸發=死零件。 刪前實測引用數:workflows.json 0/cypher src 0/examples 僅 km-wiki-ingest。 零件數 22 → 20。 ✅ 投影模型驗證成功:build-bundles.mjs 掃 .component-builds/ 目錄(readdirSync), 重跑後 manifest 自動 25 顆 → 23 顆,兩顆消失,**不需手動改 manifest**。 ⇒ 證明 manifest 這層本來就是投影(leo 的設計),只有 registry KV 那層是獨立登記簿(待改)。 驗:vitest 9 failed/179 passed=與刪除前相同(既有債)。
This commit is contained in:
@@ -1,90 +0,0 @@
|
||||
canonical_id: "kbdb_upsert_block"
|
||||
display_name: "KBDB Upsert Block"
|
||||
category: "data"
|
||||
version: "v1"
|
||||
wasi_target: "preview1"
|
||||
stability: "floating"
|
||||
runtime_compat:
|
||||
- "cf-workers"
|
||||
- "workerd"
|
||||
- "wazero"
|
||||
constraints:
|
||||
max_size_kb: 2048
|
||||
max_cold_start_ms: 50
|
||||
no_network_syscall: false
|
||||
no_filesystem_syscall: true
|
||||
io_model: "stdin_stdout_json"
|
||||
input_schema:
|
||||
type: object
|
||||
required: [api_key, page_name, content]
|
||||
properties:
|
||||
api_key:
|
||||
type: string
|
||||
description: KBDB partner key(ak_xxx)
|
||||
page_name:
|
||||
type: string
|
||||
description: 當 idempotency key。內部用 GET /blocks?page_name= 查找。
|
||||
content:
|
||||
type: string
|
||||
description: block 內容(PATCH 時覆寫,CREATE 時新建)
|
||||
type:
|
||||
type: string
|
||||
description: block type(建立時用,PATCH 時忽略)
|
||||
parent_id:
|
||||
type: string
|
||||
description: 父 block id(建立時用,PATCH 時忽略)
|
||||
user_id:
|
||||
type: string
|
||||
description: 建立時帶入 + lookup 時用來 filter(同 page_name 多 user 共存場景)
|
||||
source:
|
||||
type: string
|
||||
description: 來源標記
|
||||
tags_json:
|
||||
type: string
|
||||
description: tags JSON 字串(PATCH 時轉 array、CREATE 時直傳)
|
||||
kbdb_url:
|
||||
type: string
|
||||
description: KBDB API base(預設 https://kbdb.finally.click)
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
action:
|
||||
type: string
|
||||
enum: [created, patched]
|
||||
description: 實際做了哪個動作
|
||||
data:
|
||||
type: object
|
||||
description: KBDB 回傳(含 block id 等)
|
||||
error:
|
||||
type: string
|
||||
phase:
|
||||
type: string
|
||||
enum: [lookup, patch, create]
|
||||
description: 出錯在哪個階段
|
||||
gherkin_tests:
|
||||
- scenario: "缺 page_name"
|
||||
given: '{"api_key":"ak_x","content":"hi"}'
|
||||
then_contains: '"success":false'
|
||||
- scenario: "建立新 block"
|
||||
given: '{"api_key":"ak_x","page_name":"new-page-uniq","content":"hello"}'
|
||||
then_contains: '"action":"created"'
|
||||
- scenario: "PATCH 既有 block"
|
||||
given: '{"api_key":"ak_x","page_name":"existing-page","content":"updated"}'
|
||||
then_contains: '"action":"patched"'
|
||||
tags: [data, storage, kbdb, upsert, primitive, idempotent]
|
||||
description: |
|
||||
Upsert:用 page_name 當 idempotency key。內部 GET 找有沒有同 page_name 的 block,
|
||||
找到就 PATCH 不到就 POST 新建。解 arcrun workflow 缺 IF/branch 能力的缺口
|
||||
(arcrun.md P1 #1)。mira 7B.3f index-entry per-entity 維護是第一個使用者。
|
||||
config_example: |
|
||||
upsert_index_entry:
|
||||
api_key: "{{api_key}}"
|
||||
page_name: "index-{{entity}}"
|
||||
parent_id: "{{mira_wiki_index_entities_id}}"
|
||||
type: "index-entry"
|
||||
user_id: "inkstone_mira_tools"
|
||||
source: "ai-canon-wiki"
|
||||
content: "{{compose_index_entry.data.text}}"
|
||||
tags_json: '["mira-wiki", "ai-generated", "index"]'
|
||||
@@ -1,3 +0,0 @@
|
||||
module kbdb_upsert_block
|
||||
|
||||
go 1.21
|
||||
@@ -1,280 +0,0 @@
|
||||
// kbdb_upsert_block — 用 page_name 當 idempotency key 做 upsert
|
||||
// 內部:GET /blocks?page_name=X → user_id filter → 找到 PATCH /blocks/:id 沒找到 POST /blocks
|
||||
// 解 arcrun workflow 沒 IF/branch 能力的缺口(arcrun.md P1 #1)
|
||||
// 對應 SDD:polaris/mira/.agents/specs/mira-app/design.md §3.5.12.4.1
|
||||
//
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
//go:wasmimport u6u http_request
|
||||
func hostHttpRequest(
|
||||
urlPtr uintptr, urlLen uint32,
|
||||
methodPtr uintptr, methodLen uint32,
|
||||
headersPtr uintptr, headersLen uint32,
|
||||
bodyPtr uintptr, bodyLen uint32,
|
||||
outPtr uintptr, outLenPtr uintptr,
|
||||
) uint32
|
||||
|
||||
type Input struct {
|
||||
KBDBUrl string `json:"kbdb_url"` // optional
|
||||
APIKey string `json:"api_key"` // 必填
|
||||
PageName string `json:"page_name"` // 必填,當 idempotency key
|
||||
Content string `json:"content"` // 必填
|
||||
Type string `json:"type"` // optional(建立時用,PATCH 時忽略)
|
||||
ParentID string `json:"parent_id"` // optional(建立時用,PATCH 時忽略)
|
||||
UserID string `json:"user_id"` // optional(建立時用 + lookup filter)
|
||||
Source string `json:"source"` // optional
|
||||
TagsJSON string `json:"tags_json"` // optional(完整覆寫)
|
||||
CreateOnly bool `json:"create_only"` // 2026-05-17 加:若 true + 已存在 → 不 PATCH,回 action="exists"
|
||||
// 用於 stub creation 場景(避免 stub 覆寫已存在的 full wiki)
|
||||
}
|
||||
|
||||
var dummy [1]byte
|
||||
|
||||
func safePtr(b []byte) (uintptr, uint32) {
|
||||
if len(b) == 0 {
|
||||
return uintptr(unsafe.Pointer(&dummy[0])), 0
|
||||
}
|
||||
return uintptr(unsafe.Pointer(&b[0])), uint32(len(b))
|
||||
}
|
||||
|
||||
func writeError(msg string) {
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": false, "error": msg})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
func writeResult(action string, data map[string]interface{}) {
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"action": action,
|
||||
"data": data,
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
// urlEncode:跟 kbdb_get 一致,避免引入 net/url
|
||||
func urlEncode(s string) string {
|
||||
var out []byte
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') ||
|
||||
c == '-' || c == '_' || c == '.' || c == '~' {
|
||||
out = append(out, c)
|
||||
} else {
|
||||
const hex = "0123456789ABCDEF"
|
||||
out = append(out, '%', hex[c>>4], hex[c&0x0f])
|
||||
}
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func httpCall(method, url string, headers map[string]string, body []byte) ([]byte, uint32) {
|
||||
headersBytes, _ := json.Marshal(headers)
|
||||
urlBytes := []byte(url)
|
||||
methodBytes := []byte(method)
|
||||
|
||||
outBuf := make([]byte, 1<<20) // 1MB
|
||||
var outLen uint32
|
||||
|
||||
urlPtr, urlLen := safePtr(urlBytes)
|
||||
methodPtr, methodLen := safePtr(methodBytes)
|
||||
headersPtr, headersLenU := safePtr(headersBytes)
|
||||
bodyPtr, bodyLenU := safePtr(body)
|
||||
|
||||
result := hostHttpRequest(
|
||||
urlPtr, urlLen,
|
||||
methodPtr, methodLen,
|
||||
headersPtr, headersLenU,
|
||||
bodyPtr, bodyLenU,
|
||||
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
|
||||
)
|
||||
return outBuf[:outLen], result
|
||||
}
|
||||
|
||||
func main() {
|
||||
raw, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
writeError("failed to read stdin: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var input Input
|
||||
if err := json.Unmarshal(raw, &input); err != nil {
|
||||
writeError("invalid input JSON: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if input.APIKey == "" {
|
||||
writeError("api_key 必填")
|
||||
return
|
||||
}
|
||||
if input.PageName == "" {
|
||||
writeError("page_name 必填(upsert 的 idempotency key)")
|
||||
return
|
||||
}
|
||||
if input.Content == "" {
|
||||
writeError("content 必填")
|
||||
return
|
||||
}
|
||||
|
||||
kbdbURL := input.KBDBUrl
|
||||
if kbdbURL == "" {
|
||||
kbdbURL = "https://kbdb.finally.click"
|
||||
}
|
||||
|
||||
headers := map[string]string{
|
||||
"Authorization": "Bearer " + input.APIKey,
|
||||
}
|
||||
|
||||
// ── Step 1:lookup by page_name ────────────────────────────────────
|
||||
lookupURL := kbdbURL + "/blocks?page_name=" + urlEncode(input.PageName) +
|
||||
"&limit=" + strconv.Itoa(10)
|
||||
lookupResp, callResult := httpCall("GET", lookupURL, headers, nil)
|
||||
if callResult != 0 {
|
||||
writeError("KBDB lookup failed (host_http_request returned non-zero)")
|
||||
return
|
||||
}
|
||||
|
||||
var lookupParsed struct {
|
||||
Blocks []map[string]interface{} `json:"blocks"`
|
||||
Count int `json:"count"`
|
||||
Error interface{} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(lookupResp, &lookupParsed); err != nil {
|
||||
writeError("KBDB lookup returned non-JSON: " + string(lookupResp))
|
||||
return
|
||||
}
|
||||
if lookupParsed.Error != nil {
|
||||
errBytes, _ := json.Marshal(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": lookupParsed.Error,
|
||||
"phase": "lookup",
|
||||
})
|
||||
os.Stdout.Write(errBytes)
|
||||
return
|
||||
}
|
||||
|
||||
// ── Step 2:找符合 user_id 的第一筆 ──────────────────────────────
|
||||
var existing map[string]interface{}
|
||||
for _, b := range lookupParsed.Blocks {
|
||||
if input.UserID == "" {
|
||||
existing = b
|
||||
break
|
||||
}
|
||||
if uid, ok := b["user_id"].(string); ok && uid == input.UserID {
|
||||
existing = b
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 3:分支寫入 ───────────────────────────────────────────────
|
||||
postHeaders := map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer " + input.APIKey,
|
||||
}
|
||||
|
||||
if existing != nil {
|
||||
// CreateOnly 模式:已存在 → 不動,回 action="exists"(給 stub creation 用,
|
||||
// 避免後續 raw 提到同 entity 時把完整 wiki 覆寫成 stub)
|
||||
if input.CreateOnly {
|
||||
writeResult("exists", existing)
|
||||
return
|
||||
}
|
||||
|
||||
// PATCH 路徑
|
||||
existingID, _ := existing["id"].(string)
|
||||
if existingID == "" {
|
||||
writeError("lookup 找到 block 但 id 為空")
|
||||
return
|
||||
}
|
||||
|
||||
patchBody := make(map[string]interface{})
|
||||
patchBody["content"] = input.Content
|
||||
if input.Source != "" {
|
||||
patchBody["source"] = input.Source
|
||||
}
|
||||
if input.TagsJSON != "" {
|
||||
// PATCH endpoint 用 tags array 不是 tags_json string
|
||||
var tagsArr []string
|
||||
if err := json.Unmarshal([]byte(input.TagsJSON), &tagsArr); err == nil {
|
||||
patchBody["tags"] = tagsArr
|
||||
}
|
||||
}
|
||||
patchBodyBytes, _ := json.Marshal(patchBody)
|
||||
|
||||
patchURL := kbdbURL + "/blocks/" + existingID
|
||||
patchResp, callResult := httpCall("PATCH", patchURL, postHeaders, patchBodyBytes)
|
||||
if callResult != 0 {
|
||||
writeError("KBDB PATCH failed (host_http_request returned non-zero)")
|
||||
return
|
||||
}
|
||||
var patchParsed map[string]interface{}
|
||||
if err := json.Unmarshal(patchResp, &patchParsed); err != nil {
|
||||
writeError("KBDB PATCH returned non-JSON: " + string(patchResp))
|
||||
return
|
||||
}
|
||||
if _, hasErr := patchParsed["error"]; hasErr {
|
||||
errBytes, _ := json.Marshal(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": patchParsed["error"],
|
||||
"phase": "patch",
|
||||
})
|
||||
os.Stdout.Write(errBytes)
|
||||
return
|
||||
}
|
||||
writeResult("patched", patchParsed)
|
||||
return
|
||||
}
|
||||
|
||||
// CREATE 路徑
|
||||
postBody := make(map[string]interface{})
|
||||
postBody["content"] = input.Content
|
||||
postBody["page_name"] = input.PageName
|
||||
if input.Type != "" {
|
||||
postBody["type"] = input.Type
|
||||
}
|
||||
if input.ParentID != "" {
|
||||
postBody["parent_id"] = input.ParentID
|
||||
}
|
||||
if input.UserID != "" {
|
||||
postBody["user_id"] = input.UserID
|
||||
}
|
||||
if input.Source != "" {
|
||||
postBody["source"] = input.Source
|
||||
}
|
||||
if input.TagsJSON != "" {
|
||||
postBody["tags_json"] = input.TagsJSON
|
||||
}
|
||||
postBodyBytes, _ := json.Marshal(postBody)
|
||||
|
||||
postURL := kbdbURL + "/blocks"
|
||||
postResp, callResult := httpCall("POST", postURL, postHeaders, postBodyBytes)
|
||||
if callResult != 0 {
|
||||
writeError("KBDB POST failed (host_http_request returned non-zero)")
|
||||
return
|
||||
}
|
||||
var postParsed map[string]interface{}
|
||||
if err := json.Unmarshal(postResp, &postParsed); err != nil {
|
||||
writeError("KBDB POST returned non-JSON: " + string(postResp))
|
||||
return
|
||||
}
|
||||
if _, hasErr := postParsed["error"]; hasErr {
|
||||
errBytes, _ := json.Marshal(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": postParsed["error"],
|
||||
"phase": "create",
|
||||
})
|
||||
os.Stdout.Write(errBytes)
|
||||
return
|
||||
}
|
||||
writeResult("created", postParsed)
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
canonical_id: "km_writer"
|
||||
display_name: "KM Writer"
|
||||
category: "api"
|
||||
version: "v1"
|
||||
wasi_target: "preview1"
|
||||
stability: "floating"
|
||||
runtime_compat:
|
||||
- "cf-workers"
|
||||
- "workerd"
|
||||
constraints:
|
||||
max_size_kb: 2048
|
||||
max_cold_start_ms: 50
|
||||
no_network_syscall: false
|
||||
no_filesystem_syscall: true
|
||||
io_model: "stdin_stdout_json"
|
||||
input_schema:
|
||||
type: object
|
||||
required: [action, mira_url, token]
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
description: "操作類型:read_journal | read_journal_date | append_journal | list_pages | read_page | write_page"
|
||||
enum: [read_journal, read_journal_date, append_journal, list_pages, read_page, write_page]
|
||||
mira_url:
|
||||
type: string
|
||||
description: "Mira 服務基礎 URL(例:https://mira.uncle6.me)"
|
||||
token:
|
||||
type: string
|
||||
description: "Mira MIRA_TOKEN(Bearer token)"
|
||||
content:
|
||||
type: string
|
||||
description: "內容(append_journal / write_page 時必填)"
|
||||
timestamp:
|
||||
type: string
|
||||
description: "ISO 8601 時間戳(append_journal 時選填,影響日期和時間顯示)"
|
||||
date:
|
||||
type: string
|
||||
description: "日期 YYYY-MM-DD(read_journal_date 時必填)"
|
||||
name:
|
||||
type: string
|
||||
description: "頁面名稱(read_page / write_page 時必填)"
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
description: "Mira API 回應資料"
|
||||
error:
|
||||
type: string
|
||||
description: "錯誤訊息(success=false 時)"
|
||||
gherkin_tests:
|
||||
- scenario: "缺少 action"
|
||||
given: '{"mira_url":"https://mira.uncle6.me","token":"abc"}'
|
||||
then_contains: '{"success":false'
|
||||
- scenario: "缺少 token"
|
||||
given: '{"action":"list_pages","mira_url":"https://mira.uncle6.me"}'
|
||||
then_contains: '{"success":false'
|
||||
tags: [km, journal, logseq, mira, knowledge-management]
|
||||
description: "讀寫 Mira leo-graph 的 journals 和 pages。透過 host function 呼叫 Mira /km/* API,支援讀取、新增日誌條目,以及讀寫頁面。"
|
||||
config_example: |
|
||||
append_to_journal:
|
||||
action: "append_journal"
|
||||
mira_url: "https://mira.uncle6.me"
|
||||
token: "<mira_token>"
|
||||
content: "今天完成了 arcrun km_writer 元件"
|
||||
@@ -1,3 +0,0 @@
|
||||
module component
|
||||
|
||||
go 1.21
|
||||
@@ -1,177 +0,0 @@
|
||||
// km_writer — 讀寫 Mira leo-graph(journals + pages)
|
||||
// 透過 host function 呼叫 Mira /km/* API
|
||||
//
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
//go:wasmimport u6u http_request
|
||||
func hostHttpRequest(
|
||||
urlPtr uintptr, urlLen uint32,
|
||||
methodPtr uintptr, methodLen uint32,
|
||||
headersPtr uintptr, headersLen uint32,
|
||||
bodyPtr uintptr, bodyLen uint32,
|
||||
outPtr uintptr, outLenPtr uintptr,
|
||||
) uint32
|
||||
|
||||
// Input actions:
|
||||
// read_journal — GET today's journal (requires: mira_url, token)
|
||||
// read_journal_date — GET journal by date (requires: mira_url, token, date)
|
||||
// append_journal — POST append entry (requires: mira_url, token, content; optional: timestamp)
|
||||
// list_pages — GET all pages (requires: mira_url, token)
|
||||
// read_page — GET page by name (requires: mira_url, token, name)
|
||||
// write_page — PUT write page (requires: mira_url, token, name, content)
|
||||
|
||||
type Input struct {
|
||||
Action string `json:"action"`
|
||||
MiraURL string `json:"mira_url"`
|
||||
Token string `json:"token"`
|
||||
Content string `json:"content"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Date string `json:"date"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
raw, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
writeError("failed to read stdin: " + err.Error())
|
||||
return
|
||||
}
|
||||
var inp Input
|
||||
if err := json.Unmarshal(raw, &inp); err != nil {
|
||||
writeError("invalid input JSON: " + err.Error())
|
||||
return
|
||||
}
|
||||
if inp.Action == "" {
|
||||
writeError("action 必填")
|
||||
return
|
||||
}
|
||||
if inp.MiraURL == "" {
|
||||
writeError("mira_url 必填")
|
||||
return
|
||||
}
|
||||
if inp.Token == "" {
|
||||
writeError("token 必填")
|
||||
return
|
||||
}
|
||||
|
||||
authHeader := fmt.Sprintf(`{"Authorization":"Bearer %s","Content-Type":"application/json"}`, inp.Token)
|
||||
|
||||
switch inp.Action {
|
||||
case "read_journal":
|
||||
result := doRequest(inp.MiraURL+"/km/journal", "GET", authHeader, "")
|
||||
os.Stdout.Write(result)
|
||||
|
||||
case "read_journal_date":
|
||||
if inp.Date == "" {
|
||||
writeError("date 必填(格式 YYYY-MM-DD)")
|
||||
return
|
||||
}
|
||||
result := doRequest(inp.MiraURL+"/km/journal/"+inp.Date, "GET", authHeader, "")
|
||||
os.Stdout.Write(result)
|
||||
|
||||
case "append_journal":
|
||||
if inp.Content == "" {
|
||||
writeError("content 必填")
|
||||
return
|
||||
}
|
||||
bodyMap := map[string]string{"content": inp.Content}
|
||||
if inp.Timestamp != "" {
|
||||
bodyMap["timestamp"] = inp.Timestamp
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(bodyMap)
|
||||
result := doRequest(inp.MiraURL+"/km/journal", "POST", authHeader, string(bodyBytes))
|
||||
os.Stdout.Write(result)
|
||||
|
||||
case "list_pages":
|
||||
result := doRequest(inp.MiraURL+"/km/pages", "GET", authHeader, "")
|
||||
os.Stdout.Write(result)
|
||||
|
||||
case "read_page":
|
||||
if inp.Name == "" {
|
||||
writeError("name 必填")
|
||||
return
|
||||
}
|
||||
result := doRequest(inp.MiraURL+"/km/page/"+inp.Name, "GET", authHeader, "")
|
||||
os.Stdout.Write(result)
|
||||
|
||||
case "write_page":
|
||||
if inp.Name == "" {
|
||||
writeError("name 必填")
|
||||
return
|
||||
}
|
||||
if inp.Content == "" {
|
||||
writeError("content 必填")
|
||||
return
|
||||
}
|
||||
bodyMap := map[string]string{"content": inp.Content}
|
||||
bodyBytes, _ := json.Marshal(bodyMap)
|
||||
result := doRequest(inp.MiraURL+"/km/page/"+inp.Name, "PUT", authHeader, string(bodyBytes))
|
||||
os.Stdout.Write(result)
|
||||
|
||||
default:
|
||||
writeError("未知 action: " + inp.Action)
|
||||
}
|
||||
}
|
||||
|
||||
func doRequest(url, method, headersJSON, body string) []byte {
|
||||
urlBytes := []byte(url)
|
||||
methodBytes := []byte(method)
|
||||
headersBytes := []byte(headersJSON)
|
||||
bodyBytes := []byte(body)
|
||||
|
||||
outBuf := make([]byte, 131072) // 128KB
|
||||
var outLen uint32
|
||||
|
||||
if len(bodyBytes) == 0 {
|
||||
bodyBytes = []byte{}
|
||||
}
|
||||
|
||||
var bodyPtr uintptr
|
||||
var bodyLen uint32
|
||||
if len(bodyBytes) > 0 {
|
||||
bodyPtr = uintptr(unsafe.Pointer(&bodyBytes[0]))
|
||||
bodyLen = uint32(len(bodyBytes))
|
||||
}
|
||||
|
||||
code := hostHttpRequest(
|
||||
uintptr(unsafe.Pointer(&urlBytes[0])), uint32(len(urlBytes)),
|
||||
uintptr(unsafe.Pointer(&methodBytes[0])), uint32(len(methodBytes)),
|
||||
uintptr(unsafe.Pointer(&headersBytes[0])), uint32(len(headersBytes)),
|
||||
bodyPtr, bodyLen,
|
||||
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
|
||||
)
|
||||
|
||||
if code != 0 {
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": false, "error": "HTTP request failed"})
|
||||
return out
|
||||
}
|
||||
|
||||
responseStr := string(outBuf[:outLen])
|
||||
|
||||
// Try to parse the response as JSON to forward it
|
||||
var parsed interface{}
|
||||
if err := json.Unmarshal([]byte(responseStr), &parsed); err != nil {
|
||||
// Not JSON — wrap it
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": true, "data": responseStr})
|
||||
return out
|
||||
}
|
||||
|
||||
// Forward the parsed response as-is, wrapped in success
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": true, "data": parsed})
|
||||
return out
|
||||
}
|
||||
|
||||
func writeError(msg string) {
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": false, "error": msg})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
# km-wiki-ingest — 機械式 wiki 卡片 → KBDB ingest(Arcrun#8 / 頂層 SDD T2–T4)
|
||||
|
||||
> Phase A 產物:機械 ingest 邏輯 + 乾跑證據 + workflow 設計。**不部署、不寫 live KBDB。**
|
||||
|
||||
## 解決什麼問題
|
||||
|
||||
把各 repo 的 `system-dev/wiki/cards/**/*.md`(人工精耕卡)**機械地**(無 LLM)灌進 leo21c KBDB:
|
||||
|
||||
- **卡片 → base entry**(`metadata.embed=true`,供語意搜尋)。
|
||||
- **`## 實體` → graph node**;**`## 關聯` 的 typed-edge(`A >> 關係 >> B`)與 `[[wikilink]]` → graph triplet**。
|
||||
|
||||
取代舊 `kbdb-ingest-plugin/scripts/ingest-cli.mjs` 的 `raw → Haiku → 三元組` 路:新路純解析卡片內既有結構,**決定性、零 token、零幻覺**。
|
||||
|
||||
## 形式選擇與理由(給總管)
|
||||
|
||||
**形式 = Arcrun workflow(YAML 編排)+ 通用 `code` 零件(sandbox inline JS,Arcrun#10)承載卡片→envelope 解析 + 現成零件(`cron` / `http_request` / `foreach_control` / `kbdb_upsert_block`)。** 不再鑄 domain 零件 `km_wiki_card_parse`(Arcrun#10 裁定:一次性解析走通用逃生口)。
|
||||
|
||||
理由:
|
||||
|
||||
1. **編排本來就是 arcrun 的主場**:cron 限速 drain、Gitea webhook 只吃 delta、foreach 小批、冪等 upsert——這些跟現成零件 1:1 對得上,且 leo 要「Arcrun workflow 慢慢做」、arcrun 哲學禁一次性腳本。
|
||||
2. **arcrun 唯一缺的是「卡片 → envelope」的解析**。那是一段**決定性純轉換**(無 LLM、無網路、無檔案)——正好是 `code` 零件 sandbox 的理想形狀(`stdin_stdout_json` + `no_network_syscall` + `no_filesystem_syscall`)。用通用 `code` 節點內聯這段 JS(而非鑄 domain 零件、也非在 YAML 裡塞 `string_ops` 正則):workflow 可讀、解析可單元測試、且 registry 不因一次性邏輯增生 domain 零件。
|
||||
3. **小批是結構性的,不是靠祈禱**:一卡一 tick,每卡在 graph worker 的 fan-out ≈ `7+4N+M` subrequest(notes 卡 N≈4/M≈5 → est 28~33,穩壓 CF 50 頂下);`code` 節點內聯解析會**預先把超大卡以 `source_uri` anchor 分段**,任何單一 graph 呼叫都不破頂。
|
||||
|
||||
**Phase A 交付**:純解析+打包核心(`lib/card-to-envelope.mjs`,現在就能跑,= `code` 節點內聯 JS 的權威來源)+乾跑驗證器(`lib/dry-run.mjs`,印出「將寫入什麼」)+本 workflow.yaml(parse_card = `code` 節點)。解析零件=通用 `code`(Arcrun#10 分支,已就緒待部署);本 example 不再自帶 domain 零件契約。部署被閘控,故 live 接線是「設計而非執行」。
|
||||
|
||||
## 診斷小結:fan-out 精確來源 + 小批為何解得掉
|
||||
|
||||
讀 `kbdb-graph-plugin` 現役寫入路徑(`triplet-ingest.ts` / `triplet-crud.ts` / `templates.ts` / `kbdb-client.ts`)逐行拆帳:
|
||||
|
||||
```
|
||||
POST /triplets/ingest(graph worker 單次 invocation)對 base 的 subrequest:
|
||||
ensurePluginTemplates(3) # 頂層一次
|
||||
+ listRecordsByTemplate(1) # 抓同 source 現存 active(冪等分組)
|
||||
+ Σ_triplet [ createTriplet → ensurePluginTemplates(3) + createRecord(1) ] # ★ 每條邊重跑 ensure!
|
||||
+ persistNodes [ ensurePluginTemplates(3) + Σ_node createRecord(1) ]
|
||||
+ Σ_deprecated updateRecord(1)
|
||||
= 7 + 4*N_triplets + M_nodes + D_deprecated
|
||||
```
|
||||
|
||||
- **精確炸點還原**:07_01 單一 envelope 吞 `N=11, M=10, D=0` → `7+44+10 = 61 > 50` → 破頂半殘。**放大器=`createTriplet` 內每條邊都重呼 `ensurePluginTemplates`(3 個 GET)**,佔了 33/61。
|
||||
- **小批為何解得掉**:把「整檔一 envelope」改成「一卡一 envelope、必要時再 anchor 分段」,把 `N` 壓到讓 `7+4N+M ≤ 40`。notes 三卡實測 est 上限 = 33,全綠。超大卡(自測 20 邊/22 節點=114)→ 自動分 4 段,每段 ≤ 38。
|
||||
- **附帶建議(非本 Phase 必改)**:graph 端把 `createTriplet`/`persistNodes` 內重複的 `ensurePluginTemplates` 提到 ingest 入口只跑一次,可把每 envelope 省下 `3*(N+1)` 個 subrequest(單卡 est 33→約 18),批量還能更大。此為 graph-plugin 的可選優化,記此存查。
|
||||
|
||||
## 冪等設計
|
||||
|
||||
| 對象 | 冪等鍵 | 行為 |
|
||||
|---|---|---|
|
||||
| **entry** | `page_name`(穩定:`wikicard:<repo>/<canonical>`)+ `metadata.content_hash` | 找到同 page_name:hash 相同 → skip;不同 → PATCH content(觸發重嵌)。沒有 → POST 新建。 |
|
||||
| **triplet envelope** | `source.uri` + `source.content_hash` | graph 現役 per-source 冪等:同 hash 整包 no-op(`triplet-ingest.ts:65`)。 |
|
||||
| **分段** | 各段 `source.uri = <基uri>#segNN` | 各段獨立 uri → 各自獨立冪等,**繞開 per-source content_hash 整包 skip**(否則同 uri 第 2 段起會被判定「已落地」而整包跳過)。節點只放進「首次引用它的段」,跨段不重送(避免 graph 重建 entity)。 |
|
||||
|
||||
## 觸發(兩階段,對齊 SDD R3)
|
||||
|
||||
- **Phase 0(一次性 backfill)**:`cron */2` 每 tick drain 一張卡(限速慢推),反覆跑到全庫清空。冪等 → 可續傳、重跑零寫入。
|
||||
- **穩態(日常增量)**:**Gitea push webhook → arcrun workflow**,只吃 `commits[].{added,modified}` 中的 `system-dev/wiki/cards/**/*.md`。⚠️ Gitea → Cloudflare(arcrun),**非 GitHub Actions**,不觸 GitHub flag 紅線(D4/D20)。量小、不撞頂、不限速。
|
||||
|
||||
## 乾跑證據(Phase A,不寫 live)
|
||||
|
||||
```
|
||||
node lib/dry-run.mjs --repo-path <notes clone> --repo Leo/notes --self-test
|
||||
```
|
||||
|
||||
對 `Leo/notes` 的 3 張卡實測:3 entries(embed=true)+ 3 envelopes、15 triplets、16 nodes;
|
||||
**單次 graph 呼叫 subrequest 上限 = 33(< 50),無任一 envelope 破頂**。
|
||||
self-test 合成超大卡(不分段 est=114 會炸)→ 自動分 4 段、每段 ≤ 38,全綠。
|
||||
|
||||
## 待 live 部署 + 寫入(總管過 leo 閘用)
|
||||
|
||||
1. **部署通用 `code` 零件**(Arcrun#10 分支 `feat/issue-10-code-component`,已就緒):`cd registry/components/code && npm install && npx wrangler deploy`(→ `code.arcrun.dev`)+ `register-component.sh code`。本 workflow 的 parse_card 以 `component: code` 引用它,解析 JS 已內聯在 workflow.yaml(= `lib/card-to-envelope.mjs` 邏輯)。不再部署 domain 零件 `km_wiki_card_parse`。
|
||||
2. **部署 workflow**:`km_wiki_ingest_drain`(cron drain);`wrangler` 直推 leo21c(**禁 `acr update`**——codeload 綁 GitHub 假綠,Arcrun#4)。
|
||||
3. **注入環境變數**(不放 repo):`repo=Leo/notes ref=main gitea_token kbdb_url=https://arcrun-kbdb.leo21c.workers.dev kbdb_api_key graph_url graph_api_key=leo`。`CLOUDFLARE_ACCOUNT_ID=leo21c`(別讓官方 58309b 污染)。
|
||||
4. **entry 寫入路徑確認**:若 `kbdb_upsert_block` 尚不透傳 `metadata_json`(需 `embed:true`/`content_hash`),entry 改用 `http_request` 直打 base `POST/PATCH /entries` 帶 `body_json.metadata_json`。
|
||||
5. **預期寫入量(Leo/notes 現況 3 卡)**:3 entries + 15 triplets + 16 node records(去重後更少);分 3 次 graph 呼叫(每次 ≤ 33 subrequest)+ 3 次 entry upsert。全庫鋪開時照 cron 一卡一 tick 慢推。
|
||||
6. **驗收**:ingest 後 `GET /embed/backfill/status` 應見 pending 上升→drain 後歸零、embedded 增加;三模式(關鍵字/語意/圖)curl 驗。
|
||||
@@ -1,176 +0,0 @@
|
||||
{
|
||||
"repo": "Leo/notes",
|
||||
"commit": "4b9a53c1c99d596c23b1b449fd79728610f6995b",
|
||||
"budget": 40,
|
||||
"ceiling": 50,
|
||||
"cards": [
|
||||
{
|
||||
"relPath": "system-dev/wiki/cards/notes/Gitea當後端編輯器全CF化網站構想.md",
|
||||
"canonical": "Gitea當後端編輯器全CF化網站構想",
|
||||
"entry": {
|
||||
"page_name": "wikicard:Leo/notes/Gitea當後端編輯器全CF化網站構想",
|
||||
"entry_type": "wiki_card",
|
||||
"metadata.embed": true,
|
||||
"content_hash": "64b402952fe0…",
|
||||
"content_bytes": 2324,
|
||||
"tags": [
|
||||
"系統設計",
|
||||
"工具教學"
|
||||
]
|
||||
},
|
||||
"envelopeCount": 1,
|
||||
"envelopes": [
|
||||
{
|
||||
"source.uri": "gitea:Leo/notes@system-dev/wiki/cards/notes/Gitea當後端編輯器全CF化網站構想.md",
|
||||
"source.anchor": null,
|
||||
"nodes": 6,
|
||||
"triplets": 5,
|
||||
"est_subrequests": 33,
|
||||
"under_ceiling": true,
|
||||
"sample_triplets": [
|
||||
"Gitea >> 類比於 >> WordPress (1)",
|
||||
"Gitea >> 充當後端供稿給 >> Cloudflare Pages (1)",
|
||||
"Quartz >> 目前負責轉譯給 >> Cloudflare Pages (1)",
|
||||
"Cloudflare Artifacts >> 若提供 git 倉庫則可取代 >> Gitea (1)"
|
||||
],
|
||||
"sample_nodes": [
|
||||
"Gitea — 可自架的 git 平台,編輯體驗近似 WordPress 後…",
|
||||
"WordPress — 常見的內容管理後端,作為 Gitea 編輯體驗的類比對象。…",
|
||||
"Cloudflare Pages — Cloudflare 的靜態站前端託管。…",
|
||||
"Quartz — 目前把筆記轉成網站前端的工具。…"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"relPath": "system-dev/wiki/cards/notes/Prompt能力即拆解自己邏輯的能力.md",
|
||||
"canonical": "Prompt能力即拆解自己邏輯的能力",
|
||||
"entry": {
|
||||
"page_name": "wikicard:Leo/notes/Prompt能力即拆解自己邏輯的能力",
|
||||
"entry_type": "wiki_card",
|
||||
"metadata.embed": true,
|
||||
"content_hash": "63296a663227…",
|
||||
"content_bytes": 2109,
|
||||
"tags": [
|
||||
"AI協作",
|
||||
"工具教學",
|
||||
"觀點主張"
|
||||
]
|
||||
},
|
||||
"envelopeCount": 1,
|
||||
"envelopes": [
|
||||
{
|
||||
"source.uri": "gitea:Leo/notes@system-dev/wiki/cards/notes/Prompt能力即拆解自己邏輯的能力.md",
|
||||
"source.anchor": null,
|
||||
"nodes": 5,
|
||||
"triplets": 5,
|
||||
"est_subrequests": 32,
|
||||
"under_ceiling": true,
|
||||
"sample_triplets": [
|
||||
"Prompt 能力 >> 本質上等於 >> 邏輯拆解能力 (1)",
|
||||
"邏輯拆解能力 >> 產出 >> pseudo code (1)",
|
||||
"pseudo code >> 足以教會 >> AI (1)",
|
||||
"Prompt能力即拆解自己邏輯的能力 >> 呼應 >> 程式化邏輯可圖解任何主題不限AI (1)"
|
||||
],
|
||||
"sample_nodes": [
|
||||
"Prompt 能力 — 把腦中意圖轉成能指揮 AI 的指令的能力。…",
|
||||
"邏輯拆解能力 — 把腦中隱性流程外顯成可陳述步驟的能力。…",
|
||||
"pseudo code — 用類程式的步驟描述邏輯、尚未綁定特定語法的表達。…",
|
||||
"AI — 需被人以指令/範例指揮才產出的生成模型。…"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"relPath": "system-dev/wiki/cards/notes/程式化邏輯可圖解任何主題不限AI.md",
|
||||
"canonical": "程式化邏輯可圖解任何主題不限AI",
|
||||
"entry": {
|
||||
"page_name": "wikicard:Leo/notes/程式化邏輯可圖解任何主題不限AI",
|
||||
"entry_type": "wiki_card",
|
||||
"metadata.embed": true,
|
||||
"content_hash": "a9dbf5fc0f9a…",
|
||||
"content_bytes": 2577,
|
||||
"tags": [
|
||||
"工具教學",
|
||||
"觀點主張",
|
||||
"系統設計"
|
||||
]
|
||||
},
|
||||
"envelopeCount": 1,
|
||||
"envelopes": [
|
||||
{
|
||||
"source.uri": "gitea:Leo/notes@system-dev/wiki/cards/notes/程式化邏輯可圖解任何主題不限AI.md",
|
||||
"source.anchor": null,
|
||||
"nodes": 5,
|
||||
"triplets": 5,
|
||||
"est_subrequests": 32,
|
||||
"under_ceiling": true,
|
||||
"sample_triplets": [
|
||||
"程式化邏輯 >> 可圖解 >> 亞洲金融風暴 (1)",
|
||||
"流程圖解 >> 奠基於 >> 程式化邏輯 (1)",
|
||||
"系統動力學 >> 類同於 >> 流程圖解 (1)",
|
||||
"程式化邏輯可圖解任何主題不限AI >> 呼應 >> Prompt能力即拆解自己邏輯的能力 (1)"
|
||||
],
|
||||
"sample_nodes": [
|
||||
"程式化邏輯 — 以程式的因果鏈結構來表述任一領域的邏輯。…",
|
||||
"亞洲金融風暴 — 講者小 Lin 用長邏輯鏈敘述的金融事件案例。…",
|
||||
"流程圖解 — 用 n8n 這類流程工具把邏輯視覺化講解的方法。…",
|
||||
"系統動力學 — 以存量流量與回饋環圖解因果的建模工具。…"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"totals": {
|
||||
"cards": 3,
|
||||
"entries_to_upsert": 3,
|
||||
"triplet_envelopes": 3,
|
||||
"total_triplets": 15,
|
||||
"total_nodes": 16,
|
||||
"max_est_subrequests_single_call": 33,
|
||||
"ceiling": 50,
|
||||
"budget": 40,
|
||||
"any_envelope_over_ceiling": 0
|
||||
},
|
||||
"self_test": {
|
||||
"note": "合成 20 邊 / 22 節點 的超大卡",
|
||||
"if_single_envelope_est_subrequests": 114,
|
||||
"would_crash_single": true,
|
||||
"segmented_into": 4,
|
||||
"per_segment": [
|
||||
{
|
||||
"uri": "gitea:Leo/notes@system-dev/wiki/cards/notes/合成超大卡.md#seg01",
|
||||
"anchor": "seg01",
|
||||
"triplets": 6,
|
||||
"nodes": 7,
|
||||
"est_subrequests": 38,
|
||||
"under_ceiling": true
|
||||
},
|
||||
{
|
||||
"uri": "gitea:Leo/notes@system-dev/wiki/cards/notes/合成超大卡.md#seg02",
|
||||
"anchor": "seg02",
|
||||
"triplets": 6,
|
||||
"nodes": 6,
|
||||
"est_subrequests": 37,
|
||||
"under_ceiling": true
|
||||
},
|
||||
{
|
||||
"uri": "gitea:Leo/notes@system-dev/wiki/cards/notes/合成超大卡.md#seg03",
|
||||
"anchor": "seg03",
|
||||
"triplets": 6,
|
||||
"nodes": 6,
|
||||
"est_subrequests": 37,
|
||||
"under_ceiling": true
|
||||
},
|
||||
{
|
||||
"uri": "gitea:Leo/notes@system-dev/wiki/cards/notes/合成超大卡.md#seg04",
|
||||
"anchor": "seg04",
|
||||
"triplets": 3,
|
||||
"nodes": 3,
|
||||
"est_subrequests": 22,
|
||||
"under_ceiling": true
|
||||
}
|
||||
],
|
||||
"all_segments_under_ceiling": true
|
||||
}
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
// km-wiki-ingest — 機械式卡片→(entry + triplet envelope) 轉換核心(無 LLM、純函式)
|
||||
// ---------------------------------------------------------------------------
|
||||
// 取代舊 `kbdb-ingest-plugin/scripts/ingest-cli.mjs` 的 raw→Haiku 路:
|
||||
// 舊路 = 讀裸筆記 → 呼叫 Haiku 萃 (s,p,o) → envelope(有 LLM、非決定性、耗 token)。
|
||||
// 新路 = 讀「已精耕卡片」(`system-dev/wiki/cards/**/*.md`)→ 直接解析卡片內既有的
|
||||
// `## 實體`(節點)、`## 關聯` 的 typed-edge(`A >> 關係 >> B`)與 `[[wikilink]]`
|
||||
// → entry + triplet envelope。純機械、決定性、零 token。
|
||||
//
|
||||
// 這支=通用 `code` 零件(Arcrun#10,sandbox inline JS)承載的解析邏輯本體。
|
||||
// workflow.yaml 的 parse_card 節點把本檔的 planCard 邏輯內聯進 code 零件的 config
|
||||
// (去 import/export、raw NUL 分隔符改 u0000 escape、改用 code 沙箱注入的 sha256);
|
||||
// 不再鑄 domain 零件 km_wiki_card_parse(Arcrun#10 裁定:一次性解析走通用逃生口)。
|
||||
// 本檔續留作「該內聯 JS 的權威來源 + 可單元測試的參考實作」(純函式、stdin→stdout JSON、無 fs/網路)。
|
||||
//
|
||||
// 對齊契約:kbdb-ingest-plugin/contracts/ingest-candidate.json(envelope 形狀 / 禁止欄位)。
|
||||
// 對齊頂層 SDD:卡片→entry(metadata.embed=true,走 base API)、wikilink→triplet(走 graph)。
|
||||
//
|
||||
// 鐵律:不碰儲存、不算向量、不建表。這支只「產出將寫入什麼」,實際 HTTP 由 workflow 打。
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
// --- CF subrequest 預算(防「Too many subrequests by single Worker invocation」,07_01 根因)---
|
||||
//
|
||||
// graph worker 處理一次 POST /triplets/ingest 時,對 base 的每次 fetch = 1 subrequest。
|
||||
// 精確拆帳(讀 kbdb-graph-plugin/src/actions/triplet-ingest.ts + triplet-crud.ts + templates.ts):
|
||||
// ingestEnvelope = ensurePluginTemplates(3) + listRecordsByTemplate(1)
|
||||
// + Σ triplet [ createTriplet → ensurePluginTemplates(3) + createRecord(1) = 4 ]
|
||||
// + persistNodes [ ensurePluginTemplates(3) + Σ node createRecord(1) ]
|
||||
// + Σ deprecated updateRecord(1)
|
||||
// ⟹ subreq(envelope) = 7 + 4*N_triplets + M_nodes + D_deprecated
|
||||
//
|
||||
// 07_01 實測炸點:N=11, M=10, D=0 → 7+44+10 = 61 > 50(CF 免費/bundled 上限)→ 炸半殘。
|
||||
//
|
||||
// 對策 = 「一卡一 tick、每 envelope 壓在預算下、超大檔以 source_uri anchor 分段」。
|
||||
export const SUBREQ_CEILING = 50; // CF 單次 Worker invocation subrequest 硬上限(bundled)
|
||||
export const SUBREQ_BUDGET = 40; // 我們的目標上限(留 10 給 D_deprecated 等變動)
|
||||
|
||||
/** 精確估算「一個 envelope 打進 graph /triplets/ingest」會在 graph worker 內產生幾個 subrequest。 */
|
||||
export function estimateEnvelopeSubrequests(nTriplets, mNodes, dDeprecated = 0) {
|
||||
return 7 + 4 * nTriplets + mNodes + dDeprecated;
|
||||
}
|
||||
|
||||
// --- sha256(content_hash 冪等鍵)---
|
||||
export function sha256(text) {
|
||||
return createHash('sha256').update(text).digest('hex');
|
||||
}
|
||||
|
||||
// --- frontmatter 解析(極簡 YAML:只吃我們卡片用到的 tags / gloss / pipeline_candidate)---
|
||||
function parseFrontmatter(md) {
|
||||
const m = md.match(/^---\n([\s\S]*?)\n---\n?/);
|
||||
if (!m) return { data: {}, body: md };
|
||||
const body = md.slice(m[0].length);
|
||||
const data = {};
|
||||
for (const line of m[1].split('\n')) {
|
||||
const kv = line.match(/^([A-Za-z_][\w-]*):\s*(.*)$/);
|
||||
if (!kv) continue;
|
||||
const key = kv[1];
|
||||
let val = kv[2].trim();
|
||||
if (val.startsWith('[') && val.endsWith(']')) {
|
||||
// inline list: [a, b, c]
|
||||
data[key] = val.slice(1, -1).split(',').map((s) => s.trim()).filter(Boolean);
|
||||
} else if (val === 'true' || val === 'false') {
|
||||
data[key] = val === 'true';
|
||||
} else {
|
||||
data[key] = val;
|
||||
}
|
||||
}
|
||||
return { data, body };
|
||||
}
|
||||
|
||||
// --- 取某個 `## 標題` / `### 標題` 區塊的內文(到下一個同級或更高級標題為止)---
|
||||
function sectionBody(md, heading) {
|
||||
// heading 例:'## 實體'、'### 內文知識關係'
|
||||
const level = heading.match(/^#+/)[0].length;
|
||||
const lines = md.split('\n');
|
||||
const out = [];
|
||||
let inSec = false;
|
||||
for (const line of lines) {
|
||||
const h = line.match(/^(#+)\s+(.*)$/);
|
||||
if (h) {
|
||||
const thisLevel = h[1].length;
|
||||
if (inSec) {
|
||||
// 遇到同級或更高級標題 → 區塊結束
|
||||
if (thisLevel <= level) break;
|
||||
}
|
||||
// 標題文字「開頭相符」即算命中(容忍標題後帶括號補述)
|
||||
if (!inSec && thisLevel === level && line.replace(/^#+\s+/, '').startsWith(heading.replace(/^#+\s+/, ''))) {
|
||||
inSec = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (inSec) out.push(line);
|
||||
}
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
// --- 實體行解析:`- **正規名**(別名1/別名2)— 描述`(別名、描述皆選填)---
|
||||
function parseEntities(md) {
|
||||
const sec = sectionBody(md, '## 實體');
|
||||
const entities = [];
|
||||
for (const raw of sec.split('\n')) {
|
||||
const line = raw.trim();
|
||||
if (!line.startsWith('- ')) continue;
|
||||
if (line.startsWith('- >') || line.startsWith('> ')) continue; // 跳過引言說明行
|
||||
const m = line.match(/^- \*\*(.+?)\*\*(?:((.+?)))?\s*(?:[—–\-]\s*(.*))?$/);
|
||||
if (!m) continue;
|
||||
const name = m[1].trim();
|
||||
if (!name) continue;
|
||||
const aliases = m[2]
|
||||
? m[2].split(/[//、,]/).map((s) => s.trim()).filter((s) => s && s !== name)
|
||||
: [];
|
||||
const gloss = (m[3] || '').trim();
|
||||
entities.push({ name, aliases, gloss });
|
||||
}
|
||||
return entities;
|
||||
}
|
||||
|
||||
// --- typed-edge 行解析:`A >> 謂詞 >> B`(端點可為裸實體名或 [[wikilink]])---
|
||||
function parseTypedEdges(sectionText) {
|
||||
const edges = [];
|
||||
for (const raw of (sectionText || '').split('\n')) {
|
||||
const line = raw.trim();
|
||||
if (!line.startsWith('- ')) continue;
|
||||
const body = line.slice(2).trim();
|
||||
if (body.startsWith('(') || body.startsWith('(')) continue; // 「(暫無…)」占位行
|
||||
const parts = body.split('>>');
|
||||
if (parts.length !== 3) continue;
|
||||
const subject = stripWikilink(parts[0].trim());
|
||||
const predicate = parts[1].trim();
|
||||
const object = stripWikilink(parts[2].trim());
|
||||
if (!subject || !predicate || !object) continue;
|
||||
edges.push({ subject, predicate, object });
|
||||
}
|
||||
return edges;
|
||||
}
|
||||
|
||||
// [[notes/00-INDEX]] → notes/00-INDEX ;純字串則原樣回。
|
||||
function stripWikilink(s) {
|
||||
const m = s.match(/^\[\[(.+?)\]\]$/);
|
||||
return m ? m[1].trim() : s;
|
||||
}
|
||||
|
||||
// --- 抽所有 inline [[wikilink]](含 header 的 ← [[notes/00-INDEX]] 與內文)---
|
||||
function extractInlineWikilinks(md) {
|
||||
const out = [];
|
||||
const re = /\[\[(.+?)\]\]/g;
|
||||
let m;
|
||||
while ((m = re.exec(md)) !== null) out.push(m[1].trim());
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- 卡片 canonical id:以檔名(去副檔名)為準,對齊 `## 卡片關係` 用的 [[基名]] 慣例 ---
|
||||
export function cardCanonical(relPath) {
|
||||
const base = relPath.split('/').pop().replace(/\.md$/, '');
|
||||
return base;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析一張卡片 → { entry, nodes, triplets, meta }(尚未分段的原始產物)。
|
||||
* relPath:卡片相對 repo 根路徑(如 system-dev/wiki/cards/notes/Xxx.md)。
|
||||
* repo:如 'Leo/notes'。
|
||||
*/
|
||||
export function parseCard(md, relPath, repo = 'Leo/notes') {
|
||||
const { data: fm } = parseFrontmatter(md);
|
||||
const canonical = cardCanonical(relPath);
|
||||
const titleMatch = md.match(/^#\s+(.+)$/m);
|
||||
const title = titleMatch ? titleMatch[1].trim() : canonical;
|
||||
|
||||
// 1) 節點:## 實體 的正規名 + 別名 + gloss。
|
||||
const entities = parseEntities(md);
|
||||
|
||||
// 2) 邊:內文知識關係(實體↔實體)+ 卡片關係(卡↔卡)+ inline wikilink(卡→卡 導覽/引用)。
|
||||
const intraEdges = parseTypedEdges(sectionBody(md, '### 內文知識關係'))
|
||||
.map((e) => ({ ...e, confidence: 1.0 }));
|
||||
const cardEdges = parseTypedEdges(sectionBody(md, '### 卡片關係'))
|
||||
.map((e) => ({ ...e, confidence: 1.0 }));
|
||||
|
||||
// inline wikilink(← [[notes/00-INDEX]] 等)→ 卡→卡「連結至」邊,去重、排除自環與已被 typed 邊覆蓋者。
|
||||
const typedPairs = new Set(
|
||||
[...cardEdges].map((e) => `${e.subject} | ||||