Files
Arcrun/registry/components/code/test/fixtures/card-to-envelope.usercode.js
T
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

328 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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。
//
// 這支是「arcrun 自訂零件 km_wiki_card_parse」的參考實作(可移植成 Go/WASM 元件;
// 元件是 stdin→stdout JSON、無 fs/網路 syscall,本轉換剛好完全符合那個形狀)。
//
// 對齊契約:kbdb-ingest-plugin/contracts/ingest-candidate.jsonenvelope 形狀 / 禁止欄位)。
// 對齊頂層 SDD:卡片→entrymetadata.embed=true,走 base API)、wikilink→triplet(走 graph)。
//
// 鐵律:不碰儲存、不算向量、不建表。這支只「產出將寫入什麼」,實際 HTTP 由 workflow 打。
// (import removed: sandbox 提供注入的 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 removed: 使用沙箱注入的 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}${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)。
*/
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 };
}
return planCard(input.md, input.relPath, input.repo, input.opts || {});