diff --git a/README.md b/README.md index 01a4b63..fd6061a 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Served via jsDelivr; fetched automatically during install — you never need to - `arcrun-wait/` — **arcrun-wait**(首裝) - `daemon/` — 桌面 App(Mac/Windows)安裝檔 -Built from `Arcrun@e92d6e683271` by `installer/scripts/ship.mjs`(arcrun-rag repo,release 1.4.63,built 2026-08-29)。 +Built from `Arcrun@1eb26c98a3af` by `installer/scripts/ship.mjs`(arcrun-rag repo,release 1.4.64,built 2026-09-13)。 ⚠️ 這份檔案由出貨管線每次自動重寫(`installer/scripts/render-bundles-readme.mjs`)—— 不要手動改這裡列的零件清單——它是算出來的:公庫=Arcrun 這一版編了什麼, diff --git a/arcrun-cypher-executor/worker.mjs b/arcrun-cypher-executor/worker.mjs index 9b5bae4..c439e33 100644 --- a/arcrun-cypher-executor/worker.mjs +++ b/arcrun-cypher-executor/worker.mjs @@ -3525,11 +3525,11 @@ function invalidateCredentialCache(apiKey) { async function getCredentialDirectory(env, apiKey) { const now2 = Date.now(); const cached = dirCache[apiKey]; - if (cached && now2 - cached.fetchedAt < DIR_CACHE_TTL_MS) return cached.rows; + if (cached && now2 - cached.fetchedAt < DIR_CACHE_TTL_MS) return { rows: cached.rows, error: null }; const qs = new URLSearchParams({ owner_id: apiKey, entry_type: CREDENTIAL_ENTRY_TYPE, limit: "200" }); const res = await kbdbCredFetch(env, `/entries?${qs.toString()}`); if (!res.ok) { - return []; + return { rows: [], error: `KBDB \u56DE HTTP ${res.status}` }; } const body = await res.json().catch(() => null); const rows = (body?.entries ?? []).filter((e) => !!e.page_name).map((e) => { @@ -3544,15 +3544,15 @@ async function getCredentialDirectory(env, apiKey) { }; }); dirCache[apiKey] = { rows, fetchedAt: now2 }; - return rows; + return { rows, error: null }; } -async function getCredentialSecretRefs(env, apiKey) { - const rows = await getCredentialDirectory(env, apiKey); - const out = {}; +async function getCredentialSecretRefsDetailed(env, apiKey) { + const { rows, error } = await getCredentialDirectory(env, apiKey); + const refs = {}; for (const r of rows) { - if (r.secret_ref) out[r.name] = r.secret_ref; + if (r.secret_ref) refs[r.name] = r.secret_ref; } - return out; + return { refs, directoryError: error }; } function touchLastUsed(env, apiKey, names) { const cached = dirCache[apiKey]; @@ -3560,6 +3560,7 @@ function touchLastUsed(env, apiKey, names) { const now2 = Math.floor(Date.now() / 1e3); for (const r of cached.rows) { if (!names.includes(r.name)) continue; + if (typeof r.last_used_at === "number" && now2 - r.last_used_at < LAST_USED_MIN_INTERVAL_S) continue; const meta = { service: r.service, sensitivity: r.sensitivity, @@ -3639,7 +3640,7 @@ async function writeCredential(env, apiKey, name, value, service, sensitivityRaw await upsertCredentialEntry(env, apiKey, name, service ?? null, sensitivity, secretRef); return { secretRef, sensitivity }; } -var credentialsRouter, CYPHER_SCRIPT_NAME, CREDENTIAL_ENTRY_TYPE, DIR_CACHE_TTL_MS, dirCache; +var credentialsRouter, CYPHER_SCRIPT_NAME, CREDENTIAL_ENTRY_TYPE, DIR_CACHE_TTL_MS, dirCache, LAST_USED_MIN_INTERVAL_S, VALUE_LIKE_FIELDS; var init_credentials = __esm({ "cypher-executor/src/routes/credentials.ts"() { "use strict"; @@ -3651,6 +3652,43 @@ var init_credentials = __esm({ CREDENTIAL_ENTRY_TYPE = "credential"; DIR_CACHE_TTL_MS = 6e4; dirCache = {}; + LAST_USED_MIN_INTERVAL_S = 300; + VALUE_LIKE_FIELDS = ["value", "secret", "token", "text", "plaintext"]; + credentialsRouter.post("/credentials/directory", async (c) => { + const apiKey = c.req.header("X-Arcrun-API-Key"); + if (!apiKey) { + return c.json({ error: "\u7F3A\u5C11 X-Arcrun-API-Key header" }, 401); + } + const body = await c.req.json().catch(() => null); + const name = body?.name; + if (!validateName(name)) { + return c.json({ error: "name \u5FC5\u586B\uFF0C\u53EA\u80FD\u5305\u542B\u82F1\u6587\u5B57\u6BCD\u3001\u6578\u5B57\u548C\u5E95\u7DDA" }, 400); + } + const offending = VALUE_LIKE_FIELDS.filter((f) => body?.[f] !== void 0); + if (offending.length > 0) { + return c.json({ + error: `\u9019\u652F\u7AEF\u9EDE\u53EA\u5BEB\u76EE\u9304\uFF0C\u4E0D\u6536\u91D1\u9470\u503C\uFF08\u6536\u5230 ${offending.join("/")}\uFF09\u3002\u503C\u8ACB\u7531\u6301\u6709 Cloudflare \u5BEB\u5165\u6191\u8B49\u7684\u4E00\u65B9\u76F4\u63A5 PUT \u9032 Workers Secrets\uFF0Csecret \u540D\u7A31\u7528\u672C\u7AEF\u9EDE\u56DE\u7684 secret_ref\uFF08D36\uFF1A\u53EA\u6709\u4E00\u689D\u91D1\u9470\u50B3\u905E\u8DEF\u5F91\uFF09\u3002` + }, 400); + } + const service = typeof body?.service === "string" ? body.service : null; + const sensitivity = validSensitivity(body?.sensitivity) ? body.sensitivity : "standard"; + try { + const secretRef = await deriveSecretRef(apiKey, name); + await upsertCredentialEntry(c.env, apiKey, name, service, sensitivity, secretRef); + return c.json({ + success: true, + name, + service, + sensitivity, + // 呼叫端拿這兩個值去寫值那一半:PUT /accounts/:id/workers/scripts/{secret_script}/secrets + // body { name: secret_ref, text: <明文>, type: 'secret_text' }。 + secret_ref: secretRef, + secret_script: CYPHER_SCRIPT_NAME + }); + } catch (e) { + return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 502); + } + }); credentialsRouter.post("/credentials", async (c) => { const apiKey = c.req.header("X-Arcrun-API-Key"); if (!apiKey) { @@ -3754,13 +3792,13 @@ var init_credentials = __esm({ }); // cypher-executor/src/actions/auth-dispatcher.ts -async function resolveSecretsFromNewHome(env, apiKey, names) { +async function resolveSecretsFromNewHomeDetailed(env, apiKey, names) { const resolved = {}; - if (names.length === 0) return resolved; - const refs = await getCredentialSecretRefs(env, apiKey); - if (Object.keys(refs).length === 0) return resolved; + if (names.length === 0) return { resolved, directoryError: null }; + const { refs, directoryError } = await getCredentialSecretRefsDetailed(env, apiKey); + if (Object.keys(refs).length === 0) return { resolved, directoryError }; const secretGet2 = createArcrunHostFunctions(env, apiKey).secret_get; - if (!secretGet2) return resolved; + if (!secretGet2) return { resolved, directoryError }; const resolvedNames = []; for (const name of names) { const ref = refs[name]; @@ -3771,9 +3809,13 @@ async function resolveSecretsFromNewHome(env, apiKey, names) { resolvedNames.push(name); } if (resolvedNames.length > 0) touchLastUsed(env, apiKey, resolvedNames); - return resolved; + return { resolved, directoryError }; } -async function tryAuthDispatch(componentId, input, env, apiKey) { +function explainCredentialFailure(message, directoryError, names) { + if (!directoryError) return message; + return `credential \u76EE\u9304\u8B80\u4E0D\u5230\uFF08${directoryError}\uFF09\uFF0C${names.join("\u3001")} \u7121\u6CD5\u5F9E\u4FDD\u7BA1\u8655\u53D6\u7528\u2014\u2014\u9019\u4E0D\u4EE3\u8868 credential \u4E0D\u5B58\u5728\uFF0C\u662F\u77E5\u8B58\u5EAB\uFF08KBDB\uFF09\u9019\u4E00\u523B\u56DE\u932F\uFF0C\u8ACB\u5148\u78BA\u8A8D KBDB \u662F\u5426\u6B63\u5E38\u3002\u9000\u56DE\u820A\u8DEF\u5F91\u7684\u7D50\u679C\uFF1A${message}`; +} +async function tryAuthDispatch(componentId, input, env, apiKey, redactor) { if (AUTH_PRIMITIVE_IDS.has(componentId)) { return null; } @@ -3786,7 +3828,8 @@ async function tryAuthDispatch(componentId, input, env, apiKey) { if (!recipe) return null; if (!SUPPORTED_PRIMITIVES.has(recipe.primitive)) return null; const secretNames = recipe.required_secrets.filter((s) => !s.optional).map((s) => s.key); - const resolvedSecrets = await resolveSecretsFromNewHome(env, apiKey, secretNames); + const { resolved: resolvedSecrets, directoryError } = await resolveSecretsFromNewHomeDetailed(env, apiKey, secretNames); + redactor?.addRecord(resolvedSecrets, (name) => `credential:${name}`); const primitiveUrl = wasmWorkerUrl(`auth_${recipe.primitive}`, env.WORKER_SUBDOMAIN); const res = await fetch(primitiveUrl, { method: "POST", @@ -3802,15 +3845,23 @@ async function tryAuthDispatch(componentId, input, env, apiKey) { if (!res.ok) { const text = await res.text().catch(() => ""); throw new Error( - `auth primitive "${recipe.primitive}" \u56DE\u50B3 ${res.status}: ${text.slice(0, 200)}` + explainCredentialFailure( + `auth primitive "${recipe.primitive}" \u56DE\u50B3 ${res.status}: ${text.slice(0, 200)}`, + directoryError, + secretNames + ) ); } const result = await res.json().catch(() => null); if (!result || result.success === false) { throw new Error( - `auth primitive \u5931\u6557: ${result?.error ?? "\u672A\u77E5\u932F\u8AA4"}` + explainCredentialFailure(`auth primitive \u5931\u6557: ${result?.error ?? "\u672A\u77E5\u932F\u8AA4"}`, directoryError, secretNames) ); } + redactor?.addRecord(result.auth_headers, (k) => `auth_header:${k}`); + redactor?.addRecord(result.auth_query, (k) => `auth_query:${k}`); + redactor?.addRecord(result.auth_body, (k) => `auth_body:${k}`); + redactor?.addRecord(result.auth_path, (k) => `auth_path:${k}`); return { ...input, _auth_headers: result.auth_headers ?? {}, @@ -3845,12 +3896,13 @@ function replaceCredentialRefs(value, resolved) { } return value; } -async function resolveCredentialRefs(data, env, apiKey) { +async function resolveCredentialRefs(data, env, apiKey, redactor) { const names = /* @__PURE__ */ new Set(); collectCredentialNames(data, names); if (names.size === 0) return data; const nameList = [...names]; - const resolvedSecrets = await resolveSecretsFromNewHome(env, apiKey, nameList); + const { resolved: resolvedSecrets, directoryError } = await resolveSecretsFromNewHomeDetailed(env, apiKey, nameList); + redactor?.addRecord(resolvedSecrets, (name) => `credential:${name}`); if (nameList.every((n) => Object.prototype.hasOwnProperty.call(resolvedSecrets, n))) { return replaceCredentialRefs(data, resolvedSecrets); } @@ -3867,12 +3919,17 @@ async function resolveCredentialRefs(data, env, apiKey) { }); if (!res.ok) { const text = await res.text().catch(() => ""); - throw new Error(`credential resolve \u56DE\u50B3 ${res.status}: ${text.slice(0, 200)}`); + throw new Error( + explainCredentialFailure(`credential resolve \u56DE\u50B3 ${res.status}: ${text.slice(0, 200)}`, directoryError, nameList) + ); } const result = await res.json().catch(() => null); if (!result || result.success === false) { - throw new Error(`credential resolve \u5931\u6557: ${result?.error ?? "\u672A\u77E5\u932F\u8AA4"}`); + throw new Error( + explainCredentialFailure(`credential resolve \u5931\u6557: ${result?.error ?? "\u672A\u77E5\u932F\u8AA4"}`, directoryError, nameList) + ); } + redactor?.addRecord(result.credentials, (name) => `credential:${name}`); return replaceCredentialRefs(data, result.credentials ?? {}); } var SUPPORTED_PRIMITIVES, AUTH_PRIMITIVE_IDS, CREDENTIAL_REF; @@ -8204,6 +8261,17 @@ var init_magic_vars = __esm({ }); // cypher-executor/src/lib/telemetry.ts +function recordNodeSteps(env, apiKey, workflowName, steps, ctx) { + if (steps.length === 0) return; + const failed = steps.filter((s) => !s.ok).length; + recordTelemetry(env, apiKey, { + event_type: "node_steps", + workflow_name: workflowName, + duration_ms: steps.reduce((sum, s) => sum + s.duration_ms, 0), + ...failed > 0 ? { error_code: "node_error" } : {}, + steps + }, ctx); +} async function hashApiKey(apiKey) { if (!apiKey) return "anon"; const encoder = new TextEncoder(); @@ -8263,6 +8331,93 @@ var init_telemetry = __esm({ } }); +// cypher-executor/src/lib/trace-redaction.ts +function redactionMarker(label) { + return `[redacted:${label}]`; +} +var MIN_SUBSTRING_LEN, MAX_DEPTH, TraceRedactor; +var init_trace_redaction = __esm({ + "cypher-executor/src/lib/trace-redaction.ts"() { + "use strict"; + MIN_SUBSTRING_LEN = 4; + MAX_DEPTH = 64; + TraceRedactor = class { + /** 真身 → 標記用的 label。用 Map 讓同一個值只登記一次。 */ + labels = /* @__PURE__ */ new Map(); + /** 依長度由長到短排序的真身清單(先換長的,避免長值被短值切碎)。 */ + sortedCache = null; + /** + * 登記一個「不准出現在 trace 裡」的真身。 + * 非字串、空字串、純空白一律忽略(那些不是秘密,拿去比對只會誤傷)。 + */ + add(value, label) { + if (typeof value !== "string") return; + if (value.trim().length === 0) return; + if (this.labels.has(value)) return; + this.labels.set(value, label); + this.sortedCache = null; + } + /** 登記一整個 `{ name: 真身 }` map;label 由 name 決定。 */ + addRecord(record, label) { + if (!record || typeof record !== "object") return; + for (const [key, value] of Object.entries(record)) this.add(value, label(key)); + } + /** 目前登記了幾個真身(0 = 這次執行沒用到任何 credential,redact 直接短路)。 */ + get size() { + return this.labels.size; + } + /** 把一個字串裡所有登記過的真身換成標記。 */ + redactString(input) { + if (this.labels.size === 0) return input; + let out = input; + for (const secret of this.sorted()) { + const marker = redactionMarker(this.labels.get(secret)); + if (secret.length < MIN_SUBSTRING_LEN) { + if (out === secret) out = marker; + continue; + } + if (out.includes(secret)) out = out.split(secret).join(marker); + } + return out; + } + /** + * 深走一個值,回傳「同形狀但值被遮過」的副本。 + * 沒登記任何真身時原樣回傳同一個 reference(零成本,不影響 99% 的執行)。 + */ + redact(value) { + if (this.labels.size === 0) return value; + return this.walk(value, /* @__PURE__ */ new WeakMap(), 0); + } + sorted() { + if (this.sortedCache === null) { + this.sortedCache = [...this.labels.keys()].sort((a, b) => b.length - a.length); + } + return this.sortedCache; + } + walk(value, seen, depth) { + if (typeof value === "string") return this.redactString(value); + if (value === null || typeof value !== "object") return value; + if (value instanceof Date) return value; + if (depth >= MAX_DEPTH) return redactionMarker("depth-limit"); + const cached = seen.get(value); + if (cached !== void 0) return cached; + if (Array.isArray(value)) { + const out2 = []; + seen.set(value, out2); + for (const item of value) out2.push(this.walk(item, seen, depth + 1)); + return out2; + } + const out = {}; + seen.set(value, out); + for (const [key, child] of Object.entries(value)) { + out[this.redactString(key)] = this.walk(child, seen, depth + 1); + } + return out; + } + }; + } +}); + // cypher-executor/src/graph-executor.ts function propagateCtx(context, upstreamResult, upstreamNodeId) { const baseCtx = typeof context === "object" && context !== null ? context : {}; @@ -8383,6 +8538,7 @@ var init_graph_executor = __esm({ init_paused_runs(); init_magic_vars(); init_telemetry(); + init_trace_redaction(); GraphExecutor = class _GraphExecutor { loader; workflowLoader; @@ -8397,6 +8553,18 @@ var init_graph_executor = __esm({ // 暫停時持久化 state 用,需在 execute 進入時設定 currentGraph; currentRunId; + // inkstone/Arcrun#197:本次執行解出來的 credential 真身名單。 + // 唯一用途=把值寫進「除錯面」(trace / failed_input / 錯誤訊息 / 回傳的 data)之前 + // 換成標記。每次 execute / resumeFromPaused 進入時重建,不跨執行殘留。 + // + // 🔴 為什麼遮在這裡而不是在各個 route:trace 只有這一個產地, + // 而它的消費端有五個(POST /execute、cypher-handlers、webhook-handlers、 + // GET /executions/:task_id、POST /workflows/resume)+ 一個持久化端(paused KV)。 + // 遮在產地=六個出口一次補齊;遮在出口=下一個新出口又會漏。 + redactor = new TraceRedactor(); + // inkstone/arcrun-rag#196:本次執行的 step-level 遙測先收在這裡,執行結束寫成一筆。 + // 原本每個 Component 節點各打一次 fetch,佔掉免費層每次呼叫 50 子請求的額度。 + nodeSteps = []; constructor(loader, workflowLoader, env, apiKey) { this.loader = loader; this.workflowLoader = workflowLoader; @@ -8405,6 +8573,7 @@ var init_graph_executor = __esm({ } async execute(graph, initialContext, kvNamespace) { const trace = []; + this.redactor = new TraceRedactor(); const kvStore = kvNamespace ? { runId: `${graph.id}-${Date.now()}`, kv: kvNamespace } : void 0; this.currentGraph = graph; this.currentRunId = kvStore?.runId ?? `${graph.id}-${Date.now()}`; @@ -8424,11 +8593,17 @@ var init_graph_executor = __esm({ fanIn.set(node.id, { ctx: { ...ctxWithMagic }, remaining: inDeg }); } } - const results = await Promise.all( - startNodes.map( - (node) => this.executeNode(node, graph, ctxWithMagic, /* @__PURE__ */ new Set(), trace, fanIn, kvStore) - ) - ); + this.nodeSteps = []; + let results; + try { + results = await Promise.all( + startNodes.map( + (node) => this.executeNode(node, graph, ctxWithMagic, /* @__PURE__ */ new Set(), trace, fanIn, kvStore) + ) + ); + } finally { + this.flushNodeSteps(graph); + } let mergedResult; if (results.length === 1) { mergedResult = results[0]; @@ -8441,7 +8616,7 @@ var init_graph_executor = __esm({ {} ); } - return { data: mergedResult, trace }; + return { data: this.redactor.redact(mergedResult), trace }; } /** * 從 paused state 繼續執行 workflow @@ -8455,6 +8630,7 @@ var init_graph_executor = __esm({ async resumeFromPaused(args) { const { graph, paused_node_id, paused_context, prior_trace, kvNamespace } = args; let { callback_result } = args; + this.redactor = new TraceRedactor(); callback_result = parseRecipeOutput( callback_result, args.recipe_output_format, @@ -8478,7 +8654,7 @@ var init_graph_executor = __esm({ } const downstreamEdges = graph.edges.filter((e) => e.from === paused_node_id); if (downstreamEdges.length === 0) { - return { data: callback_result, trace }; + return { data: this.redactor.redact(callback_result), trace }; } const fanIn = /* @__PURE__ */ new Map(); for (const node of graph.nodes) { @@ -8489,11 +8665,17 @@ var init_graph_executor = __esm({ } const visited = /* @__PURE__ */ new Set([`${paused_node_id}:${JSON.stringify(paused_context).slice(0, 50)}`]); const downstreamNodes = downstreamEdges.map((e) => graph.nodes.find((n) => n.id === e.to)).filter((n) => !!n); - const results = await Promise.all( - downstreamNodes.map( - (node) => this.executeNode(node, graph, mergedContext, visited, trace, fanIn, kvStore) - ) - ); + this.nodeSteps = []; + let results; + try { + results = await Promise.all( + downstreamNodes.map( + (node) => this.executeNode(node, graph, mergedContext, visited, trace, fanIn, kvStore) + ) + ); + } finally { + this.flushNodeSteps(graph); + } let mergedResult; if (results.length === 1) { mergedResult = results[0]; @@ -8506,7 +8688,13 @@ var init_graph_executor = __esm({ {} ); } - return { data: mergedResult, trace }; + return { data: this.redactor.redact(mergedResult), trace }; + } + /** 把本次收集的 step-level 遙測寫成一筆(成功、失敗、暫停都寫)。 */ + flushNodeSteps(graph) { + const steps = this.nodeSteps; + this.nodeSteps = []; + if (this.env && steps.length > 0) recordNodeSteps(this.env, this.apiKey, graph.name, steps); } async executeNode(node, graph, context, visited, trace, fanIn, kvStore) { const nodeKey = `${node.id}:${JSON.stringify(context).slice(0, 50)}`; @@ -8525,14 +8713,13 @@ var init_graph_executor = __esm({ if (!node.componentId) throw new Error(`\u7BC0\u9EDE ${node.id} \u7F3A\u5C11 componentId`); const runner = await this.loader(node.componentId); const ctx = context; - const resolvedData = interpolateData(node.data, ctx); + const authoredData = node.data ?? {}; + const dataWithCredentials = this.env && this.apiKey ? await resolveCredentialRefs(authoredData, this.env, this.apiKey, this.redactor) : authoredData; + const resolvedData = interpolateData(dataWithCredentials, ctx); let mergedContext = { ...ctx, ...resolvedData }; - if (this.env && this.apiKey) { - mergedContext = await resolveCredentialRefs(mergedContext, this.env, this.apiKey); - } if (node.componentId === "claude_api") { const baseUrl = this.env?.PUBLIC_BASE_URL ?? "https://cypher.arcrun.dev"; mergedContext.callback_url = `${baseUrl.replace(/\/$/, "")}/workflows/resume`; @@ -8557,7 +8744,7 @@ var init_graph_executor = __esm({ } } if (this.env && this.apiKey) { - const dispatched = await tryAuthDispatch(node.componentId, mergedContext, this.env, this.apiKey); + const dispatched = await tryAuthDispatch(node.componentId, mergedContext, this.env, this.apiKey, this.redactor); if (dispatched) { mergedContext = dispatched; } @@ -8576,8 +8763,8 @@ var init_graph_executor = __esm({ trace.push({ nodeId: node.id, type: node.type, - input: nodeInput, - output: result, + input: this.redactor.redact(nodeInput), + output: this.redactor.redact(result), duration_ms: Date.now() - start }); await persistPausedRun(this.env.EXEC_CONTEXT, pending.task_id, { @@ -8612,30 +8799,25 @@ var init_graph_executor = __esm({ } } catch (e) { if (e instanceof WorkflowPaused) throw e; - const errMsg = e.message || String(e); + const errMsg = this.redactor.redactString(e.message || String(e)); const duration_ms2 = Date.now() - start; trace.push({ nodeId: node.id, type: node.type, - input: nodeInput, + input: this.redactor.redact(nodeInput), output: null, error: errMsg, duration_ms: duration_ms2 }); - if (this.env && node.type === "Component") { - recordTelemetry(this.env, this.apiKey, { - event_type: "node_failure", - workflow_name: graph.name, - component_id: node.componentId, - error_code: "node_error", - duration_ms: duration_ms2 - }); + if (node.type === "Component") { + this.nodeSteps.push({ component_id: node.componentId, duration_ms: duration_ms2, ok: false, error_code: "node_error" }); } if (e instanceof ExecutionError) throw e; throw new ExecutionError( `Node ${node.id} failed: ${errMsg}`, node.id, - nodeInput, + // #197:`failed_input` 直接進 HTTP 回應(execute.ts / cypher-handlers.ts) + this.redactor.redact(nodeInput), trace ); } @@ -8643,17 +8825,12 @@ var init_graph_executor = __esm({ trace.push({ nodeId: node.id, type: node.type, - input: nodeInput, - output: result, + input: this.redactor.redact(nodeInput), + output: this.redactor.redact(result), duration_ms }); - if (this.env && node.type === "Component") { - recordTelemetry(this.env, this.apiKey, { - event_type: "node_success", - workflow_name: graph.name, - component_id: node.componentId, - duration_ms - }); + if (node.type === "Component") { + this.nodeSteps.push({ component_id: node.componentId, duration_ms, ok: true }); } const outEdges = graph.edges.filter((e) => e.from === node.id); for (const edge of outEdges) { @@ -8860,6 +9037,17 @@ function componentVerdictsFromTrace(nodes, trace) { } return verdicts; } +function aggregateVerdicts(verdicts) { + const byId = /* @__PURE__ */ new Map(); + for (const v of verdicts) { + const a = byId.get(v.component_id) ?? { component_id: v.component_id, runs: 0, success_runs: 0, duration_ms: 0 }; + a.runs += 1; + a.success_runs += v.success ? 1 : 0; + a.duration_ms += v.duration_ms; + byId.set(v.component_id, a); + } + return [...byId.values()]; +} async function recordComponentStats(env, nodes, trace) { try { const base = (env.REGISTRY_BASE_URL ?? (env.WORKER_SUBDOMAIN ? wasmWorkerUrl("registry", env.WORKER_SUBDOMAIN) : void 0))?.replace(/\/$/, ""); @@ -8867,14 +9055,17 @@ async function recordComponentStats(env, nodes, trace) { const verdicts = componentVerdictsFromTrace(nodes, trace); if (verdicts.length === 0) return; await Promise.all( - verdicts.map( - (v) => fetch(`${base}/analytics/record`, { + aggregateVerdicts(verdicts).map( + (a) => fetch(`${base}/analytics/record`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - canonical_id: v.component_id, - success: v.success, - duration_ms: v.duration_ms + canonical_id: a.component_id, + // 舊版 registry 不認 runs 時只會記 1 筆:給它「全部成功才算成功」的保守值 + success: a.success_runs === a.runs, + duration_ms: a.duration_ms, + runs: a.runs, + success_runs: a.success_runs }) }).catch(() => void 0) // 統計失敗不影響執行 @@ -12906,6 +13097,53 @@ function generatePassword(length = 16) { return out; } +// cypher-executor/src/lib/mcp-redirect-hosts.ts +var MCP_BUILTIN_REDIRECT_HOSTS = ["claude.ai", "claude.com", "anthropic.com"]; +var MCP_REDIRECT_HOST_TEMPLATE = "portal_mcp_redirect_host"; +function normalizeRedirectHost(input) { + const raw2 = String(input ?? "").trim(); + if (!raw2) return { ok: false, error: "\u8ACB\u586B\u5165\u7DB2\u5740\u6216\u7DB2\u57DF\uFF08\u4F8B\u5982 n8n.example.com\uFF09" }; + let host = raw2.toLowerCase(); + if (host.includes("://")) { + let u; + try { + u = new URL(raw2); + } catch { + return { ok: false, error: `\u770B\u4E0D\u61C2\u9019\u500B\u7DB2\u5740\uFF1A${raw2}` }; + } + if (u.protocol !== "https:" && u.protocol !== "http:") { + return { ok: false, error: "\u53EA\u6536 https:// \u958B\u982D\u7684\u7DB2\u5740\uFF08\u672C\u6A5F\u6E2C\u8A66\u53EF\u7528 localhost\uFF09" }; + } + host = u.hostname.toLowerCase(); + } else { + host = host.split("/")[0].split("?")[0]; + if (host.includes("@")) return { ok: false, error: "\u8ACB\u4E0D\u8981\u5E36\u5E33\u865F\u5BC6\u78BC\uFF0C\u53EA\u8981\u7DB2\u57DF\u5C31\u597D" }; + if (host.startsWith("[")) return { ok: false, error: "\u4E0D\u652F\u63F4 IPv6 \u4F4D\u5740\uFF0C\u672C\u6A5F\u6E2C\u8A66\u8ACB\u586B localhost" }; + host = host.split(":")[0]; + } + if (!host) return { ok: false, error: "\u8ACB\u586B\u5165\u7DB2\u5740\u6216\u7DB2\u57DF\uFF08\u4F8B\u5982 n8n.example.com\uFF09" }; + if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/.test(host)) { + return { ok: false, error: `\u300C${raw2}\u300D\u4E0D\u662F\u5408\u6CD5\u7684\u7DB2\u57DF\u3002\u4E0D\u63A5\u53D7\u842C\u7528\u5B57\u5143\uFF0C\u8ACB\u586B\u78BA\u5207\u7684\u7DB2\u57DF` }; + } + const isLocal = host === "localhost" || host === "127.0.0.1"; + if (!isLocal && !host.includes(".")) { + return { ok: false, error: `\u300C${host}\u300D\u770B\u8D77\u4F86\u4E0D\u662F\u5B8C\u6574\u7DB2\u57DF\uFF08\u5C11\u4E86 .com \u4E4B\u985E\u7684\u7D50\u5C3E\uFF09` }; + } + if (host.length > 253) return { ok: false, error: "\u7DB2\u57DF\u592A\u9577" }; + if (!isLocal && host.split(".").length < 2) { + return { ok: false, error: `\u300C${host}\u300D\u7BC4\u570D\u592A\u5927\uFF0C\u8ACB\u586B\u5B8C\u6574\u7DB2\u57DF` }; + } + if (MCP_BUILTIN_REDIRECT_HOSTS.some((h) => host === h || host.endsWith("." + h))) { + return { ok: false, error: `\u300C${host}\u300D\u662F\u5167\u5EFA\u5C31\u5141\u8A31\u7684\u7DB2\u57DF\uFF08Claude \u5B98\u65B9\uFF09\uFF0C\u4E0D\u5FC5\u518D\u52A0\u4E00\u6B21` }; + } + return { ok: true, host }; +} +function mcpUrlFor(subdomain) { + const sub = String(subdomain ?? "").trim(); + if (!sub) return ""; + return `https://arcrun-mcp.${sub}.workers.dev/mcp`; +} + // cypher-executor/src/lib/portal-seeds.ts var PORTAL_TEMPLATE_SEEDS = [ { @@ -12938,6 +13176,20 @@ var PORTAL_TEMPLATE_SEEDS = [ slots: ["name", "display_name", "description", "status", "graph_source", "root", "mode", "reason"], created_by: "system" }, + { + // `inkstone/Arcrun#164`:「哪些網址可以接我的 MCP」——一個網域一筆 record。 + // 為什麼是 record 而不是一個字串設定:加了誰要留得下痕跡(本票紅線第三條), + // 而「誰在什麼時候加的」是這個物件本身的屬性,不是一團 JSON(D91 同一條)。 + // host = 已正規化的小寫網域(lib/mcp-redirect-hosts.ts normalizeRedirectHost) + // label = 使用者自己認得的名字(「我的 n8n」),純顯示用 + // created_at = ISO 時間字串 + // created_by = 加它的那個 portal 帳號 email + // 🔴 不寫 KV(leo 2026-08-25 已禁長效用途),也不加 D1 表——這是 KBDB 萬用表的 template。 + name: "portal_mcp_redirect_host", + description: "MCP OAuth \u5141\u8A31\u7684 redirect \u7DB2\u57DF\uFF08Arcrun#164\uFF1B\u4E00\u500B\u7DB2\u57DF\u4E00\u7B46\uFF0C\u53EF\u52A0\u53EF\u6536\u56DE\uFF09", + slots: ["host", "label", "created_at", "created_by"], + created_by: "system" + }, { // t130:rag_ingest_card.post_triplet 寫 POST /records {template:'triplet'}。 // 新實例若無此 template 回 400「template not found: triplet」→ 三元組全滅。 @@ -14462,6 +14714,106 @@ portalRouter.put( return c.json({ success: true, retention_days: data.retention_days ?? null }); }) ); +async function listMcpRedirectHosts(env) { + const rows = await listRecordsByTemplate(env, MCP_REDIRECT_HOST_TEMPLATE); + return rows.filter((r) => (r.values.host ?? "").trim() !== "").sort((a, b) => (a.values.host ?? "").localeCompare(b.values.host ?? "")); +} +portalRouter.get( + "/portal/mcp-settings", + (c) => run(c, async () => { + const auth = await requirePortalUser(c); + if (!auth.ok) return auth.res; + const isAdmin = (auth.user.values.role ?? "") === "admin"; + const sub = String(c.env.WORKER_SUBDOMAIN ?? "").trim(); + const mcpUrl = mcpUrlFor(sub); + const payload = { + success: true, + mcp_url: mcpUrl, + // 誠實講「為什麼沒有」:這台實例的部署設定裡沒有 WORKER_SUBDOMAIN ⇒ 多半是安裝 + // 中途失敗(畫面卻說裝好了)。不要讓使用者以為是自己沒找到。 + mcp_url_reason: mcpUrl ? "" : "\u9019\u500B\u5BE6\u4F8B\u6C92\u6709\u8A18\u9304\u81EA\u5DF1\u7684\u90E8\u7F72\u4F4D\u7F6E\uFF08\u5B89\u88DD\u53EF\u80FD\u6C92\u6709\u5B8C\u6210\uFF09\uFF0C\u6240\u4EE5\u7B97\u4E0D\u51FA MCP \u7DB2\u5740", + builtin_hosts: [...MCP_BUILTIN_REDIRECT_HOSTS], + can_edit: isAdmin + }; + if (isAdmin) { + payload.hosts = (await listMcpRedirectHosts(c.env)).map((r) => ({ + record_id: r.record_id, + host: r.values.host ?? "", + label: r.values.label ?? "", + created_at: r.values.created_at ?? "", + created_by: r.values.created_by ?? "" + })); + } + return c.json(payload); + }) +); +portalRouter.post( + "/portal/admin/mcp-redirect-hosts", + (c) => run(c, async () => { + const auth = await requirePortalAdmin(c); + if (!auth.ok) return auth.res; + const body = await c.req.json().catch(() => null); + const norm = normalizeRedirectHost(body?.host); + if (!norm.ok) return c.json({ error: norm.error }, 400); + const seeded = await ensurePortalTemplates(c.env); + if (seeded.errors.length > 0) { + return c.json({ error: `portal templates seed \u5931\u6557\uFF1A${seeded.errors.join("; ")}` }, 502); + } + const existing = await listMcpRedirectHosts(c.env); + const dup = existing.find((r) => (r.values.host ?? "") === norm.host); + if (dup) { + return c.json({ success: true, already: true, host: norm.host, record_id: dup.record_id }); + } + const ns = portalNamespace(c.env); + const res = await kbdbFetch(c.env, "/records", { + method: "POST", + body: JSON.stringify({ + template: MCP_REDIRECT_HOST_TEMPLATE, + owner_id: ns, + values: { + host: norm.host, + label: String(body?.label ?? "").trim().slice(0, 80), + created_at: (/* @__PURE__ */ new Date()).toISOString(), + created_by: auth.user.values.email ?? "" + } + }) + }); + if (!res.ok) throw new KbdbError(`POST /records\uFF08${MCP_REDIRECT_HOST_TEMPLATE}\uFF09\u2192 ${res.status}`); + const created = await res.json().catch(() => null); + return c.json({ + success: true, + host: norm.host, + record_id: created?.record_id ?? created?.record?.record_id ?? "" + }); + }) +); +portalRouter.delete( + "/portal/admin/mcp-redirect-hosts/:id", + (c) => run(c, async () => { + const auth = await requirePortalAdmin(c); + if (!auth.ok) return auth.res; + const recordId = c.req.param("id"); + const rows = await listMcpRedirectHosts(c.env); + const target = rows.find((r) => r.record_id === recordId); + if (!target) return c.json({ error: "\u9019\u500B\u7DB2\u57DF\u4E0D\u5728\u540D\u55AE\u4E0A\uFF08\u53EF\u80FD\u5DF2\u7D93\u88AB\u79FB\u9664\u4E86\uFF09" }, 404); + const found = await deleteKbdbRecord(c.env, recordId); + if (!found) return c.json({ error: "\u9019\u500B\u7DB2\u57DF\u4E0D\u5728\u540D\u55AE\u4E0A\uFF08\u53EF\u80FD\u5DF2\u7D93\u88AB\u79FB\u9664\u4E86\uFF09" }, 404); + return c.json({ success: true, host: target.values.host ?? "" }); + }) +); +portalRouter.get( + "/portal/internal/mcp-redirect-hosts", + (c) => run(c, async () => { + const expected = c.env.KBDB_INTERNAL_TOKEN ?? ""; + if (!expected) { + return c.json({ error: "\u9019\u53F0\u5BE6\u4F8B\u6C92\u6709\u8A2D\u5B9A\u670D\u52D9\u5167\u90E8\u91D1\u9470\uFF08KBDB_INTERNAL_TOKEN\uFF09\uFF0C\u7121\u6CD5\u56DE\u7B54" }, 503); + } + const got = (c.req.header("authorization") ?? "").match(/^Bearer\s+(\S+)/i)?.[1] ?? ""; + if (!got || !constantTimeEqual(got, expected)) return c.json({ error: "unauthorized" }, 401); + const rows = await listMcpRedirectHosts(c.env); + return c.json({ success: true, hosts: rows.map((r) => (r.values.host ?? "").trim()).filter(Boolean) }); + }) +); portalRouter.delete( "/portal/admin/libraries/by-name/:name", (c) => run(c, async () => { @@ -17288,11 +17640,12 @@ portalDataRouter.get( return c.json({ success: true, entry }); }) ); -async function fetchNeighborsFromKbdb(env, tenant2, node, depth, libraries) { +async function fetchNeighborsFromKbdb(env, tenant2, node, depth, libraries, directed) { const qs = new URLSearchParams(); qs.set("depth", String(depth)); qs.set("template", "triplet"); if (!libraries.includes("*")) qs.set("library", libraries.join(",")); + if (directed) qs.set("directed", "true"); const res = await kbdbFetch(env, `/graph/neighbors/${encodeURIComponent(node)}?${qs.toString()}&${ownerQuery(tenant2)}`); const body = await res.json().catch(() => null); return { ok: res.ok, status: res.status, body }; @@ -17310,13 +17663,14 @@ portalDataRouter.get( const rawName = c.req.param("name"); const depthRaw = c.req.query("depth") ?? ""; const depth = /^\d{1,2}$/.test(depthRaw) ? Number(depthRaw) : 2; + const directed = c.req.query("directed") === "true"; const tryNames = [rawName]; const normalized = normalizeCjkQuery(rawName); if (normalized !== rawName) tryNames.push(normalized); let first = null; try { for (const name of tryNames) { - const r = await fetchNeighborsFromKbdb(c.env, tenant2, name, depth, libraries); + const r = await fetchNeighborsFromKbdb(c.env, tenant2, name, depth, libraries, directed); if (!first) first = r; if (!r.ok) break; const mapped = mapGraphNeighborsResponse(r.body); @@ -17325,7 +17679,7 @@ portalDataRouter.get( if (first?.ok) { const fallbackName = await fuzzyFindNode(c.env, tenant2, rawName, libraries); if (fallbackName && !tryNames.includes(fallbackName)) { - const r = await fetchNeighborsFromKbdb(c.env, tenant2, fallbackName, depth, libraries); + const r = await fetchNeighborsFromKbdb(c.env, tenant2, fallbackName, depth, libraries, directed); if (r.ok) { const mapped = mapGraphNeighborsResponse(r.body); if (mapped.count > 0) return c.json(mapped); diff --git a/arcrun-kbdb/worker.mjs b/arcrun-kbdb/worker.mjs index aefe7da..de01a55 100644 --- a/arcrun-kbdb/worker.mjs +++ b/arcrun-kbdb/worker.mjs @@ -2124,6 +2124,10 @@ var UNLABELLED_LIBRARY = "general"; function libraryOf(expr) { return `COALESCE(NULLIF(${expr}, ''), '${UNLABELLED_LIBRARY}')`; } +function libraryOfValue(value) { + const v = (value ?? "").trim(); + return v === "" ? UNLABELLED_LIBRARY : v; +} var ENTRY_LIBRARY_EXPR = "json_extract(metadata_json, '$.library')"; var ENTRY_LIBRARY = libraryOf(ENTRY_LIBRARY_EXPR); var ENTRY_UNLABELLED = `(${ENTRY_LIBRARY_EXPR} IS NULL OR ${ENTRY_LIBRARY_EXPR} = '')`; @@ -2164,21 +2168,25 @@ async function getEntry(db, id) { return row ?? null; } var NOT_MACHINERY_PREDICATE = "(src_id IS NULL AND entry_type NOT IN ('record', 'sheet', 'field', 'system'))"; +function eqTerm(column, exactKeyPresent) { + return exactKeyPresent ? `+${column} = ?` : `${column} = ?`; +} async function listEntries(db, f = {}) { const conds = []; const params = []; + const exact = Boolean(f.page_name || f.source); if (f.entry_type) { - conds.push("entry_type = ?"); + conds.push(eqTerm("entry_type", exact)); params.push(f.entry_type); } else { conds.push(NOT_MACHINERY_PREDICATE); } if (f.owner_id) { - conds.push("owner_id = ?"); + conds.push(eqTerm("owner_id", exact)); params.push(f.owner_id); } if (f.parent_id) { - conds.push("parent_id = ?"); + conds.push(eqTerm("parent_id", exact)); params.push(f.parent_id); } if (f.page_name) { @@ -2206,11 +2214,17 @@ async function listEntries(db, f = {}) { const where = conds.length ? `WHERE ${conds.join(" AND ")}` : ""; const limit = Math.min(f.limit ?? 100, 1e3); const offset = f.offset ?? 0; - const [rowsRes, countRow] = await Promise.all([ - db.prepare(`SELECT * FROM entries ${where} ORDER BY created_at DESC, rowid DESC LIMIT ? OFFSET ?`).bind(...params, limit, offset).all(), - db.prepare(`SELECT COUNT(*) as total FROM entries ${where}`).bind(...params).first() - ]); - return { entries: rowsRes.results ?? [], total: countRow?.total ?? 0 }; + const pageSizeKnown = Number.isFinite(limit) && limit > 0; + const rowsRes = await db.prepare(`SELECT * FROM entries ${where} ORDER BY created_at DESC, rowid DESC LIMIT ? OFFSET ?`).bind(...params, limit, offset).all(); + const entries = rowsRes.results ?? []; + let total; + if (pageSizeKnown && entries.length > 0 && entries.length < limit) total = offset + entries.length; + else if (pageSizeKnown && entries.length === 0 && offset === 0) total = 0; + else { + const countRow = await db.prepare(`SELECT COUNT(*) as total FROM entries ${where}`).bind(...params).first(); + total = countRow?.total ?? 0; + } + return { entries, total }; } async function blocksOfPages(db, pages, perPageLimit = 8) { if (pages.length === 0) return []; @@ -3521,6 +3535,58 @@ entryRoutes.delete("/:id", async (c) => { return c.json({ success: true, vector_deleted }); }); +// kbdb/src/actions/entity-canon.ts +var NOTE_EXT = /\.(md|markdown|mdx|txt|org)$/i; +function unwrapWhole(t) { + const code = /^(`+)([\s\S]*?)(`+)$/.exec(t); + if (code) { + const inner = code[2].trim(); + if (inner && !inner.includes("`")) return inner; + } + if (t.startsWith("[[") && t.endsWith("]]") && t.length > 4) { + const inner = t.slice(2, -2).trim(); + if (inner && !inner.includes("[[") && !inner.includes("]]")) return inner; + } + return t; +} +function canonicalEntity(raw2) { + if (typeof raw2 !== "string") return raw2; + const fallback = raw2.trim(); + let t = raw2.normalize("NFC").trim(); + if (!t) return fallback; + for (let i = 0; i < 4; i++) { + const before = t; + t = unwrapWhole(t); + if (t === before) break; + } + if (NOTE_EXT.test(t)) { + t = t.replace(NOTE_EXT, ""); + const cut = Math.max(t.lastIndexOf("/"), t.lastIndexOf("\\")); + if (cut >= 0) t = t.slice(cut + 1); + } + t = t.replace(/\s+/g, " ").trim(); + return t || fallback; +} +var ENTITY_SLOTS = ["subject", "object"]; +function isTripletShaped(slots) { + return slots.includes("subject") && slots.includes("predicate") && slots.includes("object"); +} +function canonicalizeEntityValues(slots, values) { + if (!isTripletShaped(slots)) return values; + let touched = false; + const out = { ...values }; + for (const slot of ENTITY_SLOTS) { + const v = out[slot]; + if (typeof v !== "string") continue; + const c = canonicalEntity(v); + if (c !== v) { + out[slot] = c; + touched = true; + } + } + return touched ? out : values; +} + // kbdb/src/actions/record-crud.ts function uid2(prefix) { return `${prefix}_${crypto.randomUUID()}`; @@ -3622,7 +3688,7 @@ async function createRecord(db, input) { if (!tpl) throw new Error(`template not found: ${input.template}`); const slots = JSON.parse(tpl.slots_json); const recordId = input.record_id ?? uid2("rec"); - const values = input.values ?? {}; + const values = canonicalizeEntityValues(slots, input.values ?? {}); const entryIds = input.entry_ids ?? {}; const refSlots = Object.keys(entryIds); const ownerId = input.owner_id ?? null; @@ -3673,7 +3739,8 @@ async function updateRecord(db, recordId, values) { const recordOwnerId = identity?.owner_id ?? null; const tpl = await getTemplate(db, templateId); const allowed = tpl ? JSON.parse(tpl.slots_json) : [...slotToEntries.keys()]; - for (const [slot, content] of Object.entries(values)) { + const canon = canonicalizeEntityValues(allowed, values); + for (const [slot, content] of Object.entries(canon)) { if (!allowed.includes(slot)) { throw new Error(`slot not in template: ${slot}`); } @@ -3780,13 +3847,19 @@ async function deleteRecord(db, recordId) { } // kbdb/src/routes/templates.ts +function readFields(body) { + if (!body) return void 0; + const raw2 = Array.isArray(body.slots) ? body.slots : Array.isArray(body.fields) ? body.fields : void 0; + return raw2; +} var templateRoutes = new Hono2(); templateRoutes.post("/", async (c) => { const body = await c.req.json().catch(() => null); - if (!body || !body.name || !Array.isArray(body.slots)) { - return c.json({ success: false, error: "name and slots[] required" }, 400); + const fields = readFields(body); + if (!body || !body.name || !Array.isArray(fields)) { + return c.json({ success: false, error: "name and slots[] (alias: fields[]) required" }, 400); } - const tpl = await createTemplate(c.env.DB, body); + const tpl = await createTemplate(c.env.DB, { ...body, slots: fields }); return c.json({ success: true, template: tpl }); }); templateRoutes.get("/", async (c) => { @@ -3800,7 +3873,11 @@ templateRoutes.get("/:idOrName", async (c) => { }); templateRoutes.patch("/:id", async (c) => { const body = await c.req.json().catch(() => ({})); - const tpl = await updateTemplate(c.env.DB, c.req.param("id"), body); + const fields = readFields(body); + const tpl = await updateTemplate(c.env.DB, c.req.param("id"), { + ...body, + ...fields === void 0 ? {} : { slots: fields } + }); if (!tpl) return c.json({ success: false, error: "not found" }, 404); return c.json({ success: true, template: tpl }); }); @@ -4615,14 +4692,18 @@ async function findTripletEdgesByNode(db, templateIdOrName, fields, nodeValue, o } return rows; } +function neighborToEdge(n) { + return n.direction === "in" ? { subject: n.node, predicate: n.predicate, object: n.from } : { subject: n.from, predicate: n.predicate, object: n.node }; +} async function graphNeighbors(db, start, opts = {}) { const depth = Math.max(1, Math.min(Math.floor(opts.depth ?? 1) || 1, 10)); const template = opts.template ?? "triplet"; const directed = !!opts.directed; const owner_id = opts.owner_id; const libraries = opts.library && opts.library.length > 0 ? opts.library : void 0; - const visited = /* @__PURE__ */ new Set([start]); - let frontier = [start]; + const startNode = canonicalEntity(start); + const visited = /* @__PURE__ */ new Set([startNode]); + let frontier = [startNode]; const neighbors = []; for (let d = 1; d <= depth; d++) { if (frontier.length === 0) break; @@ -4634,7 +4715,7 @@ async function graphNeighbors(db, start, opts = {}) { const nb = e.object; if (!nb || visited.has(nb)) continue; visited.add(nb); - neighbors.push({ node: nb, predicate: e.predicate ?? "", from: cur, depth: d }); + neighbors.push({ node: nb, predicate: e.predicate ?? "", from: cur, depth: d, direction: "out" }); next.push(nb); } if (!directed) { @@ -4644,18 +4725,125 @@ async function graphNeighbors(db, start, opts = {}) { const nb = e.subject; if (!nb || visited.has(nb)) continue; visited.add(nb); - neighbors.push({ node: nb, predicate: e.predicate ?? "", from: cur, depth: d }); + neighbors.push({ node: nb, predicate: e.predicate ?? "", from: cur, depth: d, direction: "in" }); next.push(nb); } } } frontier = next; } - return { success: true, start, depth, directed, libraries: libraries ?? null, neighbors, count: neighbors.length }; + return { success: true, start: startNode, depth, directed, libraries: libraries ?? null, neighbors, count: neighbors.length }; +} + +// kbdb/src/actions/entity-canon-backfill.ts +var HARD_LIMIT_CAP2 = 2e3; +var DEFAULT_LIMIT = 500; +async function canonicalizeTripletEntities(db, env, opts = {}) { + const templateName = opts.template ?? "triplet"; + const dryRun = opts.dry_run !== false; + const limit = Math.min(Math.max(opts.limit ?? DEFAULT_LIMIT, 1), HARD_LIMIT_CAP2); + const offset = Math.max(opts.offset ?? 0, 0); + const ownerId = opts.owner_id?.trim() || void 0; + const tpl = await getTemplate(db, templateName); + if (!tpl) throw new Error(`triplet template not found: ${templateName}`); + const slots = JSON.parse(tpl.slots_json); + if (!isTripletShaped(slots)) { + throw new Error(`template is not triplet-shaped (need subject/predicate/object): ${templateName}`); + } + const sql = `SELECT b.src_id AS rid, + MAX(CASE WHEN r.rel_id = ? THEN r.dst_id END) AS subject_eid, + MAX(CASE WHEN r.rel_id = ? THEN v.content END) AS subject, + MAX(CASE WHEN r.rel_id = ? THEN r.dst_id END) AS object_eid, + MAX(CASE WHEN r.rel_id = ? THEN v.content END) AS object, + MAX(CASE WHEN r.rel_id = ? THEN v.content END) AS library + FROM entries b + LEFT JOIN entries r ON r.src_id = b.src_id AND r.rel_id != 'sys_belongs' + LEFT JOIN entries v ON v.id = r.dst_id + WHERE b.rel_id = 'sys_belongs' AND b.dst_id = ?${ownerId ? " AND b.owner_id = ?" : ""} + GROUP BY b.src_id + ORDER BY b.src_id + LIMIT ? OFFSET ?`; + const sField = fieldEntryId(tpl.id, "subject"); + const oField = fieldEntryId(tpl.id, "object"); + const lField = fieldEntryId(tpl.id, "library"); + const params = [sField, sField, oField, oField, lField, tpl.id]; + if (ownerId) params.push(ownerId); + params.push(limit, offset); + const res = await db.prepare(sql).bind(...params).all(); + const rows = res.results ?? []; + const rewrites = /* @__PURE__ */ new Map(); + const groups = /* @__PURE__ */ new Map(); + let scannedCells = 0; + for (const row of rows) { + const lib = libraryOfValue(row.library); + for (const slot of ENTITY_SLOTS) { + const raw2 = slot === "subject" ? row.subject : row.object; + const eid = slot === "subject" ? row.subject_eid : row.object_eid; + if (typeof raw2 !== "string" || !raw2 || !eid) continue; + scannedCells++; + const canon = canonicalEntity(raw2); + const key = `${lib}\0${canon}`; + const g = groups.get(key) ?? { library: lib, canonical: canon, from: /* @__PURE__ */ new Set(), cells: 0 }; + g.from.add(raw2); + g.cells++; + groups.set(key, g); + if (canon !== raw2) rewrites.set(eid, canon); + } + } + const changedCells = rewrites.size; + const merges = [...groups.values()].filter((g) => g.from.size > 1).map((g) => ({ library: g.library, canonical: g.canonical, from: [...g.from].sort(), cells: g.cells })).sort((a, b) => b.from.length - a.from.length || a.canonical.localeCompare(b.canonical)); + const budget = await maintenanceBudgetToday(env, db); + let applied = 0; + let quotaExceeded = false; + if (!dryRun && changedCells > 0) { + const entries = [...rewrites.entries()].slice(0, budget.remaining); + quotaExceeded = entries.length < changedCells; + if (entries.length > 0) { + await db.batch( + entries.map( + ([eid, content]) => db.prepare("UPDATE entries SET content = ?, updated_at = unixepoch() WHERE id = ?").bind(content, eid) + ) + ); + applied = entries.length; + } + try { + await addMaintenanceUsage(db, applied); + } catch { + } + } + return { + dry_run: dryRun, + triplet_template: templateName, + scanned_records: rows.length, + scanned_cells: scannedCells, + changed_cells: changedCells, + applied_cells: applied, + merges, + quota_limit: budget.limit, + quota_used_today: budget.used + applied, + quota_exceeded: quotaExceeded, + // 這一批滿了就還有下一批;沒滿就是掃到底了(誠實回 null,不要讓呼叫端自己猜) + next_offset: rows.length === limit ? offset + limit : null + }; } // kbdb/src/routes/graph.ts var graphRoutes = new Hono2(); +graphRoutes.post("/canonicalize-entities", async (c) => { + const body = await c.req.json().catch(() => ({})); + try { + const result = await canonicalizeTripletEntities(c.env.DB, c.env, { + template: typeof body.template === "string" ? body.template : void 0, + owner_id: typeof body.owner_id === "string" ? body.owner_id : void 0, + dry_run: body.dry_run === false ? false : true, + limit: typeof body.limit === "number" ? body.limit : void 0, + offset: typeof body.offset === "number" ? body.offset : void 0 + }); + return c.json({ success: true, ...result }); + } catch (e) { + return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 400); + } +}); graphRoutes.get("/neighbors/:node", async (c) => { const node = c.req.param("node"); if (!node) return c.json({ success: false, error: "node required" }, 400); @@ -4667,11 +4855,7 @@ graphRoutes.get("/neighbors/:node", async (c) => { const library = parseLibraryList(c.req.query("library")); try { const result = await graphNeighbors(c.env.DB, node, { depth, template, directed, owner_id, library }); - const edges = result.neighbors.map((n) => ({ - subject: n.from, - predicate: n.predicate, - object: n.node - })); + const edges = result.neighbors.map(neighborToEdge); return c.json({ ...result, edges }); } catch (e) { return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 500); @@ -4971,6 +5155,16 @@ var GENERATIONS = [ file: "0008_entries_content_index.sql", what: "entries.content \u7D22\u5F15\u2014\u2014graph \u9130\u5C45\u67E5\u8A62\u5F9E\u7BC0\u9EDE\u540D\u76F4\u63A5\u67E5\uFF08\u4E0D\u6488\u5168\u8868\uFF09\uFF0CArcrun#168 \u6839\u56E0\u4FEE\u5FA9\u7684\u5730\u57FA", checks: [{ kind: "index", name: "idx_entries_content" }] + }, + { + n: 9, + file: "0009_entries_list_indexes.sql", + what: "entries \u5217\u8868\uFF0F\u5B58\u5728\u6027\u7D22\u5F15\u2014\u2014list \u7AEF\u9EDE\u4E0D\u518D\u6392\u5E8F\u6574\u500B owner\u3001\u300C\u9019\u5F35\u5361\u5728\u4E0D\u5728\u300D\u53EA\u8B80\u547D\u4E2D\u5217\uFF08Arcrun#210 D1 \u514D\u8CBB\u5C64\u4E09\u5929\u9023\u71D2\u7684\u6839\u56E0\u4FEE\u5FA9\uFF09", + checks: [ + { kind: "index", name: "idx_entries_owner_type_created" }, + { kind: "index", name: "idx_entries_owner_created" }, + { kind: "index", name: "idx_entries_source" } + ] } ]; var EXPECTED_GENERATION = GENERATIONS[GENERATIONS.length - 1].n; @@ -5124,6 +5318,7 @@ app.get("/maintenance/relation-orphans", async (c) => { }); app.route("/entries", entryRoutes); app.route("/templates", templateRoutes); +app.route("/sheets", templateRoutes); app.route("/records", recordRoutes); app.route("/recipe-stats", recipeStatRoutes); app.route("/execution-log", executionLogRoutes); diff --git a/arcrun-mcp/worker.mjs b/arcrun-mcp/worker.mjs index bfc0ec6..f467b01 100644 --- a/arcrun-mcp/worker.mjs +++ b/arcrun-mcp/worker.mjs @@ -31796,11 +31796,11 @@ function registerAllKbdbDataTools(server, env, identity) { function registerCreateTemplate(server, env, identity) { server.tool( "kbdb_create_template", - "\u5EFA\u4E00\u500B KBDB template\uFF08\u842C\u7528\u8868\u88E1\u7684\u4E00\u7A2E\u8CC7\u6599\u5F62\u72C0\uFF0C\u985E Supabase \u7684\u865B\u64EC\u8868\uFF09\u3002KBDB \u4E0D\u80FD\u5EFA\u771F\u7684\u8CC7\u6599\u8868\u2014\u2014\u8981\u5B58\u300C\u65B0\u985E\u578B\u300D\u7684\u7D50\u69CB\u5316\u8CC7\u6599\u6642\uFF0C\u5C31\u5EFA\u4E00\u500B template \u4E26\u7528 slots \u5217\u51FA\u5B83\u7684\u6B04\u4F4D\u540D\uFF0C\u4E4B\u5F8C\u7528 kbdb_create_record \u586B\u503C\u3002\u4F8B\uFF1Aname='contact', slots=['name','email','phone']\u3002", + "\u5728 KBDB \u5EFA\u4E00\u5F35**\u865B\u64EC\u8868**\uFF08\u5BE6\u4F5C\u88E1\u53EB sheet\uFF09\u3002KBDB \u6C38\u9060\u4E0D\u52A0\u771F\u7684\u8CC7\u6599\u8868\uFF0C\u4E5F\u4E0D\u63A5\u53D7 SQL\u2014\u2014\u8981\u5B58\u300C\u65B0\u985E\u578B\u300D\u7684\u7D50\u69CB\u5316\u8CC7\u6599\u6642\uFF0C\u5C31\u5EFA\u4E00\u5F35\u865B\u64EC\u8868\uFF0C\u7528 slots \u5217\u51FA\u5B83\u7684**\u865B\u64EC\u6B04\u4F4D**\u540D\uFF0C\u4E4B\u5F8C\u7528 kbdb_create_record \u586B\u503C\u3002\u4F8B\uFF1Aname='contact', slots=['name','email','phone']\u3002\u{1F534} \u60F3\u5230 CREATE TABLE / ALTER TABLE \u5C31\u662F\u7528\u932F\u6A5F\u5236\u4E86\uFF1A\u6B63\u89E3\u6C38\u9060\u662F\u9019\u652F\u5DE5\u5177\u3002", { - name: external_exports.string().min(1).describe("template \u540D\u7A31\uFF08\u552F\u4E00\u8B58\u5225\uFF0C\u4E4B\u5F8C\u586B record \u7528\u9019\u500B\u540D\u5B57\uFF09\uFF0C\u5982 'contact' / 'note'"), - slots: external_exports.array(external_exports.string().min(1)).min(1).describe("\u6B04\u4F4D\u540D\u6E05\u55AE\uFF0C\u5982 ['name','email','phone']"), - description: external_exports.string().optional().describe("\u9019\u500B template \u7528\u9014\u7684\u7C21\u8FF0\uFF08\u9078\u586B\uFF09"), + name: external_exports.string().min(1).describe("\u865B\u64EC\u8868\u7684\u540D\u5B57\uFF08\u552F\u4E00\u8B58\u5225\uFF0C\u4E4B\u5F8C\u586B record \u7528\u9019\u500B\u540D\u5B57\uFF09\uFF0C\u5982 'contact' / 'note'"), + slots: external_exports.array(external_exports.string().min(1)).min(1).describe("**\u865B\u64EC\u6B04\u4F4D**\u540D\u6E05\u55AE\uFF0C\u5982 ['name','email','phone']\uFF08\u53C3\u6578\u540D slots \u662F\u6B77\u53F2\u6CBF\u7528\uFF0C\u4E0D\u662F\u90A3\u5F35\u4E0D\u5B58\u5728\u7684 slots \u8868\uFF09"), + description: external_exports.string().optional().describe("\u9019\u5F35\u865B\u64EC\u8868\u7528\u9014\u7684\u7C21\u8FF0\uFF08\u9078\u586B\uFF09"), created_by: external_exports.string().optional().describe("\u5EFA\u7ACB\u8005\u6A19\u8A18\uFF08\u9078\u586B\uFF1B\u767B\u5165\u8EAB\u5206\u4E0B\u7531 server \u8A18\u9304\uFF0C\u4E0D\u5403\u6B64\u503C\uFF09") }, async ({ name, slots, description, created_by }) => { @@ -31831,7 +31831,7 @@ function registerCreateTemplate(server, env, identity) { function registerListTemplates(server, env, identity) { server.tool( "kbdb_list_templates", - "\u5217\u51FA KBDB \u88E1\u6240\u6709 template\uFF08\u5DF2\u5B9A\u7FA9\u7684\u8CC7\u6599\u5F62\u72C0\uFF09\u3002\u8981\u5B58\u8CC7\u6599\u524D\u5148\u770B\u6709\u6C92\u6709\u73FE\u6210 template \u53EF\u7528\uFF0C\u6C92\u6709\u518D kbdb_create_template\u3002", + "\u5217\u51FA KBDB \u88E1\u6240\u6709**\u865B\u64EC\u8868**\uFF08\u5DF2\u5B9A\u7FA9\u7684\u8CC7\u6599\u5F62\u72C0\uFF09\u3002\u8981\u5B58\u8CC7\u6599\u524D\u5148\u770B\u6709\u6C92\u6709\u73FE\u6210\u7684\u53EF\u7528\uFF0C\u6C92\u6709\u518D kbdb_create_template\u3002\u56DE\u61C9\u88E1\u6BCF\u5F35\u8868\u7684 slots_json \u5C31\u662F\u5B83\u7684**\u865B\u64EC\u6B04\u4F4D**\u6E05\u55AE\u3002", {}, async () => { if (identity.kind === "stale") return staleIdentityError(); @@ -31843,9 +31843,9 @@ function registerListTemplates(server, env, identity) { } const data = await res.json(); return successResponse(data, [ - "\u6BCF\u500B template \u7684 slots_json \u662F\u5B83\u7684\u6B04\u4F4D\u6E05\u55AE", + "\u6BCF\u5F35\u865B\u64EC\u8868\u7684 slots_json \u662F\u5B83\u7684\u865B\u64EC\u6B04\u4F4D\u6E05\u55AE", "\u586B\u8CC7\u6599\u7528 kbdb_create_record", - "template \u662F\u5168\u57DF\u5171\u4EAB\u7684\u300C\u8CC7\u6599\u5F62\u72C0\u300D\u5B9A\u7FA9\uFF08schema\uFF09\uFF0C\u4E0D\u542B\u4EFB\u4F55\u4EBA\u7684\u5167\u5BB9\u2014\u2014\u5167\u5BB9\u7684\u6B0A\u9650\u5728 record/entry \u90A3\u5C64" + "\u865B\u64EC\u8868\u662F\u5168\u57DF\u5171\u4EAB\u7684\u300C\u8CC7\u6599\u5F62\u72C0\u300D\u5B9A\u7FA9\uFF0C\u4E0D\u542B\u4EFB\u4F55\u4EBA\u7684\u5167\u5BB9\u2014\u2014\u5167\u5BB9\u7684\u6B0A\u9650\u5728 record/entry \u90A3\u5C64" ]); } catch (e) { return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["\u7A0D\u5F8C\u91CD\u8A66"]); @@ -31856,10 +31856,10 @@ function registerListTemplates(server, env, identity) { function registerCreateRecord(server, env, identity) { server.tool( "kbdb_create_record", - "\u4F9D\u67D0 template \u586B\u4E00\u7B46 record\uFF08\u4E00\u5217\u8CC7\u6599\uFF09\u3002values \u662F {slot\u540D: \u5167\u5BB9}\uFF0Cslot \u540D\u8981\u5C0D\u5F97\u4E0A template \u7684 slots\u3002template \u4E0D\u5B58\u5728\u6703\u5931\u6557\u2014\u2014\u5148 kbdb_list_templates \u78BA\u8A8D\uFF0C\u6216 kbdb_create_template \u5EFA\u4E00\u500B\u3002", + "\u4F9D\u67D0\u5F35**\u865B\u64EC\u8868**\u586B\u4E00\u5217\u8CC7\u6599\uFF08record\uFF09\u3002values \u662F {\u865B\u64EC\u6B04\u4F4D\u540D: \u5167\u5BB9}\uFF0C\u6B04\u4F4D\u540D\u8981\u5C0D\u5F97\u4E0A\u90A3\u5F35\u8868\u5BA3\u544A\u7684\u6B04\u4F4D\u3002\u865B\u64EC\u8868\u4E0D\u5B58\u5728\u6703\u5931\u6557\u2014\u2014\u5148 kbdb_list_templates \u78BA\u8A8D\uFF0C\u6216 kbdb_create_template \u5EFA\u4E00\u5F35\u3002\u26A0\uFE0F values \u88E1**\u6C92\u5BA3\u544A\u904E\u7684\u6B04\u4F4D\u540D\u6703\u88AB\u975C\u9ED8\u4E1F\u6389**\uFF08\u56DE 200 \u4F46\u90A3\u683C\u6C92\u5BEB\u9032\u53BB\uFF09\u21D2 \u5BEB\u5B8C\u7528 kbdb_get_record \u8B80\u56DE\u4F86\u6838\u5C0D\u3002", { - template: external_exports.string().min(1).describe("template \u7684 name \u6216 id"), - values: external_exports.record(external_exports.string()).describe("\u6B04\u4F4D\u5167\u5BB9 {slot\u540D: \u5B57\u4E32\u5167\u5BB9}\uFF0C\u5982 {name:'Leo', email:'leo@x.com'}"), + template: external_exports.string().min(1).describe("\u865B\u64EC\u8868\u7684 name \u6216 id"), + values: external_exports.record(external_exports.string()).describe("\u6B04\u4F4D\u5167\u5BB9 {\u865B\u64EC\u6B04\u4F4D\u540D: \u5B57\u4E32\u5167\u5BB9}\uFF0C\u5982 {name:'Leo', email:'leo@x.com'}"), owner_id: external_exports.string().optional().describe("\u8CC7\u6599\u6B78\u5C6C\u6A19\u8A18\uFF08\u9078\u586B\uFF1B\u767B\u5165\u8EAB\u5206\u4E0B\u4E00\u5F8B\u7531 server \u5B9A\u6210\u4F60\u7684\u6B78\u5C6C\uFF0C\u4E0D\u5403\u6B64\u503C\uFF09") }, async ({ template, values, owner_id }) => { @@ -31876,13 +31876,13 @@ function registerCreateRecord(server, env, identity) { if (!res.ok) { if (identity.kind === "portal") return portalError(res, `\u586B record\uFF08template\u300C${template}\u300D\uFF09`); return errorResponse("create_record_failed", `\u586B record \u5931\u6557`, [ - `\u78BA\u8A8D template\u300C${template}\u300D\u5B58\u5728\uFF08kbdb_list_templates\uFF09`, - "values \u7684 slot \u540D\u8981\u5C0D\u5F97\u4E0A template \u7684 slots" + `\u78BA\u8A8D\u865B\u64EC\u8868\u300C${template}\u300D\u5B58\u5728\uFF08kbdb_list_templates\uFF09`, + "values \u7684\u6B04\u4F4D\u540D\u8981\u5C0D\u5F97\u4E0A\u90A3\u5F35\u865B\u64EC\u8868\u5BA3\u544A\u7684\u865B\u64EC\u6B04\u4F4D" ], await res.text().catch(() => "")); } const data = await res.json(); return successResponse(data, [ - `\u5DF2\u5B58\u5165\u3002\u7528 kbdb_query(template='${template}') \u5217\u51FA\u6B64 template \u7684\u6240\u6709 record`, + `\u5DF2\u5B58\u5165\u3002\u7528 kbdb_query(template='${template}') \u5217\u51FA\u9019\u5F35\u865B\u64EC\u8868\u7684\u6240\u6709 record\uFF1B\u7528 kbdb_get_record \u8B80\u56DE\u4F86\u6838\u5C0D`, ...identity.kind === "portal" ? [OWNER_IGNORED_HINT] : [] ]); } catch (e) { @@ -31905,7 +31905,7 @@ function registerGetRecord(server, env, identity) { if (res.status === 404) { return errorResponse("not_found", `\u67E5\u7121 record\u300C${record_id}\u300D\uFF08\u4E0D\u5B58\u5728\uFF0C\u6216\u4E0D\u5728\u4F60\u7684\u6B0A\u9650\u7BC4\u570D\u5167\uFF09`, [ "\u78BA\u8A8D record_id \u6B63\u78BA", - "\u7528 kbdb_query \u5217\u51FA\u67D0 template \u7684 record \u53D6 id" + "\u7528 kbdb_query \u5217\u51FA\u67D0\u5F35\u865B\u64EC\u8868\u7684 record \u53D6 id" ]); } if (!res.ok) { @@ -31923,9 +31923,9 @@ function registerGetRecord(server, env, identity) { function registerQuery(server, env, identity) { server.tool( "kbdb_query", - "\u5217\u51FA\u67D0 template \u5E95\u4E0B\u7684 record\uFF08\u7D50\u69CB\u5316\u67E5\u8A62\uFF0C\u6309 template \u53D6\u6574\u6279\u8CC7\u6599\uFF09\u3002**\u6703\u5206\u9801**\uFF1A\u56DE\u61C9\u7684 total \u662F\u7B26\u5408\u689D\u4EF6\u7684\u5168\u90E8\u7B46\u6578\u3001count \u662F\u9019\u4E00\u9801\u62FF\u5230\u5E7E\u7B46\u2014\u2014total \u6BD4\u5DF2\u53D6\u5F97\u7684\u591A\u5C31\u5E36 offset \u518D\u53EB\u4E00\u6B21\uFF0C\u4E0D\u8981\u628A\u7B2C\u4E00\u9801\u7576\u6210\u5168\u90E8\u3002\u8981\u6309\u95DC\u9375\u5B57\u627E\u5167\u5BB9\u7528 kbdb_search\u3002", + "\u5217\u51FA\u67D0\u5F35**\u865B\u64EC\u8868**\u5E95\u4E0B\u7684 record\uFF08\u7D50\u69CB\u5316\u67E5\u8A62\uFF0C\u6309\u865B\u64EC\u8868\u53D6\u6574\u6279\u8CC7\u6599\uFF09\u3002**\u6703\u5206\u9801**\uFF1A\u56DE\u61C9\u7684 total \u662F\u7B26\u5408\u689D\u4EF6\u7684\u5168\u90E8\u7B46\u6578\u3001count \u662F\u9019\u4E00\u9801\u62FF\u5230\u5E7E\u7B46\u2014\u2014total \u6BD4\u5DF2\u53D6\u5F97\u7684\u591A\u5C31\u5E36 offset \u518D\u53EB\u4E00\u6B21\uFF0C\u4E0D\u8981\u628A\u7B2C\u4E00\u9801\u7576\u6210\u5168\u90E8\u3002\u8981\u6309\u95DC\u9375\u5B57\u627E\u5167\u5BB9\u7528 kbdb_search\u3002", { - template: external_exports.string().min(1).describe("template \u7684 name \u6216 id"), + template: external_exports.string().min(1).describe("\u865B\u64EC\u8868\u7684 name \u6216 id"), owner_id: external_exports.string().optional().describe("\u53EA\u53D6\u67D0\u6B78\u5C6C\u7684 record\uFF08\u9078\u586B\uFF1B\u767B\u5165\u8EAB\u5206\u4E0B\u4E0D\u751F\u6548\uFF0C\u7BC4\u570D\u7531\u4F60\u7684\u6B0A\u9650\u6C7A\u5B9A\uFF09"), limit: external_exports.number().int().positive().optional().describe("\u9019\u4E00\u9801\u8981\u5E7E\u7B46\uFF08\u9810\u8A2D 100\uFF0C\u55AE\u6B21\u4E0A\u9650 500\uFF09"), offset: external_exports.number().int().min(0).optional().describe("\u5F9E\u7B2C\u5E7E\u7B46\u958B\u59CB\uFF08\u5206\u9801\u7528\uFF0C\u9810\u8A2D 0\uFF09") @@ -32051,7 +32051,10 @@ function registerGraphNeighbors(server, env, orgNamespace, identity) { env, identity.portal.session, `/portal/data/graph/neighbors/${encodeURIComponent(subject)}`, - { query: { depth: depth ?? 1 } } + // 🔴 inkstone/Arcrun#175:portal 路徑原本只送 depth,directed 被丟掉 ⇒ 引擎永遠當 + // 無向查詢,directed=true 也會回反向邊。這裡把它補上(服務級 token 路徑早就送了, + // 見下方 `if (directed) query.directed = "true"`)。未帶=不送=維持無向(criterion 4)。 + { query: { depth: depth ?? 1, ...directed ? { directed: "true" } : {} } } ); if (!res.ok) return portalError(res, `\u67E5\u300C${subject}\u300D\u7684\u9130\u5C45`); const out = await res.json().catch(() => null); @@ -33396,10 +33399,125 @@ function consentPage(p, error2) { `; } +function redirectBlockedPage(p) { + let host = ""; + try { + host = p.redirectUri ? new URL(p.redirectUri).hostname : ""; + } catch { + host = ""; + } + const what = host ? `

\u4F60\u7684 AI \u5DE5\u5177\u8981\u6C42\u628A\u6388\u6B0A\u7D50\u679C\u9001\u56DE ${esc2(host)}\uFF0C + \u4F46\u9019\u500B\u7DB2\u57DF\u4E0D\u5728\u9019\u500B\u77E5\u8B58\u5EAB\u5141\u8A31\u7684\u540D\u55AE\u4E0A\u3002

` : `

\u4F60\u7684 AI \u5DE5\u5177\u6C92\u6709\u5E36\u56DE\u9023\u7DDA\u7DB2\u5740\uFF08redirect_uri\uFF09\uFF0C + \u6216\u5E36\u7684\u683C\u5F0F\u4E0D\u6B63\u78BA\uFF0C\u6240\u4EE5\u7121\u6CD5\u78BA\u8A8D\u8981\u628A\u6388\u6B0A\u7D50\u679C\u9001\u56DE\u54EA\u88E1\u3002

`; + const list = p.allowed.length ? `` : `

\uFF08\u76EE\u524D\u4E00\u500B\u90FD\u6C92\u6709\uFF09

`; + const portalLink = p.portalUrl ? `

\u6253\u958B\u8A2D\u5B9A\u9801 \u2192

` : ""; + return ` + + + + +Arcrun MCP \u6388\u6B0A \u2014 \u9019\u500B\u7DB2\u57DF\u9084\u6C92\u88AB\u5141\u8A31 + + + +

\u9019\u500B\u7DB2\u57DF\u9084\u6C92\u88AB\u5141\u8A31\u9023\u4E0A\u4F60\u7684\u77E5\u8B58\u5EAB

+ ${what} +

\u600E\u9EBC\u89E3\u6C7A

+
    +
  1. \u7528\u700F\u89BD\u5668\u767B\u5165\u4F60\u7684\u77E5\u8B58\u5EAB Portal\uFF08\u7BA1\u7406\u54E1\u5E33\u865F\uFF09
  2. +
  3. \u5230 \u8A2D\u5B9A \u2192 \u63A5\u4E0A\u4F60\u7684 AI\uFF08MCP\uFF09
  4. +
  5. \u5728\u300C\u5141\u8A31\u9023\u7DDA\u7684\u7DB2\u5740\u300D\u628A${host ? ` ${esc2(host)} ` : "\u90A3\u500B\u5DE5\u5177\u7D66\u4F60\u7684 callback \u7DB2\u5740"}\u52A0\u9032\u53BB
  6. +
  7. \u56DE\u5230\u525B\u624D\u90A3\u500B\u5DE5\u5177\uFF0C\u91CD\u65B0\u6309\u4E00\u6B21\u6388\u6B0A
  8. +
+ ${portalLink} +

\u76EE\u524D\u5141\u8A31\u7684\u7DB2\u57DF

+ ${list} +

\u64CB\u4E0B\u9019\u6B21\u9023\u7DDA\u7684\u662F\u4F60\u81EA\u5DF1\u7684 Arcrun \u77E5\u8B58\u5EAB\uFF08\u4E0D\u662F Claude\u3001\u4E0D\u662F\u4F60\u7684 AI \u5DE5\u5177\uFF09\u3002 + \u9019\u9053\u6AA2\u67E5\u662F\u70BA\u4E86\u9632\u6B62\u6709\u4EBA\u628A\u6388\u6B0A\u7D50\u679C\u5C0E\u53BB\u5225\u7684\u7DB2\u7AD9\uFF0C\u6240\u4EE5\u9810\u8A2D\u53EA\u653E\u884C Claude \u5B98\u65B9\u7DB2\u57DF\uFF1B + \u5176\u9918\u8981\u7531\u4F60\u81EA\u5DF1\u52A0\u3002
+ \u6280\u8853\u8A0A\u606F\uFF08\u56DE\u5831\u6642\u9644\u4E0A\uFF09\uFF1Ainvalid_request: redirect_uri missing or not allowed

+ +`; +} + +// mcp/src/oauth/allowed-hosts.ts +var DEFAULT_REDIRECT_HOSTS = ["claude.ai", "claude.com", "anthropic.com"]; +var HOSTS_CACHE_TTL_MS = 15e3; +var hostsCache = null; +function parseHostList(raw2) { + return String(raw2 ?? "").split(",").map((s) => s.trim().toLowerCase()).filter(Boolean); +} +function union2(...lists) { + const seen = /* @__PURE__ */ new Set(); + const out = []; + for (const list of lists) { + for (const h of list) { + const k = h.trim().toLowerCase(); + if (!k || seen.has(k)) continue; + seen.add(k); + out.push(k); + } + } + return out; +} +async function fetchPortalHosts(env) { + if (!env.CYPHER_EXECUTOR || !env.KBDB_INTERNAL_TOKEN) return null; + try { + const res = await env.CYPHER_EXECUTOR.fetch( + new Request("https://cypher/portal/internal/mcp-redirect-hosts", { + headers: { Authorization: `Bearer ${env.KBDB_INTERNAL_TOKEN}` } + }) + ); + if (!res.ok) return null; + const body = await res.json(); + if (!Array.isArray(body.hosts)) return null; + return body.hosts.filter((h) => typeof h === "string"); + } catch { + return null; + } +} +async function allowedRedirectHosts(env) { + const now = Date.now(); + if (hostsCache && now - hostsCache.at < HOSTS_CACHE_TTL_MS) return hostsCache.hosts; + const fromEnv = parseHostList(env.MCP_ALLOWED_REDIRECT_HOSTS); + const fromPortal = await fetchPortalHosts(env) ?? []; + const hosts = union2(DEFAULT_REDIRECT_HOSTS, fromEnv, fromPortal); + hostsCache = { at: now, hosts }; + return hosts; +} +function hostMatches(host, hosts) { + const h = host.toLowerCase(); + return hosts.some((allowed) => h === allowed || h.endsWith("." + allowed)); +} +function isAllowedRedirect(uri, hosts) { + let u; + try { + u = new URL(uri); + } catch { + return false; + } + const host = u.hostname.toLowerCase(); + const isLocal = host === "localhost" || host === "127.0.0.1" || host === "::1"; + if (u.protocol === "http:") return isLocal; + if (u.protocol !== "https:") return false; + if (isLocal) return true; + return hostMatches(host, hosts); +} // mcp/src/oauth/routes.ts var DEFAULT_TOKEN_TTL = 2592e3; -var DEFAULT_REDIRECT_HOSTS = ["claude.ai", "claude.com", "anthropic.com"]; var CORS_JSON = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, OPTIONS", @@ -33414,21 +33532,15 @@ function tokenTtl(env) { const n = parseInt(env.MCP_TOKEN_TTL ?? "", 10); return Number.isFinite(n) && n > 0 ? n : DEFAULT_TOKEN_TTL; } -function isAllowedRedirect(uri, env) { - let u; +function portalUrlFromOrigin(origin) { try { - u = new URL(uri); + const u = new URL(origin); + if (!u.hostname.startsWith("arcrun-mcp.")) return ""; + u.hostname = u.hostname.replace("arcrun-mcp.", "arcrun-rag-ui."); + return u.origin + "/portal/"; } catch { - return false; + return ""; } - const host = u.hostname.toLowerCase(); - const isLocal = host === "localhost" || host === "127.0.0.1" || host === "::1"; - if (u.protocol === "http:") return isLocal; - if (u.protocol !== "https:") return false; - if (isLocal) return true; - const configured = (env.MCP_ALLOWED_REDIRECT_HOSTS ?? "").split(",").map((s) => s.trim().toLowerCase()).filter(Boolean); - const list = configured.length ? configured : DEFAULT_REDIRECT_HOSTS; - return list.some((h) => host === h || host.endsWith("." + h)); } async function readParams(req) { const ct = req.headers.get("content-type") ?? ""; @@ -33471,10 +33583,14 @@ function registerOAuthRoutes(app2) { } const redirectUris = Array.isArray(raw2.redirect_uris) ? raw2.redirect_uris.filter((x) => typeof x === "string") : []; const clientName = typeof raw2.client_name === "string" ? raw2.client_name : "MCP Client"; + const hosts = await allowedRedirectHosts(c.env); for (const uri of redirectUris) { - if (!isAllowedRedirect(uri, c.env)) { + if (!isAllowedRedirect(uri, hosts)) { return c.json( - { error: "invalid_redirect_uri", error_description: `redirect_uri not allowed: ${uri}` }, + { + error: "invalid_redirect_uri", + error_description: `redirect_uri not allowed: ${uri}\u3002\u9019\u500B\u7DB2\u57DF\u4E0D\u5728\u4F60\u7684\u77E5\u8B58\u5EAB\u5141\u8A31\u7684\u540D\u55AE\u4E0A\uFF0C\u8ACB\u767B\u5165 Portal \u7684\u300C\u8A2D\u5B9A \u2192 \u63A5\u4E0A\u4F60\u7684 AI\uFF08MCP\uFF09\u300D\u628A\u5B83\u52A0\u9032\u53BB\u3002\u76EE\u524D\u5141\u8A31\uFF1A${hosts.join(", ")}` + }, 400, CORS_JSON ); @@ -33495,7 +33611,7 @@ function registerOAuthRoutes(app2) { CORS_JSON ); }); - app2.get("/authorize", (c) => { + app2.get("/authorize", async (c) => { const q = c.req.query(); if (q.response_type !== "code") { return c.text("unsupported_response_type: only 'code' is supported", 400); @@ -33503,8 +33619,16 @@ function registerOAuthRoutes(app2) { if (q.code_challenge_method !== "S256" || !q.code_challenge) { return c.text("invalid_request: PKCE S256 code_challenge required", 400); } - if (!q.redirect_uri || !isAllowedRedirect(q.redirect_uri, c.env)) { - return c.text("invalid_request: redirect_uri missing or not allowed", 400); + const hosts = await allowedRedirectHosts(c.env); + if (!q.redirect_uri || !isAllowedRedirect(q.redirect_uri, hosts)) { + return c.html( + redirectBlockedPage({ + redirectUri: q.redirect_uri ?? "", + allowed: hosts, + portalUrl: portalUrlFromOrigin(originOf(c.req.url)) + }), + 400 + ); } const canonicalResource = resourceUri(originOf(c.req.url)); if (q.resource && !resourceMatches(q.resource, originOf(c.req.url))) { @@ -33532,8 +33656,16 @@ function registerOAuthRoutes(app2) { app2.post("/authorize", async (c) => { const p = await readParams(c.req.raw); const redirectUri = p.redirect_uri ?? ""; - if (!redirectUri || !isAllowedRedirect(redirectUri, c.env)) { - return c.text("invalid_request: redirect_uri not allowed", 400); + const hosts = await allowedRedirectHosts(c.env); + if (!redirectUri || !isAllowedRedirect(redirectUri, hosts)) { + return c.html( + redirectBlockedPage({ + redirectUri, + allowed: hosts, + portalUrl: portalUrlFromOrigin(originOf(c.req.url)) + }), + 400 + ); } if (p.code_challenge_method !== "S256" || !p.code_challenge) { return c.text("invalid_request: PKCE S256 required", 400); diff --git a/daemon/Arcrun-0.18.52.dmg b/daemon/Arcrun-0.18.52.dmg new file mode 100644 index 0000000..fba9381 Binary files /dev/null and b/daemon/Arcrun-0.18.52.dmg differ diff --git a/daemon/Arcrun-0.18.52.msix b/daemon/Arcrun-0.18.52.msix new file mode 100644 index 0000000..044a369 Binary files /dev/null and b/daemon/Arcrun-0.18.52.msix differ diff --git a/daemon/Arcrun-win-0.18.52.exe b/daemon/Arcrun-win-0.18.52.exe new file mode 100755 index 0000000..be46873 Binary files /dev/null and b/daemon/Arcrun-win-0.18.52.exe differ diff --git a/manifest.json b/manifest.json index 30656d7..357a36a 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "schema": 2, - "built": "2026-08-29", - "source": "Arcrun@e92d6e683271", + "built": "2026-09-13", + "source": "Arcrun@1eb26c98a3af", "core": [ { "name": "arcrun-array-ops", @@ -198,7 +198,7 @@ "name": "arcrun-cypher-executor", "main_module": "worker.mjs", "main_file": "arcrun-cypher-executor/worker.mjs", - "js_bytes": 703085, + "js_bytes": 721657, "modules": [], "compat_date": "2025-02-19", "compat_flags": [ @@ -239,10 +239,10 @@ "stripped": { "services": 13 }, - "source_commit": "f08274db7874d3c9ba6dba927cf58b272390daa4", - "source_content_sha256": "be1d58410009b640bf800e13b07db6a95ab0deff991315ec436ff6f1147771be", - "sha256": "be1d58410009b640bf800e13b07db6a95ab0deff991315ec436ff6f1147771be", - "bytes": 703085 + "source_commit": "1eb26c98a3af2067303e7544ae9bca1a77867c32", + "source_content_sha256": "2e61c3ebc75761463e79e1740da65971a9111aa1675442d8742a372816551591", + "sha256": "2e61c3ebc75761463e79e1740da65971a9111aa1675442d8742a372816551591", + "bytes": 721657 }, { "name": "arcrun-date-ops", @@ -396,7 +396,7 @@ "name": "arcrun-kbdb", "main_module": "worker.mjs", "main_file": "arcrun-kbdb/worker.mjs", - "js_bytes": 195715, + "js_bytes": 203823, "modules": [], "compat_date": "2025-02-19", "compat_flags": [ @@ -416,16 +416,16 @@ "ENVIRONMENT": "production" } }, - "source_commit": "98b45409740bdb4976f8744f09255c98be8fc10c", - "source_content_sha256": "fc1b12b56c7b3f8d68021349c72b5301afbb93333bf25887595b0fedc0e2232d", - "sha256": "fc1b12b56c7b3f8d68021349c72b5301afbb93333bf25887595b0fedc0e2232d", - "bytes": 195715 + "source_commit": "bed24fbbd5c3befed02c5690133d640f1d036e56", + "source_content_sha256": "8df57bc74d8f9c828db40207fd0079ae060374be27c0dc9cab835959aee091b0", + "sha256": "8df57bc74d8f9c828db40207fd0079ae060374be27c0dc9cab835959aee091b0", + "bytes": 203823 }, { "name": "arcrun-mcp", "main_module": "worker.mjs", "main_file": "arcrun-mcp/worker.mjs", - "js_bytes": 1208080, + "js_bytes": 1215339, "modules": [], "compat_date": "2024-11-27", "compat_flags": [ @@ -440,10 +440,10 @@ "ai": false, "vars": {} }, - "source_commit": "f87260b1234f0c108c378caf474d7af78cdd6cb9", - "source_content_sha256": "fad73a5128b50f8f42317ea1c4844ea39eee3c32846ab061282e77b731d810d0", - "sha256": "fad73a5128b50f8f42317ea1c4844ea39eee3c32846ab061282e77b731d810d0", - "bytes": 1208080 + "source_commit": "5dd01c41cc58d84bd0d854af083c48b657f77bc0", + "source_content_sha256": "e3190ad33e01abbe58de09a48d85af46129386bad1a52cf1eb9ce1aeb41b50af", + "sha256": "e3190ad33e01abbe58de09a48d85af46129386bad1a52cf1eb9ce1aeb41b50af", + "bytes": 1215339 }, { "name": "arcrun-merge", @@ -507,7 +507,7 @@ "name": "arcrun-rag-ui", "main_module": "index.js", "main_file": "tier2/ui/index.js", - "js_bytes": 715537, + "js_bytes": 723651, "modules": [], "compat_date": "2026-07-01", "compat_flags": [], @@ -518,10 +518,10 @@ "ai": false, "vars": {} }, - "source_commit": "e92d6e6832716b633e02cdfc2d73aba966c34c90", - "source_content_sha256": "d1f71a84caa3e40b1a546588b3f86bab145e8dde9edbb68c299e56a65c61825e", - "sha256": "d1f71a84caa3e40b1a546588b3f86bab145e8dde9edbb68c299e56a65c61825e", - "bytes": 715537 + "source_commit": "5dd01c41cc58d84bd0d854af083c48b657f77bc0", + "source_content_sha256": "9dfa16ae18c83b2a73689e0df9fabb34c65b82978a929927fa7fb9d1afb3e022", + "sha256": "9dfa16ae18c83b2a73689e0df9fabb34c65b82978a929927fa7fb9d1afb3e022", + "bytes": 723651 }, { "name": "arcrun-set", @@ -698,12 +698,12 @@ "bytes": 67697 } ], - "release": "1.4.63", + "release": "1.4.64", "built_for": "oauth-installer-lazy-load", "notes": [ "rag_takedown_direct 用了 __CARDS_PREFIX__,但安裝器的代換表沒有它 ⇒ 這個佔位符會原封不動被推進使用者的工作流。" ], - "fingerprint": "56d038b0d50909f375cc0b15dfc51364014d6b581c30c1d65bd0c8876aa2880c", + "fingerprint": "a925c1bd35c86d5305d96b07bd9d43839de4f179fd94acc8cd24370f54952d7a", "library": [ { "name": "arcrun-array-ops", @@ -888,7 +888,7 @@ "name": "arcrun-cypher-executor", "main_module": "worker.mjs", "main_file": "arcrun-cypher-executor/worker.mjs", - "js_bytes": 703085, + "js_bytes": 721657, "modules": [], "compat_date": "2025-02-19", "compat_flags": [ @@ -929,8 +929,8 @@ "stripped": { "services": 13 }, - "source_commit": "f08274db7874d3c9ba6dba927cf58b272390daa4", - "source_content_sha256": "be1d58410009b640bf800e13b07db6a95ab0deff991315ec436ff6f1147771be", + "source_commit": "1eb26c98a3af2067303e7544ae9bca1a77867c32", + "source_content_sha256": "2e61c3ebc75761463e79e1740da65971a9111aa1675442d8742a372816551591", "first_install": true }, { @@ -1075,7 +1075,7 @@ "name": "arcrun-kbdb", "main_module": "worker.mjs", "main_file": "arcrun-kbdb/worker.mjs", - "js_bytes": 195715, + "js_bytes": 203823, "modules": [], "compat_date": "2025-02-19", "compat_flags": [ @@ -1095,15 +1095,15 @@ "ENVIRONMENT": "production" } }, - "source_commit": "98b45409740bdb4976f8744f09255c98be8fc10c", - "source_content_sha256": "fc1b12b56c7b3f8d68021349c72b5301afbb93333bf25887595b0fedc0e2232d", + "source_commit": "bed24fbbd5c3befed02c5690133d640f1d036e56", + "source_content_sha256": "8df57bc74d8f9c828db40207fd0079ae060374be27c0dc9cab835959aee091b0", "first_install": true }, { "name": "arcrun-mcp", "main_module": "worker.mjs", "main_file": "arcrun-mcp/worker.mjs", - "js_bytes": 1208080, + "js_bytes": 1215339, "modules": [], "compat_date": "2024-11-27", "compat_flags": [ @@ -1118,8 +1118,8 @@ "ai": false, "vars": {} }, - "source_commit": "f87260b1234f0c108c378caf474d7af78cdd6cb9", - "source_content_sha256": "fad73a5128b50f8f42317ea1c4844ea39eee3c32846ab061282e77b731d810d0", + "source_commit": "5dd01c41cc58d84bd0d854af083c48b657f77bc0", + "source_content_sha256": "e3190ad33e01abbe58de09a48d85af46129386bad1a52cf1eb9ce1aeb41b50af", "first_install": true }, { @@ -1180,7 +1180,7 @@ "name": "arcrun-rag-ui", "main_module": "index.js", "main_file": "tier2/ui/index.js", - "js_bytes": 715537, + "js_bytes": 723651, "modules": [], "compat_date": "2026-07-01", "compat_flags": [], @@ -1191,8 +1191,8 @@ "ai": false, "vars": {} }, - "source_commit": "e92d6e6832716b633e02cdfc2d73aba966c34c90", - "source_content_sha256": "d1f71a84caa3e40b1a546588b3f86bab145e8dde9edbb68c299e56a65c61825e", + "source_commit": "5dd01c41cc58d84bd0d854af083c48b657f77bc0", + "source_content_sha256": "9dfa16ae18c83b2a73689e0df9fabb34c65b82978a929927fa7fb9d1afb3e022", "first_install": true }, { @@ -1489,20 +1489,23 @@ } ], "daemon": { - "version": "0.18.49", + "version": "0.18.52", "mac": { - "file": "daemon/Arcrun-0.18.49.dmg", - "sha256": "7ff5a6154314292e1e56137d106185515c01bab9762fbadf2ed03c5b07cd195c" + "file": "daemon/Arcrun-0.18.52.dmg", + "sha256": "a3bc29ef6dc00da982a2d7990fc1731d88fc1436df38d75499aafae0245a09ce" }, "win": { - "file": "daemon/Arcrun-win-0.18.49.exe", - "sha256": "ec3da2ec1f6236c7c8937a1ea88e0dc728070819174304e1e7c0d3fb72d6169b" + "file": "daemon/Arcrun-win-0.18.52.exe", + "sha256": "9e074685eb44e6a4729c819411221cc24018f7cdfcb732df15911741a5356f80" }, "msix": { - "file": "daemon/Arcrun-0.18.49.msix", - "sha256": "a24fc99db714f6f7eac8205b27381710d9814a9558b9734dd2cada7379903f56" + "file": "daemon/Arcrun-0.18.52.msix", + "sha256": "6e1f022ce812fb7c92ca7a4fbc32c428ab483a666971d96bfb5acdef4b0fdca3" }, - "built": "20260829-1134", - "notes": "雲端的知識庫清單看得出每一個資料夾是從哪一台電腦同步上去的・改名之後不會多出一台電腦,也不必等下一次改檔才生效:名字一換,下一輪就會把新名字送上去(細節見說明文件)" + "built": "20260913-2009", + "notes": "雲端資料庫的免費額度用完時,小幫手會直接告訴你・額度用完期間不再重打雲端・急著用的話,卡片上也會告訴你:升級 Cloudflare Workers 付費方案(每月 5 美元起)就沒有每日上限" + }, + "installer": { + "version": "1.0.10" } } diff --git a/tier2/ui/index.js b/tier2/ui/index.js index 1bf1b1d..20e6551 100644 --- a/tier2/ui/index.js +++ b/tier2/ui/index.js @@ -1,6 +1,6 @@ // arcrun-rag-ui — 用戶實例的 GUI(console-ui/public 內嵌;由 Arcrun scripts/build-ui-worker.mjs 產生,勿手改) -const FILES = {"/apple-touch-icon.png":{"type":"image/png","b64":true,"data":"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"},"/console/dashboard/index.html":{"type":"text/html; charset=utf-8","b64":false,"data":"\n\n\n\n\nArcrun 駕駛艙\n\n\n\n\n\n\n
\n
\n
Arcrun 駕駛艙
\n
\n
\n \n
\n
\n
\n
\n
\n
載入中
\n
\n
\n
\n
\n
\n
今日完成
\n
/
\n
\n
\n
\n
收件匣未處理
\n
\n
來自 Telegram
\n
\n
\n
\n
等你的事
\n
載入中…
\n
\n
\n
今日路線
\n \n
本週
\n \n
系統狀況live 健康信號
\n
載入中…
\n
每 60 秒自動刷新
\n 進入完整控制台 ›\n
\n\n\n\n"},"/console/index.html":{"type":"text/html; charset=utf-8","b64":false,"data":"\n\n\n\n\nArcrun Console\n\n\n\n\n\n\n\n\n
\n
\n
\n
Arcrun
\n
私人系統・僅供擁有者進入
\n
\n
\n \n \n \n
\n
\n
這是一個人的智慧總部。
若你不是擁有者,這裡沒有你要找的東西。
\n \n \n
\n
\n\n\n
\n
\n
\n
Arcrun
\n
首次設定
\n
系統偵測到尚未設定擁有者帳號。
設定一組 Email 與密碼,之後只有你能進入。
\n
\n
\n \n \n \n \n
\n
\n \n
\n
\n\n\n
\n
\n
Arcrun
\n \n
總庫搜尋
\n
工作流
\n \n
設定
\n
☾ 切深色
\n
\n
\n
\n\n \n
\n
Arcrun 控制台
\n
\n
\n
\n
\n
\n
載入中
\n
\n
\n
\n
\n
\n
今日完成
\n
/
\n
\n
\n
\n
收件匣未處理
\n
\n
來自 Telegram
\n
\n
\n
\n
等你的事
\n
載入中…
\n
\n
\n
\n
\n
\n 今日路線狀態即時同步\n
\n
  • 載入中…
\n
本週
\n
    \n
    每 60 秒自動刷新
    \n
    \n
    \n
    \n\n \n
    \n
    總庫搜尋
    \n
    \n
    \n
    \n 藏書地圖\n
    \n
    地圖載入中…
    \n
    \n
    \n
    \n \n \n
    \n
    \n
    \n
    \n
    \n
    \n
    \n
    \n\n \n
    \n
    \n \n 知識卡片\n
    \n
    \n
    載入中…
    \n
    \n
    關聯視圖
    \n
    \n
    \n
    \n
    \n
    \n\n \n
    \n
    工作流
    \n \n
    載入中…
    \n
    \n
    零件與 Recipes(框架資源)
    \n
    \n \n \n
    \n
    \n
    \n
    \n\n \n
    \n
    憑證管理
    \n
    \n 🛡 保險箱原則:系統只保存目錄與加密後的值,任何頁面都看不到密文內容。只能整筆替換或刪除。\n
    \n
    \n
    新增憑證
    \n
    \n \n \n \n \n
    \n
    \n
    \n
    載入中…
    \n
    \n\n \n
    \n
    分流台全部待辦・80/15/5
    \n
    \n
    載入中…
    \n
    \n\n \n
    \n
    設定
    \n
    \n
    \n
    \n
    \n
    深色模式
    \n
    預設淺色(紙感)。切換立即生效,選擇記在這台裝置(localStorage)。
    \n
    \n
    \n
    \n
    \n
    \n \n
    語意搜尋
    \n
    狀態偵測中…
    \n
    \n
    \n
    \n
    MCP token 有效期(TTL)
    \n
    讀取中…
    \n
    \n 誠實佔位:此頁還不能改這個值——可調功能=Arcrun#19(設定頁改→存 KBDB→發 token 時讀)。實作前要調整請在部署端 env MCP_TOKEN_TTL(mcp worker)設定。\n
    \n
    \n
    \n
    更換帳號密碼
    \n
    需輸入舊密碼驗證身分
    \n
    \n \n \n \n \n
    \n
    \n
    \n \n
    \n
    Portal 帳號密碼救援
    \n
    忘記某個 Portal(RAG 搜尋頁)帳號的密碼,包含你自己那組管理員帳號——不需要先登進 Portal。輸入該帳號的 Email,會產生一組新密碼,只顯示這一次,請立刻抄下並拿去 Portal 登入頁使用。
    \n
    \n \n \n
    \n
    \n
    \n
    \n
    系統資訊
    \n
    載入中…
    \n
    \n \n
    \n
    \n\n
    \n
    \n\n\n
    \n \n
    搜尋
    \n
    工作流
    \n \n
    設定
    \n
    \n\n
    \n\n\n\n\n"},"/favicon.ico":{"type":"image/x-icon","b64":true,"data":"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"},"/favicon.svg":{"type":"image/svg+xml","b64":false,"data":"arcrun icon"},"/index.html":{"type":"text/html; charset=utf-8","b64":false,"data":"\n\n\n\nArcrun RAG\n\n\n\n\n\n\n\n

    正在前往搜尋頁… 沒有自動跳轉請點這裡

    \n\n\n"},"/portal/app-mount.test.mjs":{"type":"application/javascript; charset=utf-8","b64":false,"data":"// App 掛載協定 v0.2 的結構自測(Arcrun#152/#82,leo 2026-08-24 拍板)\n// 跑法:node console-ui/public/portal/app-mount.test.mjs\n//\n// 這支守的是**協定的形狀**,不是長相——長相要用瀏覽器量(見 #152 的驗收留言)。\n// 之所以要有它:這一輪的病根是「出口與樣式被做成個案」——筆記 App 有返回鍵、總圖沒有;\n// CIS 表複製進 shadow 了、但 App 根本沒接上。個案會悄悄再長出來,所以把\n// 「不准再有個案」這件事寫成可執行的檢查。\nimport fs from 'node:fs';\n\nconst html = fs.readFileSync(new URL('./index.html', import.meta.url).pathname, 'utf8');\nlet pass = 0, fail = 0;\nconst chk = (label, cond, extra = '') => {\n if (cond) { console.log('PASS:', label); pass++; }\n else { console.log('FAIL:', label, extra); fail++; }\n};\n\n// ── ① 出口:每一個非首頁的 view 都必須有 .pagehead ────────────────────────────\n// ensureBackControls() 是往 `#v- .pagehead` 裡塞返回鍵的。少了那一列 = 那個 view\n// 悄悄沒有出口,而且沒有人會發現(leo 就是這樣卡在總圖)。\nconst viewsLine = html.match(/var VIEWS = \\[(.*?)\\];/);\nif (!viewsLine) throw new Error('抽不到 VIEWS 清單');\nconst VIEWS = viewsLine[1].split(',').map((s) => s.trim().replace(/^'|'$/g, ''));\nconst HOME = (html.match(/var HOME = '([^']+)'/) || [])[1];\nchk('抽得到 VIEWS 與 HOME', VIEWS.length > 0 && !!HOME, `VIEWS=${VIEWS.length} HOME=${HOME}`);\n\nfor (const v of VIEWS) {\n if (v === HOME) continue;\n // 抓 `
    \">` 之後、下一個 view 之前那一段,看有沒有 pagehead\n const at = html.indexOf(`id=\"v-${v}\"`);\n if (at < 0) { chk(`view ${v} 存在於 HTML`, false); continue; }\n const nextView = html.indexOf('id=\"v-', at + 10);\n const block = html.slice(at, nextView < 0 ? at + 4000 : nextView);\n chk(`非首頁 view「${v}」有 .pagehead(返回鍵才掛得上)`, /class=\"pagehead\"/.test(block));\n}\n\n// ── ② 不准再有「某一頁自己的返回鍵」 ─────────────────────────────────────────\nchk('沒有殘留的個案返回鍵(app-back-btn 已撤)', !html.includes('app-back-btn'));\nchk('返回鍵只由 ensureBackControls() 產生(全檔只有一處 createElement 出 data-portal-back)',\n (html.match(/setAttribute\\('data-portal-back'/g) || []).length === 1);\nchk('返回鍵是頁首第一格(insertBefore firstChild)', /insertBefore\\(b, head\\.firstChild\\)/.test(html));\nchk('返回鍵一律回 HOME', /data-portal-back\\]'\\)\\) nav\\(HOME\\)/.test(html));\n\n// ── ③ 樣式:兩種模式與層序 ──────────────────────────────────────────────────\nchk('層序=arcrun-base < app < arcrun(全局在最上面才蓋得過 App)',\n html.includes('@layer arcrun-base, app, arcrun;'));\nchk('沒宣告=跟隨全局(預設 inherit,不是維持 App 原樣)',\n /var inherit = style !== 'own';/.test(html));\nchk('App 自己的 style 會被包進 app 層', /'@layer app\\{' \\+ st\\.textContent \\+ '\\}'/.test(html));\nchk('全局色票/CIS 都進 arcrun 層', (html.match(/'@layer arcrun\\{'/g) || []).length === 2);\nchk('樣式模式從 App 宣告來(不是 Portal 猜的)', /mountAppUi\\(host, app\\.ui_html, app\\.ui_style\\)/.test(html));\n\n// ── ④ 畫布是全局決定的(兩種模式都一樣)────────────────────────────────────\n// leo:「全局要給它多大的畫布也是全局決定的⋯⋯應該整個右側邊欄都是它的」\nchk('App 頁不吃 .page 的寬度上限與左右留白', /#v-app\\.on \\{[^}]*max-width: none;[^}]*padding: 0;/.test(html));\nchk('shadow host 由**外部文件**決定尺寸(App 在 shadow 裡改不動)',\n /#app-ui-mount \\{[^}]*width: 100%/.test(html));\n\n// ── ⑤ 隔離沒有被拆掉(這一輪的紅線)──────────────────────────────────────\nchk('App 仍然掛進 Shadow DOM', /host\\.attachShadow\\(\\{ mode: 'open' \\}\\)/.test(html));\n\nconsole.log(`\\n${fail === 0 ? '✅' : '❌'} pass=${pass} fail=${fail}`);\nprocess.exit(fail === 0 ? 0 : 1);\n"},"/portal/folder-tree.test.mjs":{"type":"application/javascript; charset=utf-8","b64":false,"data":"// 資料夾樹的畫面計算(InkStoneCo#44)——守 `gapWhy` 與 `rollupTree` 之間的**介面契約**。\n//\n// 🔴 這支測試存在的唯一理由,是一個 2026-08-17 真的送到瀏覽器才被抓到的 bug:\n//\n// `rollupTree()` 疊出來的合計物件用短鍵名 { total, synced, pending, unsupported, excluded }\n// 而 `gapWhy()` 當時讀的是節點的長鍵名 { total_files, unsupported_files, … }\n//\n// ⇒ 三個判斷全部拿到 undefined ⇒ **那行「為什麼沒上傳」永遠是空字串**。\n//\n// 症狀有多難發現:畫面完全正常——樹畫得出來、每個數字都對、也沒有任何 console 錯誤,\n// **只是那句解釋安靜地不見了**。而那句解釋正是 leo 要這個畫面回答的唯一那件事:\n// 「不上傳通常是不支援,比如程式碼、不支援的格式。」\n// 單元測試(雲端那半)全綠、`curl` 也看不出來(它不執行 JS)。\n//\n// ⇒ 所以這裡測的不是「gapWhy 會不會算數」,而是**它跟呼叫端講不講同一種話**。\n// 以後誰改了任一邊的鍵名,這支就會紅。\n\nimport fs from 'node:fs';\n\nconst html = fs.readFileSync(new URL('./index.html', import.meta.url).pathname, 'utf8');\n\n// 抽出 gapWhy + rollupTree 兩支(錨定「函式結束」這個結構,不綁文案——\n// 同 os-split.test.mjs 2026-08-05 的教訓:綁文案會讓改字就炸)。\nconst start = html.indexOf('function gapWhy');\nconst rollupAt = html.indexOf('function rollupTree', start);\nconst endMark = 'return { sums: sums, kids: kids };';\nconst end = rollupAt < 0 ? -1 : html.indexOf(endMark, rollupAt) + endMark.length + '\\n }'.length;\nif (start < 0 || rollupAt < 0 || end < start) throw new Error('抽不到函式區塊');\nconst src = html.slice(start, end);\n\nconst api = new Function(src + '; return { gapWhy: gapWhy, rollupTree: rollupTree };')();\n\nlet pass = 0, fail = 0;\nconst chk = (label, cond, extra = '') => {\n if (cond) { console.log('PASS:', label); pass++; }\n else { console.log('FAIL:', label, extra); fail++; }\n};\n\n// 小幫手真的會送上來的形狀(長鍵名),刻意混著支援/不支援/整棵被剪掉的目錄。\nconst nodes = [\n { path: '', name: 'kb', parent: '-', depth: 0, total_files: 2, synced_files: 1, pending_files: 1, unsupported_files: 0, excluded_files: 0 },\n { path: 'docs', name: 'docs', parent: '', depth: 1, total_files: 5, synced_files: 2, pending_files: 1, unsupported_files: 2, excluded_files: 0 },\n { path: 'docs/img', name: 'img', parent: 'docs', depth: 2, total_files: 4, synced_files: 0, pending_files: 0, unsupported_files: 4, excluded_files: 0 },\n { path: 'src', name: 'src', parent: '', depth: 1, total_files: 4, synced_files: 0, pending_files: 0, unsupported_files: 0, excluded_files: 4 },\n { path: 'node_modules', name: 'node_modules', parent: '', depth: 1, total_files: 0, synced_files: 0, pending_files: 0, unsupported_files: 0, excluded_files: 0, skipped: true, skip_reason: '這是工具產生的' },\n];\n\nconst r = api.rollupTree(nodes);\n\n// ① 子樹合計:根要把 docs/img/src 全部疊進來(skipped 的那棵不算——沒走進去就是不知道)\nconst root = r.sums[''];\nchk('根的合計=2+5+4+4(skipped 的 node_modules 不計)', root.total === 15, JSON.stringify(root));\nchk('根的已同步=1+2', root.synced === 3, JSON.stringify(root));\n\n// ② 🔴 本檔的重點:gapWhy 吃得下 rollupTree 吐出來的東西\nconst why = api.gapWhy(root);\nchk('gapWhy(合計) 不是空字串(鍵名對得上)', why.length > 0, JSON.stringify(why));\nchk('gapWhy 講得出不支援的份數', why.includes('6 份'), why); // 2(docs) + 4(img)\nchk('gapWhy 講得出不收的份數', why.includes('4 份'), why); // src\nchk('gapWhy 講得出處理中的份數', why.includes('2 份'), why); // 根 1 + docs 1\n\n// ③ 差額必須解釋得了(leo 的規格:兩個數字不相等是正常的,但差額要說得出來)\nconst gap = root.total - root.synced;\nchk('差額 == 不支援 + 不收 + 處理中', gap === root.unsupported + root.excluded + root.pending,\n `gap=${gap} unsup=${root.unsupported} excl=${root.excluded} pend=${root.pending}`);\n\n// ④ 完全同步的資料夾不該多嘴\nchk('沒有差額時 gapWhy 回空字串',\n api.gapWhy({ total: 3, synced: 3, pending: 0, unsupported: 0, excluded: 0 }) === '');\n\nconsole.log(`\\n${pass} passed, ${fail} failed`);\nprocess.exit(fail ? 1 : 0);\n"},"/portal/index.html":{"type":"text/html; charset=utf-8","b64":false,"data":"\n\n\n\n\nArcrun Portal\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n\n\n
    \n
    \n
    \n
    \"arc>run\" width=\"986\" height=\"176\">\"arc>run\" width=\"986\" height=\"176\">
    \n
    知識入口・Portal
    \n
    \n
    \n \n \n \n
    \n
    \n \n
    \n 忘記密碼了?寄一條「修改密碼」連結給我\n
    \n
    \n \n \n
    \n
    \n \n
    \n
    \n\n\n
    \n
    \n
    \n
    設定新密碼
    \n
    \n
    \n
    \n \n
    \n
    \n
    \n\n\n
    \n
    \n
    \n
    \"arc>run\" width=\"986\" height=\"176\">\"arc>run\" width=\"986\" height=\"176\">
    \n
    歡迎!先建立你的帳號
    \n
    \n
    \n \n \n \n \n
    \n \n \n
    \n
    這組帳密就是之後登入知識庫用的,只需要設定這一次。
    \n
    \n
    \n\n\n
    \n
    \n
    \"arcrun\"\"arcrun\"
    \n \n
    App 界面
    \n
    App 市集
    \n
    AR 設定
    \n
    App 開發
    \n
    ☾ 切深色
    \n
    \n
    \n
    \n\n \n
    \n
    App 界面
    \n
    載入中…
    \n
    \n\n \n
    \n
    搜尋
    \n
    \n
    \n \n \n
    \n
    \n \n \n \n
    \n \n
    \n
    AI 問答・答案附出處,可回搜尋驗證
    \n
    \n \n \n
    \n
    \n
    \n
    \n
    \n
    \n
    \n
    \n
    \n
    \n
    \n
    \n\n \n
    \n
    總圖
    \n
    \n
    \n
    \n 點節點可跳到該實體的圖譜搜尋。這張圖由知識庫的三元組即時算出——同一份地圖的文字版\n (給 AI 注入用的總庫目錄)。\n
    \n
    \n
    \n\n \n
    \n
    \n \n 知識卡片\n
    \n
    載入中…
    \n
    \n\n \n
    \n
    App 市集瀏覽、安裝、或上傳你自己做的 App
    \n
    \n
    \n
    \n
    \n
    上傳你的 App
    \n
    打包成一份宣告,讓其他人也能安裝——尚未接後端,先佔位
    \n
    \n
    \n
    已安裝
    \n
    載入中…
    \n
    可安裝
    \n \n
    載入中…
    \n
    \n
    \n\n \n
    \n
    上傳Markdown / 純文字
    \n
    \n 上傳的文件會進入知識庫收件管線:約 1 分鐘後可在搜尋頁找到;AI 整理後會以 wiki 卡形式出現。支援 .md / .txt(一律以 .md 收件)。\n
    \n
    \n
    把 .md / .txt 檔案拖放到這裡
    \n
    \n \n \n
    \n
    \n
    \n\n \n \n
    \n
    App 開發Workflows/零件/Recipe/Credential
    \n
    \n
    \n
    \n
    \n
    叫 AI 幫你做一個 App
    \n
    用一句話描述你要什麼——尚未接後端,先佔位
    \n
    \n
    \n
    工作環境
    \n
    \n
    \n Workflows\n
    \n
    \n 資料上傳\n
    \n
    零件尚未接進 Portal
    \n
    Recipe尚未接進 Portal
    \n
    Credential尚未接進 Portal
    \n
    \n
    \n
    \n\n
    \n
    工作流唯讀・系統狀態
    \n
    \n 此頁只顯示系統裡的工作流與最近執行狀態,不能觸發執行(觸發屬系統擁有者權限)。\n
    \n
    載入中…
    \n
    \n\n \n
    \n
    \n \n App\n \n
    \n
    載入中…
    \n
    \n\n \n
    \n
    設定
    \n \n
    \n
    帳號
    \n
    管理
    \n
    \n
    \n \n
    \n
    \n
    \n
    \n 版本\n \n
    \n
    查詢中…
    \n
    \n \n
    \n
    \n
    \n
    我的帳號
    \n
    載入中…
    \n
    \n
    \n
    \n
    \n
    深色模式
    \n
    預設淺色(紙感)。切換立即生效,選擇記在這台裝置。
    \n
    \n
    \n
    \n
    \n \n
    \n
    更改密碼
    \n
    需輸入舊密碼驗證身分;新密碼至少 8 碼
    \n
    \n \n \n \n \n
    \n
    \n
    \n \n
    \n \n
    \n 你的知識庫網址(小幫手連線用)\n \n \n
    \n
    同步小幫手
    \n
    把你電腦上的資料夾變成知識庫。換電腦或重裝時可以再下載一次。
    \n
    \n 下載 Mac 版\n
    封測版未簽章,第一次請右鍵→打開。裝好第一次開啟時,貼上這個網址+你的帳號密碼就連上了。
    \n
    \n
    \n \n
    \n
    \n 你的 MCP 網址(給你的 AI 連線用)\n \n \n
    \n
    接上你的 AI(MCP)
    \n
    把上面這串網址貼到 Claude、ChatGPT 等 AI 的「新增自訂連接器」欄位,就能讓你的 AI 直接查這個知識庫。
    \n
    \n \n
    \n
    AI 設定
    \n
    \n 這裡不需要任何設定。聊天問答用的是你自己 Cloudflare 帳號內建的 AI,裝好就能直接問。
    \n 文件整理成知識卡的部分,請在同步小幫手(電腦上的托盤圖示)的「AI 設定…」填一把 Gemini API Key。\n
    \n
    \n \n
    \n
    疑難排解
    \n
    要回報問題,請到你電腦上的 Arcrun(同步小幫手)「版本與更新」頁——那裡的「疑難排解」按一下就能匯出完整診斷檔給我們(只有統計數字,不含你的任何文件內容)。
    \n
    \n \n
    \n
    \n\n \n
    \n
    管理帳號與知識庫授權
    \n
    \n
    帳號
    \n
    管理
    \n
    \n\n
    執行紀錄保留期
    \n
    \n
    保留天數
    \n
    執行紀錄是稽核資料,超過保留天數會被每日自動清除;預設 90 天(3 個月),也可設為「不刪除」(企業稽核用途)。
    \n
    \n \n \n \n
    \n
    \n
    \n
    \n\n
    帳號管理
    \n
    \n
    新增同仁帳號
    \n
    建立後會產生一組一次性密碼——只顯示這一次,請當場轉交同仁(同仁可在「設定」自行改密)。
    \n
    \n \n \n \n \n
    \n
    \n
    \n
    \n
    載入中…
    \n\n
    庫目錄管理
    \n
    \n 你的知識庫網址(小幫手連線用)\n \n \n
    \n
    \n \n 裝好同步小幫手並選好要看守的資料夾之後,每個資料夾會自動成為一個「庫」出現在下面——不需要人工新增。下面還是空的,代表小幫手還沒裝好或還沒選資料夾。\n
    \n
    載入中…
    \n
    \n\n
    \n
    \n\n\n
    \n
    App 界面
    \n
    App 市集
    \n
    AR 設定
    \n
    App 開發
    \n
    \n\n
    \n\n\n\n"},"/portal/libs-machine-group.test.mjs":{"type":"application/javascript; charset=utf-8","b64":false,"data":"// 同步庫總覽的**機器分組**(inkstone/Arcrun#180)——守「一眼分得出現役與殘留」這件事。\n//\n// 🔴 這支測試存在的理由是一句真的說出口的話。leo 2026-08-28 打開庫目錄看到:\n//\n// 總庫 · 16 個資料夾\n// 🖥 未知來源 · 同步小幫手還沒回報是哪一台機器\n// arcrun167-ship / rt-lib / pms / youlinhsieh-test1 / …(16 個全在這底下)\n//\n// 而他的桌面小幫手上只掛著 4 個。他的第一句話是:\n// 「**這是把別的帳號同步的資料夾外泄了?**」\n//\n// 不是外洩——16 個全是他自己歷史上同步過的。**但畫面說不清楚**,\n// 而說不清楚的代價(以為資料外洩的那幾分鐘)由使用者付。\n//\n// 病灶:機器那一層是**寫死的單一個節點**。原本的註解寫著\n// 「資料補上之後這一層會自然分裂成好幾台,畫面這端不需要再改」——**後半是錯的**:\n// 不分組就永遠只有一台,地端把 machine 送上來也不會分裂。\n//\n// 所以這裡測的不是「畫得好不好看」,而是四件會被踩壞的事:\n// 1. 有機器身分的庫**掛到那台機器底下**,而且顯示的是名字不是比對鍵\n// 2. 沒有機器身分的庫**自成一組**(那組就是「歷史殘留」的所在)——這正是「一眼分得出來」\n// 3. 同一台機器**不准裂成兩台**(改名前後、label 有時等於 id)\n// 4. 舊資料(沒有那兩格)**不准消失**:庫數進去多少就要出來多少\n\nimport fs from 'node:fs';\n\nconst html = fs.readFileSync(new URL('./index.html', import.meta.url).pathname, 'utf8');\n\n// 抽三支:machineLabelMap(既有,同一台只給一個名字)+ 這次改的兩支。\n// 數大括號取整支,不綁任何文案(同 tree-render.test.mjs 的既有慣例——\n// 綁文案的版本會在有人改一個字的時候炸,那是假紅)。\nconst grab = (name) => {\n const i = html.indexOf(`function ${name}(`);\n if (i < 0) throw new Error(`找不到 ${name}`);\n let d = 0;\n for (let k = html.indexOf('{', i); k < html.length; k++) { if (html[k] === '{') d++; if (html[k] === '}') { d--; if (!d) return html.slice(i, k + 1); } }\n throw new Error('括號不平衡');\n};\nconst src = ['machineLabelMap', 'adminLibsTreeData', 'machineTreeNode'].map(grab).join('\\n');\n\n// libTreeNode/esc 是別的職責(第三層那一列怎麼長、跳脫),這裡替身即可——\n// 本檔要守的是**分組**,不是那一列的內容。\nconst harness = `\n function esc(s) { return String(s == null ? '' : s); }\n function libTreeNode(l) { return { content: 'LIB:' + l.name, payload: {}, children: [] }; }\n`;\n\nconst api = new Function(harness + src + '; return { adminLibsTreeData: adminLibsTreeData };')();\n\nlet pass = 0, fail = 0;\nconst chk = (label, cond, extra = '') => {\n if (cond) { console.log('PASS:', label); pass++; }\n else { console.log('FAIL:', label, extra); fail++; }\n};\n\nconst MBA = 'youlinhsieh@Leo-MBA';\nconst IMAC = 'leo@Leo-iMac';\n\n/** 一個「現在正在同步」的庫:小幫手報過樹、樹上有機器身分、active 清單裡有它。 */\nfunction live(name, machine, label) {\n return {\n name, display_name: name, status: 'active', daemon_watching: true,\n folder_tree: { root: '/x/' + name, mode: 'all', reason: '', machine, machine_label: label, total_files: 3, synced_files: 3 },\n };\n}\n/** 一個「歷史殘留」的庫:從沒回報過樹(所以沒有機器身分),也不在 active 清單裡。 */\nfunction residue(name) {\n return { name, display_name: name, status: 'active', auto: true, daemon_watching: false };\n}\n\n// ═══ 1. 現役的掛到自己那台機器底下,殘留自成一組(=leo 的實況)═══\nconst libs = [\n live('youlinhsieh-test1', MBA, '教育部 Leo 的 Mac'),\n live('youlinhsieh-test2', MBA, '教育部 Leo 的 Mac'),\n live('pms', MBA, '教育部 Leo 的 Mac'),\n live('pms_v1_legacy', MBA, '教育部 Leo 的 Mac'),\n residue('rt-lib'), residue('arcrun167-ship'), residue('mira6demo'), residue('testkb'),\n];\nconst tree = api.adminLibsTreeData(libs);\n\nchk('機器層不再是寫死的一個節點', tree.children.length === 2, '實際 ' + tree.children.length + ' 組');\n\nconst named = tree.children.filter((m) => m.content.includes('教育部 Leo 的 Mac'));\nchk('有一組叫得出機器名(顯示的是 label 不是比對鍵)', named.length === 1, tree.children.map((m) => m.content).join(' | '));\nchk('那台底下掛的正是它同步的 4 個庫', named[0] && named[0].children.length === 4,\n named[0] ? String(named[0].children.length) : 'n/a');\n\nconst unknown = tree.children.filter((m) => m.content.includes('tt-unknown\">❔'));\nchk('拿不到機器身分的自成一組', unknown.length === 1);\nchk('殘留的 4 個全在那一組(沒有混進現役那台)', unknown[0] && unknown[0].children.length === 4,\n unknown[0] ? String(unknown[0].children.length) : 'n/a');\n\n// 🔴 這條是本票標題的前半:**不必逐列滑過去**就分得出哪些是殘留。\nchk('殘留那一組排在最後(現役的不該被殘留擋在後面)', tree.children[tree.children.length - 1] === unknown[0]);\nchk('殘留那一組的 ⏸ 數得出來(4 個都沒在同步)', unknown[0].content.includes('⏸ 4'), unknown[0].content);\n\n// 🔴 leo 2026-08-28:「GUI 是國際通用,圖形指示⋯⋯任何出現文字都要謹慎」\n// ⇒ 那一列上不准再出現「未知來源」四個字(理由搬進滑過去那一格)。\nchk('列上不再有「未知來源」四個字', !unknown[0].content.includes('未知來源'), unknown[0].content);\nchk('但理由沒有消失——它在滑過去那一格裡', unknown[0].payload.pop.includes('移除'), unknown[0].payload.pop);\n\n// ═══ 2. 兩台機器=兩組(原本那句「會自然分裂成好幾台」的兌現)═══\nconst two = api.adminLibsTreeData([\n live('a', MBA, '教育部 Leo 的 Mac'),\n live('b', IMAC, 'Leo 的 iMac'),\n live('c', IMAC, 'Leo 的 iMac'),\n]);\nchk('兩台機器分成兩組', two.children.length === 2, String(two.children.length));\nchk('沒有「不知道是哪一台」那組(全都認得出來)', !two.children.some((m) => m.content.includes('tt-unknown\">❔')));\nconst imac = two.children.find((m) => m.content.includes('Leo 的 iMac'));\nchk('iMac 底下 2 個庫', imac && imac.children.length === 2);\nchk('機器那一列數得出自己有幾個資料夾', imac.content.includes('🗂 2'), imac.content);\n\n// ═══ 3. 同一台機器不准裂成兩台(3492a3c 那次的病)═══\n// 實況:卡片那條路上,label 有時等於 id(沒取過名),有時是真名。分組鍵必須是 machine。\nconst renamed = api.adminLibsTreeData([\n live('a', MBA, MBA), // 還沒取名,label === id\n live('b', MBA, '教育部 Leo 的 Mac'), // 取過名\n]);\nchk('同一個比對鍵只長一台', renamed.children.length === 1, String(renamed.children.length));\nchk('取過的名字勝過原始 id', renamed.children[0].content.includes('教育部 Leo 的 Mac'), renamed.children[0].content);\n\n// ═══ 4. 舊資料不准消失(不是所有實例都會馬上升級小幫手)═══\n// 舊版小幫手:樹報得出來,但沒有 machine 那兩格;而且它**正在同步**。\nconst oldDaemon = [{\n name: 'oldlib', display_name: 'oldlib', status: 'active', daemon_watching: true,\n folder_tree: { root: '/x/oldlib', mode: 'all', reason: '', total_files: 5, synced_files: 5 },\n}];\nconst oldTree = api.adminLibsTreeData(oldDaemon);\nchk('舊版小幫手的庫沒有消失', oldTree.children.reduce((n, m) => n + m.children.length, 0) === 1);\nchk('它落在「不知道是哪一台」那組', oldTree.children[0].content.includes('tt-unknown\">❔'));\n// 🔴 誠實:它**還在同步**,不是殘留。那一格不准講成「這些都是移除掉的」。\nchk('滑過去那格說得出它還在同步', oldTree.children[0].payload.pop.includes('還在同步'), oldTree.children[0].payload.pop);\nchk('而且沒有把它講成殘留', !oldTree.children[0].payload.pop.includes('沒有小幫手在同步'), oldTree.children[0].payload.pop);\n\n// ═══ 5. 表頭的數字沒被分組弄壞(第一層還是講全部)═══\nchk('表頭仍然數全部 8 個庫', tree.content.includes('🗂 8'), tree.content);\nchk('表頭仍然數得出已同步/總共(4 個現役 × 3 份)', tree.content.includes('12 / 12'), tree.content);\n\nconsole.log(`\\n${pass} passed, ${fail} failed`);\nprocess.exit(fail ? 1 : 0);\n"},"/portal/os-split.test.mjs":{"type":"application/javascript; charset=utf-8","b64":false,"data":"import fs from 'node:fs';\nconst html = fs.readFileSync(new URL('./index.html', import.meta.url).pathname,'utf8');\n// 抽出 daemonPick 相關函式(從 DAEMON_BASE_DEFAULT 到 daemonHint 結尾)\n//\n// 🔴 2026-08-05:結尾標記本來寫死 daemonHint 的**整句文案**,於是同日改 Mac 提示語\n// (zip→DMG 的步驟不同)就讓這支自測直接炸「抽不到函式區塊」,而且沒人發現。\n// ⇒ 改成錨定「函式結束」這個結構,不再綁文案——文案本來就會改,測試不該為此壞掉。\nconst start = html.indexOf('var DAEMON_BASE_DEFAULT');\nconst hintAt = html.indexOf('function daemonHint', start);\nconst endMark = '\\n }';\nconst end = hintAt < 0 ? -1 : html.indexOf(endMark, hintAt) + endMark.length;\nif (start < 0 || hintAt < 0 || end < start) throw new Error('抽不到函式區塊');\nconst src = html.slice(start, end);\n\nconst cases = [\n ['Windows', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36'],\n ['Mac', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Safari/605.1.15'],\n ['iPhone', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Safari/604.1'],\n ['Linux', 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120 Safari/537.36'],\n];\nlet pass=0, fail=0;\nconst chk=(l,c,extra='')=>{ if(c){console.log('PASS:',l);pass++;} else {console.log('FAIL:',l,extra);fail++;} };\n\nfor (const [name, ua] of cases) {\n const fn = new Function('navigator','window', src + '; return {daemonPick:daemonPick, daemonHint:daemonHint, daemonBase:daemonBase};');\n const api = fn({userAgent: ua}, {});\n const d = api.daemonPick();\n const label = d.sure ? d.pick.label : '(兩個都給)';\n const url = d.sure ? d.pick.url : d.mac.url + ' + ' + d.win.url;\n console.log(`\\n[${name}] sure=${d.sure} → ${label}`);\n console.log(` url: ${url}`);\n if (name==='Windows') {\n chk('Windows 給 win zip', d.sure && d.pick.url.endsWith('ArcrunRAG-win-unsigned.zip'), d.pick&&d.pick.url);\n chk('Windows 另一版是 Mac', d.other && d.other.url.endsWith('ArcrunRAG-mac.dmg'));\n chk('Windows 話術提 藍色視窗', api.daemonHint('win').includes('仍要執行'));\n }\n if (name==='Mac') {\n // 2026-08-05:Mac 一律給 DMG(拖進 Applications 的標準安裝畫面),不再給 zip\n // ——zip 解開就是一個裸 .app,使用者會直接在「下載」資料夾雙擊執行,自更新會蓋錯位置。\n chk('Mac 給 dmg(不是 zip)', d.sure && d.pick.url.endsWith('ArcrunRAG-mac.dmg'));\n chk('Mac 另一版是 Windows', d.other && d.other.url.endsWith('win-unsigned.zip'));\n chk('Mac 話術提 右鍵打開', api.daemonHint('mac').includes('右鍵'));\n }\n if (name==='iPhone' || name==='Linux') {\n // iPhone 含 \"Mac OS X\" 但不是桌機 Mac;Linux 兩者皆非 → 都該落在「不確定=兩個都給」\n if (name==='Linux') chk('Linux 判不出來→兩個都給', d.sure===false);\n if (name==='iPhone') chk('iPhone 不該被判成 Mac(手機→兩個都給)', d.sure===false, 'sure='+d.sure);\n }\n}\n// 舊 key 相容\nconst fn2 = new Function('navigator','window', src + '; return daemonBase();');\nconsole.log('\\n[相容] daemonDownload 舊 key →', fn2({userAgent:''},{ARCRUN_CONFIG:{daemonDownload:'https://x.dev/d/ArcrunRAG-mac-unsigned.zip'}}));\nchk('舊 key 推得出目錄', fn2({userAgent:''},{ARCRUN_CONFIG:{daemonDownload:'https://x.dev/d/ArcrunRAG-mac-unsigned.zip'}})==='https://x.dev/d/');\nchk('daemonBase 新 key 優先', fn2({userAgent:''},{ARCRUN_CONFIG:{daemonBase:'https://y.dev/z'}})==='https://y.dev/z/');\nconsole.log(`\\n=== ${pass} passed, ${fail} failed ===`);\nprocess.exit(fail?1:0);\n"},"/portal/retrieval-tree.test.mjs":{"type":"application/javascript; charset=utf-8","b64":false,"data":"// 檢索過程樹(Arcrun#44 第二輪,leo 2026-08-18「希望以樹狀圖來展示」)的資料組裝測試。\n// 只測**樹的資料**,不測它怎麼被畫出來(那在 tree-render.test.mjs),\n// 我們自己要為之負責的是「層級對不對、有沒有把讀過的東西吞掉」。\n// 跑法:node console-ui/public/portal/retrieval-tree.test.mjs\nimport fs from 'node:fs';\nconst html = fs.readFileSync(new URL('./index.html', import.meta.url).pathname, 'utf8');\n\nconst grab = (name) => {\n const i = html.indexOf(`function ${name}(`);\n if (i < 0) throw new Error(`找不到 ${name}`);\n let d = 0, j = html.indexOf('{', i);\n for (let k = j; k < html.length; k++) { if (html[k] === '{') d++; if (html[k] === '}') { d--; if (!d) return html.slice(i, k + 1); } }\n throw new Error('括號不平衡');\n};\nconst src = ['esc', 'srcLocalPath', 'machineLabelMap', 'srcCrumbs', 'retrievalTreeData', 'normalizeTreeNode', 'visibleRows'].map(grab).join('\\n');\nconst fn = new Function(src + '\\nvar SEARCH_ROUTE_LABEL = { graph: \"圖譜命中子庫\", index: \"索引退回選庫\", all: \"全庫讀取\" };'\n + '\\nreturn { srcCrumbs, machineLabelMap, retrievalTreeData, normalizeTreeNode, visibleRows };')();\n\nlet pass = 0, fail = 0;\nconst t = (l, c, e = '') => { c ? (console.log('PASS:', l), pass++) : (console.log('FAIL:', l, e), fail++); };\nconst txt = (n) => String(n.content).replace(/<[^>]*>/g, '');\n\n// 2026-08-18 從 youlin stage 實跑 /portal/data/chat 抓下來的真回應形狀(narrative 真的是空字串)\nconst REAL = {\n route: 'graph', vector_used: false, libraries_considered: 8,\n libraries: [{ library: 'testkb', score: 1.69, via: 'graph', matched_entities: ['Arcrun124驗證卡'] }],\n indexes_read: [\n { library: 'general', narrative: '', selected: false, top_entities: [], triplet_count: 0, entry_count: 100 },\n { library: 'testkb', narrative: '', selected: true, top_entities: ['Arcrun124驗證卡'], triplet_count: 4, entry_count: 9 },\n { library: 'kb', narrative: '', selected: false, top_entities: ['knowledge-base'], triplet_count: 7, entry_count: 16 }\n ],\n pages_read: [{ page: 'Arcrun124驗證卡', library: 'testkb', chars: 231, source: 'kb://test/arcrun-124-verify.md#0' }]\n};\n\n// 🔴 inkstone/Arcrun#170(2026-08-27)改掉這裡好幾條斷言的期望值——\n// 不是測試鬆綁,是行為本身照票的要求換了:\n// 「子庫」「三元組」「索引沒有敘事」是內部詞,low-code 使用者看不懂\n// (leo 原話:「這是在跟誰溝通?」),全部換成使用者看得懂的說法;\n// 而「考慮過但沒讀原文」的收合桶本身也被拿掉——leo:「這張圖的目的是\n// 告訴他相關文件放哪裡……只該在真的有命中的時候出現,而且只畫命中的\n// 那條路徑」,收合桶再怎麼收合,都還是陪葬在畫面上的無關資訊。\n// (總管 2026-08-27 複驗指令:逐條判斷哪些是刻意改掉、哪些是改壞了,\n// 刻意改的要在這裡寫明原因,不准整支刪掉或跳過。)\n\n// ① 層級=leo 畫的那張:搜尋詞 → 命中資料夾 → 資料夾摘要 → 原文片段\nconst tree = fn.retrievalTreeData('Arcrun124驗證卡是什麼?', REAL);\nt('第 1 層是搜尋詞', txt(tree).indexOf('Arcrun124驗證卡是什麼?') === 0, txt(tree));\nconst lib = tree.children[0];\n// 「命中實體」→「找到關鍵字」(#170:使用者看得懂的字,意思不變)。\n// 🔴 同一次順便拿掉了原始相似度分數(`score 1.69`)與內部路由詞(`via: graph`)——\n// 兩個都是「對使用者沒有意義的數字/內部詞」,不服務「相關文件放哪裡」這個目的,\n// 分數沒有刻度、使用者不知道 1.69 是好是壞;拿掉比留著噪音更誠實。\nt('第 2 層是命中資料夾(帶找到的關鍵字,不帶原始分數/內部路由詞)',\n /testkb/.test(txt(lib)) && /找到關鍵字/.test(txt(lib)) && !/1\\.69/.test(txt(lib)) && !/\\bgraph\\b/.test(txt(lib)), txt(lib));\nconst idx = lib.children[0];\n// 「三元組」整個拿掉(使用者看不懂、對他沒意義),「筆條目」→「筆內容」(跟 assemble 的\n// prompt 用語一致,見 workflows/rag-chat.local.yaml 的 catalog 組字)\nt('第 3 層是該資料夾的摘要(不含三元組這種內部詞)', !/三元組/.test(txt(idx)) && /筆內容/.test(txt(idx)), txt(idx));\nconst page = idx.children[0];\nt('第 4 層是原文片段(掛在資料夾摘要底下)', /Arcrun124驗證卡/.test(txt(page)) && /231 字/.test(txt(page)), txt(page));\nt('原文片段帶資料夾麵包屑', /test › arcrun-124-verify\\.md/.test(txt(page)), txt(page));\n\n// ② narrative 是空字串時不假裝有敘事——但也不能講「索引沒有敘事」這種內部詞\n// (使用者看不懂「索引」「敘事」是什麼),改把這個資料夾主要談什麼(top_entities)\n// 擺出來,讓這一層仍然說得出讀到了什麼(REAL 的 testkb 剛好兩者都是這個情況:\n// narrative 空、top_entities 有)\nt('narrative 空時用「主要談」代替,不講內部詞「索引沒有敘事」', /主要談:Arcrun124驗證卡/.test(txt(idx)) && !/索引沒有敘事/.test(txt(idx)), txt(idx));\n// narrative 與 top_entities 都沒有時,這一層真的沒有東西可說——整行不顯示,\n// 不留一個「(索引沒有敘事)」式的空殼占位字(general 那筆兩者皆空,但 general\n// 沒被選用,不會出現在樹上;這裡直接測 idxNode 沒被匯出,改用等價的資料夾建構驗證)\nconst bothEmpty = fn.retrievalTreeData('問句', {\n route: 'graph',\n libraries: [{ library: 'blanklib', score: 1, via: 'graph', matched_entities: [] }],\n indexes_read: [{ library: 'blanklib', narrative: '', selected: true, top_entities: [], entry_count: 3 }],\n pages_read: [{ page: 'Q', library: 'blanklib', chars: 5, source: 'kb://q.md#0' }]\n});\nconst blankIdx = bothEmpty.children[0].children[0];\nt('narrative 與主要談都沒有時,那一行不留內部詞占位字', !/主要談|索引沒有敘事/.test(txt(blankIdx)) && /筆內容/.test(txt(blankIdx)), txt(blankIdx));\n\n// ③ 🔴 拿掉「沒被選用的資料夾收在收合節點」這整條行為(#170):\n// leo 的判準是「只畫命中的那條路徑」,收合起來還是陪葬在畫面上——\n// 使用者要的是「相關文件放哪裡」,不是「這次系統瞄過哪些資料夾」。\n// 新判準:沒被選用、沒讀到原文的資料夾**完全不出現**在樹上。\nt('沒被選用的資料夾完全不出現(不生收合桶,也不用任何形式露出)', !/general|(? 換行),那正是 leo 說的「上下間距很大」。\n// 一列放不下的部分由 CSS 截斷、全文掛 title,所以資料這端不准再自己換行。\nconst everyContent = [tree, lib, idx, page].map(n => String(n.content)).join('');\nt('節點內容不含換行標籤(一個節點=一列)', !//i.test(everyContent),\n (everyContent.match(/]*>/gi) || []).join(' '));\nt('同一列裡的多段資訊用分隔點接起來', //.test(String(page.content)), String(page.content));\n\n// ⑧ 判準第 2 條的算式:列數 × 一列 23px 要塞得進 1080p(收合的那支只算它自己一列)\n// #170 拿掉了「考慮過但沒讀原文」那個收合桶,這棵樹裡不再有任何 fold=1 的節點——\n// 但 visibleRows 的收合行為是共用機制(同一份 renderTree 服務三個入口,見檔頭註解),\n// 用一個手刻的 fixture 驗證它沒有被連帶改壞,不依賴這棵樹恰好長出一個折疊節點。\nconst foldedFixture = { content: 'x', payload: { fold: 1 },\n children: [{ content: 'a', children: [] }, { content: 'b', children: [] }] };\nt('收合的節點只算一列(就算底下藏著子節點)', fn.visibleRows(foldedFixture) === 1, String(fn.visibleRows(foldedFixture)));\n// 新樹形=根(1) → 資料夾(1) → 摘要(1) → 原文片段(1),沒有陪葬分支,共 4 列\n// (改前是 5:多算一列給已拿掉的「考慮過但沒讀原文」收合桶)\nt('整棵樹的列數=展開狀態下看得到幾列', fn.visibleRows(fn.normalizeTreeNode(tree)) === 4,\n String(fn.visibleRows(fn.normalizeTreeNode(tree))));\nt('單一節點也算一列(不會回 0)', fn.visibleRows({ content: 'x', children: [] }) === 1);\n\n// ⑨ 機器那一層(inkstone/mira#6 補上的兩格:machine=比對鍵、machine_label=顯示名)\nconst withM = fn.srcCrumbs('kb://RFP/daemon-真機驗收.md#0', 'mira6-watch',\n { machine: 'youlinhsieh@Leo-MBA', machine_label: '教育部 Leo 的 Mac' });\nt('有機器就拿顯示名', withM.machineLabel === '教育部 Leo 的 Mac', String(withM.machineLabel));\nt('比對鍵原樣留著(樹上分支要用它當 key)', withM.machine === 'youlinhsieh@Leo-MBA', String(withM.machine));\nt('機器名沒有混進路徑', withM.path === 'RFP/daemon-真機驗收.md' && withM.dirs.join('/') === 'RFP', withM.path);\n\nconst onlyKey = fn.srcCrumbs('kb://a/b.md#0', 'lib', { machine: 'u@Host' });\nt('只有比對鍵沒有顯示名 → 顯示名退回比對鍵', onlyKey.machineLabel === 'u@Host', String(onlyKey.machineLabel));\n\nconst noM = fn.srcCrumbs('kb://a/b.md#0', 'lib', {});\nt('舊資料沒有這兩格 → machine 是 null(畫面顯示未知來源)', noM.machine === null && noM.machineLabel === null, JSON.stringify(noM.machine));\nconst emptyM = fn.srcCrumbs('kb://a/b.md#0', 'lib', { machine: '', machine_label: '' });\nt('空字串一律當沒有,不顯示成空白機器', emptyM.machine === null && emptyM.machineLabel === null, JSON.stringify(emptyM));\n\n// 同一個相對路徑來自兩台機器 → 樹上要是兩支,不可以被併成一支\nconst twoMachines = fn.retrievalTreeData('問句', { route: 'graph',\n libraries: [{ library: 'mira6-watch', score: 2.1, via: 'graph', matched_entities: ['x'] }],\n indexes_read: [{ library: 'mira6-watch', narrative: '', selected: true, triplet_count: 1, entry_count: 2 }],\n pages_read: [\n { page: 'P', library: 'mira6-watch', chars: 10, source: 'kb://RFP/same.md#0', machine: 'u@Leo-MBA', machine_label: 'Leo 的 Mac' },\n { page: 'P', library: 'mira6-watch', chars: 10, source: 'kb://RFP/same.md#0', machine: 'u@Leo-iMac', machine_label: 'Leo 的 iMac' } ] });\nconst pageNodes = twoMachines.children[0].children[0].children;\nt('同路徑不同機器=兩支,不被併掉', pageNodes.length === 2, String(pageNodes.length));\nt('兩支各自標出自己的機器',\n /Leo 的 Mac/.test(pageNodes[0].content) && /Leo 的 iMac/.test(pageNodes[1].content),\n txt(pageNodes[0]) + ' / ' + txt(pageNodes[1]));\nconst noMachinePage = fn.retrievalTreeData('問句', { route: 'graph', libraries: [], indexes_read: [],\n pages_read: [{ page: 'P', library: 'kb', chars: 1, source: 'kb://a.md#0' }] });\nt('沒有機器的片段標「未知來源」,不留空', /未知來源/.test(noMachinePage.children[0].children[0].content));\n\n// ⑩ 同一台機器只能有一個顯示名(youlin stage 2026-08-18 實測到的真實情況:\n// 同一把 youlinhsieh@Leo-MBA,改名前那筆 label 就是 ID 本身,改名後是「教育部 Leo 的 Mac」)\nconst REALPAIR = [\n { page: '資料夾總覽:mira6-watch', library: 'mira6-watch', chars: 40, source: 'kb://x.md#0',\n machine: 'youlinhsieh@Leo-MBA', machine_label: 'youlinhsieh@Leo-MBA' },\n { page: 'daemon-真機驗收', library: 'mira6-watch', chars: 93, source: 'kb://RFP/daemon.md#0',\n machine: 'youlinhsieh@Leo-MBA', machine_label: '教育部 Leo 的 Mac' }\n];\nconst lb = fn.machineLabelMap(REALPAIR);\nt('有人取過名字就全篇用那個名字', lb['youlinhsieh@Leo-MBA'] === '教育部 Leo 的 Mac', JSON.stringify(lb));\nt('舊那筆也跟著顯示成新名字(同一台不會長成兩台)',\n fn.srcCrumbs('kb://x.md#0', 'mira6-watch', REALPAIR[0], lb).machineLabel === '教育部 Leo 的 Mac');\nt('比對鍵不受顯示名影響', fn.srcCrumbs('kb://x.md#0', 'mira6-watch', REALPAIR[0], lb).machine === 'youlinhsieh@Leo-MBA');\nt('誰都沒取過名字 → 退回顯示 ID', fn.machineLabelMap([{ machine: 'u@H', machine_label: 'u@H' }])['u@H'] === 'u@H');\nt('兩台不同機器不會被併成一個名字',\n Object.keys(fn.machineLabelMap([{ machine: 'a@X', machine_label: 'X 機' }, { machine: 'b@Y', machine_label: 'Y 機' }])).length === 2);\n\nconst treeReal = fn.retrievalTreeData('問句', { route: 'graph',\n libraries: [{ library: 'mira6-watch', score: 2.1, via: 'graph', matched_entities: ['x'] }],\n indexes_read: [{ library: 'mira6-watch', narrative: '', selected: true, triplet_count: 1, entry_count: 2 }],\n pages_read: REALPAIR });\nconst both = treeReal.children[0].children[0].children.map(n => n.content).join(' ');\nt('樹上兩個片段標的是同一個機器名', (both.match(/教育部 Leo 的 Mac/g) || []).length === 2, both.slice(0, 200));\n\nconsole.log(`\\n=== ${pass} passed, ${fail} failed ===`);\nprocess.exit(fail ? 1 : 0);\n"},"/portal/safejson.test.mjs":{"type":"application/javascript; charset=utf-8","b64":false,"data":"import fs from 'node:fs';\nconst html = fs.readFileSync(new URL('./index.html', import.meta.url).pathname,'utf8');\n\n// 抽出 safeJson 與 friendlyErr 求值\nconst grab = (name) => {\n const i = html.indexOf(`function ${name}(`);\n if (i < 0) throw new Error(`找不到 ${name}`);\n let d=0, j=html.indexOf('{', i);\n for (let k=j;k{c?(console.log('PASS:',l),pass++):(console.log('FAIL:',l,e),fail++)};\n\n// ① safeJson:非 JSON 不可拋例外(同事撞到的 404 HTML 頁)\nconst html404 = '404 Not Found';\nawait fn.safeJson({ text: () => Promise.resolve(html404) })\n .then(d => t('404 HTML → 回空物件不拋錯', typeof d === 'object' && d !== null))\n .catch(e => t('404 HTML → 不該拋錯', false, e.message));\n\nawait fn.safeJson({ text: () => Promise.resolve('') })\n .then(d => t('空回應 → 回空物件', JSON.stringify(d)==='{}'))\n .catch(() => t('空回應 → 不該拋錯', false));\n\nawait fn.safeJson({ text: () => Promise.resolve('{\"error\":\"帳號或密碼不對\"}') })\n .then(d => t('正常 JSON 仍要解析得出來', d.error === '帳號或密碼不對'), )\n .catch(() => t('正常 JSON 不該拋錯', false));\n\n// ② friendlyErr:不可把技術訊息噴給使用者\nconst leak = fn.friendlyErr(new Error('Unexpected non-whitespace character after JSON at position 4'));\nt('JSON 錯誤 → 不外洩原文', !/JSON|position/i.test(leak), `實得: ${leak}`);\nt('JSON 錯誤 → 說人話', /伺服器回應異常/.test(leak), `實得: ${leak}`);\n\nconst net = fn.friendlyErr(new Error('Failed to fetch'));\nt('網路錯誤 → 既有訊息保留', /連線中斷/.test(net), `實得: ${net}`);\n\nconst ours = fn.friendlyErr(new Error('帳號或密碼不對——用你在知識庫網站設定的那組'));\nt('我們自己的中文訊息 → 原樣顯示', /帳號或密碼不對/.test(ours), `實得: ${ours}`);\n\nconst stack = fn.friendlyErr(new Error('TypeError: Cannot read properties of undefined'));\nt('英文技術訊息 → 收斂不外洩', !/TypeError|undefined/.test(stack), `實得: ${stack}`);\n\nconsole.log(`\\n=== ${pass} passed, ${fail} failed ===`);\nprocess.exit(fail?1:0);\n"},"/portal/tree-render.test.mjs":{"type":"application/javascript; charset=utf-8","b64":false,"data":"// 樹的**畫法**測試(Arcrun#144,leo 2026-08-19 打回 markmap 之後)。\n//\n// 取代舊的 tree-links.test.mjs——那支測的是「把 markmap 的貝茲曲線改寫成 L 型折線」,\n// 而那整段程式碼連同 markmap 一起被移除了(形狀錯的不是線,是佈局;見 index.html\n// renderTree() 的技術選型註解)。\n//\n// 這支守的是**我們自己寫的那一段**:把樹資料攤成巢狀
    的字串拼接。\n// 摺疊行為本身是瀏覽器的,不測;縮排是 CSS 的,不測。\n// 跑法:node console-ui/public/portal/tree-render.test.mjs\nimport fs from 'node:fs';\nconst html = fs.readFileSync(new URL('./index.html', import.meta.url).pathname, 'utf8');\n\nconst grab = (name) => {\n const i = html.indexOf(`function ${name}(`);\n if (i < 0) throw new Error(`找不到 ${name}`);\n let d = 0;\n for (let k = html.indexOf('{', i); k < html.length; k++) { if (html[k] === '{') d++; if (html[k] === '}') { d--; if (!d) return html.slice(i, k + 1); } }\n throw new Error('括號不平衡');\n};\nconst fn = new Function(\n ['esc', 'treeHtml', 'treeKidsHtml', 'treeNodeHtml', 'titleAttr', 'normalizeTreeNode', 'visibleRows',\n 'gapWhy', 'rollupTree', 'folderTreeData', 'libTypeLabel', 'adminLibsTreeData', 'libTreeNode',\n // inkstone/Arcrun#180:機器那一層改成真的分組,於是多了這兩支相依\n //(machineLabelMap 是既有的——同一台機器在整頁只給一個名字,見 3492a3c)\n 'machineTreeNode', 'machineLabelMap',\n 'folderKidsData'].map(grab).join('\\n')\n // 同步庫總覽的第四層是「點開才抓」的,資料放在這兩張表裡(畫面端的快取)。\n // 測試自己當那份快取,就能把「還沒抓/抓過但沒有/抓到了」三種狀態都走一遍。\n + '\\nvar folderTrees = {}, treeOpen = {};'\n + '\\nreturn { treeHtml, treeKidsHtml, treeNodeHtml, titleAttr, normalizeTreeNode, visibleRows,'\n + ' adminLibsTreeData, folderTreeData,'\n + ' setFolderTree: function (n, t) { folderTrees[n] = t; },'\n + ' setOpen: function (n, v) { treeOpen[n] = v; } };')();\n\nlet pass = 0, fail = 0;\nconst t = (l, c, e = '') => { c ? (console.log('PASS:', l), pass++) : (console.log('FAIL:', l, e), fail++); };\n\nconst leaf = (c) => ({ content: c, children: [] });\nconst TREE = {\n content: '問句', children: [\n { content: 'kb', children: [leaf('頁 A'), leaf('頁 B')] },\n { content: '考慮過但沒讀原文', payload: { fold: 1 }, children: [leaf('other')] }\n ]\n};\n\n// ① 有小孩=可摺疊的
    ;沒小孩=一列,不生假的可展開節點\nconst out = fn.treeHtml(TREE);\nt('外層是 .ftree', out.startsWith('
    '), out.slice(0, 40));\nt('有小孩的節點是
    ', (out.match(/
    ,是一列 .ft-leaf', (out.match(/ft-row ft-leaf/g) || []).length === 3,\n String((out.match(/ft-row ft-leaf/g) || []).length));\nt('可摺疊的節點把標題放在 (點得到的就是這一列)', //.test(out));\n\n// ② 縮排靠巢狀,不靠算 padding ⇒ 深度沒有上限\nt('每一層小孩包在 .ft-kids 裡', (out.match(/
    /g) || []).length === 3,\n String((out.match(/
    /g) || []).length));\nlet deep = leaf('底'); for (let i = 0; i < 12; i++) deep = { content: 'L' + i, children: [deep] };\nt('12 層照樣攤得開(沒有寫死的深度上限)',\n (fn.treeHtml(deep).match(/
    /g) || []).length === 12);\n\n// ③ payload.fold ⇒ 預設收起來(那一支不能被展開,也不能被吞掉)\nconst folded = out.slice(out.indexOf('考慮過') - 200, out.indexOf('考慮過'));\nt('fold 的那一支沒有 open 屬性', /
    /.test(out), '');\nt('收起來的那支內容仍在 DOM 裡(不隱瞞,只是收合)', /other/.test(out));\n\n// ④ 節點內容是 HTML(三個入口的異質節點靠這個分)——不可以被跳脫掉\nt('節點的 HTML 原樣留著', /kb<\\/b>/.test(out) && /頁 A<\\/b>/.test(out));\n\n// ⑤ 一列放不下會截斷 ⇒ 全文必須掛在 title 上(=檔案總管對長檔名的做法)\nt('title 是剝掉標籤的純文字', fn.titleAttr('·乙') === ' title=\"甲·乙\"',\n fn.titleAttr('·乙'));\nt('title 裡的引號要跳脫,不能把屬性打斷', /"|"|'/.test(fn.titleAttr('say \"hi\"')) || !/\"hi\"/.test(fn.titleAttr('say \"hi\"')),\n fn.titleAttr('say \"hi\"'));\nt('沒有文字就不掛空的 title', fn.titleAttr('') === '');\n\n// ⑥ 多個根(資料夾樹會有)要全部畫出來,不是只畫第一個\nconst multi = fn.treeHtml([leaf('根一'), leaf('根二')]);\nt('陣列=多個根,全部畫出來', /根一/.test(multi) && /根二/.test(multi));\n\n// ⑦ 註腳(資料夾樹的「數字是已同步/總共」那段)掛得上去,且只在有內容時出現\nt('有註腳就畫在樹的後面', /
    說明<\\/div><\\/div>$/.test(fn.treeHtml(leaf('x'), '說明')));\nt('沒註腳就不生空的區塊', !/ft-foot/.test(fn.treeHtml(leaf('x'))));\n\n// ⑧ 呼叫端漏寫 children 不會炸(葉子寫成 { content } 是最常見的手滑)\nt('缺 children 的節點補得回來', fn.normalizeTreeNode({ content: 'x' }).children.length === 0);\nt('缺 children 也畫得出來', /ft-leaf/.test(fn.treeHtml({ content: 'x' })));\n\n// ⑨ 🔴 判準第 2 條的算式:列數 × 23px 要塞得進 1080p。\n// 這支測的是「列數算得對」——收合的那支只算它自己一列。\nt('列數=展開狀態下看得到的列', fn.visibleRows(fn.normalizeTreeNode(TREE)) === 5,\n String(fn.visibleRows(fn.normalizeTreeNode(TREE)))); // 根 + kb + 2 頁 + 收合的那支\nconst wide = { content: 'r', children: [] };\nfor (let i = 0; i < 8; i++) wide.children.push({ content: 'lib' + i, children: [{ content: 'idx', children: Array.from({ length: 2 }, () => leaf('p')) }] });\nt('8 子庫 × (索引+2 片原文) = 33 列,×23px ≈ 759px,1080p 放得下',\n fn.visibleRows(fn.normalizeTreeNode(wide)) === 33 && fn.visibleRows(fn.normalizeTreeNode(wide)) * 23 < 900,\n String(fn.visibleRows(fn.normalizeTreeNode(wide))));\n\n// ⑩ 行尾那一格(庫目錄的「移除」鈕)——名字被截斷時它不能跟著消失,\n// 所以它必須在 .ft-lbl **外面**,而且不出現在 title 裡(title 是「這一列叫什麼」)。\nconst withTail = fn.treeHtml({ content: '很長的名字', tail: '', children: [] });\nt('tail 畫在 .ft-lbl 外面(不參與截斷)', /<\\/span>\n
    \n
    \n
    \n
    \n
    \n
    載入中
    \n
    \n
    \n
    \n
    \n
    \n
    今日完成
    \n
    /
    \n
    \n
    \n
    \n
    收件匣未處理
    \n
    \n
    來自 Telegram
    \n
    \n
    \n
    \n
    等你的事
    \n
    載入中…
    \n
    \n
    \n
    今日路線
    \n
    • 載入中…
    \n
    本週
    \n
      \n
      系統狀況live 健康信號
      \n
      載入中…
      \n
      每 60 秒自動刷新
      \n 進入完整控制台 ›\n\n\n\n\n"},"/console/index.html":{"type":"text/html; charset=utf-8","b64":false,"data":"\n\n\n\n\nArcrun Console\n\n\n\n\n\n\n\n\n
      \n
      \n
      \n
      Arcrun
      \n
      私人系統・僅供擁有者進入
      \n
      \n
      \n \n \n \n
      \n
      \n
      這是一個人的智慧總部。
      若你不是擁有者,這裡沒有你要找的東西。
      \n \n \n
      \n
      \n\n\n
      \n
      \n
      \n
      Arcrun
      \n
      首次設定
      \n
      系統偵測到尚未設定擁有者帳號。
      設定一組 Email 與密碼,之後只有你能進入。
      \n
      \n
      \n \n \n \n \n
      \n
      \n \n
      \n
      \n\n\n
      \n
      \n
      Arcrun
      \n \n
      總庫搜尋
      \n
      工作流
      \n \n
      設定
      \n
      ☾ 切深色
      \n
      \n
      \n
      \n\n \n
      \n
      Arcrun 控制台
      \n
      \n
      \n
      \n
      \n
      \n
      載入中
      \n
      \n
      \n
      \n
      \n
      \n
      今日完成
      \n
      /
      \n
      \n
      \n
      \n
      收件匣未處理
      \n
      \n
      來自 Telegram
      \n
      \n
      \n
      \n
      等你的事
      \n
      載入中…
      \n
      \n
      \n
      \n
      \n
      \n 今日路線狀態即時同步\n
      \n
      • 載入中…
      \n
      本週
      \n
        \n
        每 60 秒自動刷新
        \n
        \n
        \n
        \n\n \n
        \n
        總庫搜尋
        \n
        \n
        \n
        \n 藏書地圖\n
        \n
        地圖載入中…
        \n
        \n
        \n
        \n \n \n
        \n
        \n
        \n
        \n
        \n
        \n
        \n
        \n\n \n
        \n
        \n \n 知識卡片\n
        \n
        \n
        載入中…
        \n
        \n
        關聯視圖
        \n
        \n
        \n
        \n
        \n
        \n\n \n
        \n
        工作流
        \n \n
        載入中…
        \n
        \n
        零件與 Recipes(框架資源)
        \n
        \n \n \n
        \n
        \n
        \n
        \n\n \n
        \n
        憑證管理
        \n
        \n 🛡 保險箱原則:系統只保存目錄與加密後的值,任何頁面都看不到密文內容。只能整筆替換或刪除。\n
        \n
        \n
        新增憑證
        \n
        \n \n \n \n \n
        \n
        \n
        \n
        載入中…
        \n
        \n\n \n
        \n
        分流台全部待辦・80/15/5
        \n
        \n
        載入中…
        \n
        \n\n \n
        \n
        設定
        \n
        \n
        \n
        \n
        \n
        深色模式
        \n
        預設淺色(紙感)。切換立即生效,選擇記在這台裝置(localStorage)。
        \n
        \n
        \n
        \n
        \n
        \n \n
        語意搜尋
        \n
        狀態偵測中…
        \n
        \n
        \n
        \n
        MCP token 有效期(TTL)
        \n
        讀取中…
        \n
        \n 誠實佔位:此頁還不能改這個值——可調功能=Arcrun#19(設定頁改→存 KBDB→發 token 時讀)。實作前要調整請在部署端 env MCP_TOKEN_TTL(mcp worker)設定。\n
        \n
        \n
        \n
        更換帳號密碼
        \n
        需輸入舊密碼驗證身分
        \n
        \n \n \n \n \n
        \n
        \n
        \n \n
        \n
        Portal 帳號密碼救援
        \n
        忘記某個 Portal(RAG 搜尋頁)帳號的密碼,包含你自己那組管理員帳號——不需要先登進 Portal。輸入該帳號的 Email,會產生一組新密碼,只顯示這一次,請立刻抄下並拿去 Portal 登入頁使用。
        \n
        \n \n \n
        \n
        \n
        \n
        \n
        系統資訊
        \n
        載入中…
        \n
        \n \n
        \n
        \n\n
        \n
        \n\n\n
        \n \n
        搜尋
        \n
        工作流
        \n \n
        設定
        \n
        \n\n
        \n\n\n\n\n"},"/favicon.ico":{"type":"image/x-icon","b64":true,"data":"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"},"/favicon.svg":{"type":"image/svg+xml","b64":false,"data":"arcrun icon"},"/index.html":{"type":"text/html; charset=utf-8","b64":false,"data":"\n\n\n\nArcrun RAG\n\n\n\n\n\n\n\n

        正在前往搜尋頁… 沒有自動跳轉請點這裡

        \n\n\n"},"/portal/app-mount.test.mjs":{"type":"application/javascript; charset=utf-8","b64":false,"data":"// App 掛載協定 v0.2 的結構自測(Arcrun#152/#82,leo 2026-08-24 拍板)\n// 跑法:node console-ui/public/portal/app-mount.test.mjs\n//\n// 這支守的是**協定的形狀**,不是長相——長相要用瀏覽器量(見 #152 的驗收留言)。\n// 之所以要有它:這一輪的病根是「出口與樣式被做成個案」——筆記 App 有返回鍵、總圖沒有;\n// CIS 表複製進 shadow 了、但 App 根本沒接上。個案會悄悄再長出來,所以把\n// 「不准再有個案」這件事寫成可執行的檢查。\nimport fs from 'node:fs';\n\nconst html = fs.readFileSync(new URL('./index.html', import.meta.url).pathname, 'utf8');\nlet pass = 0, fail = 0;\nconst chk = (label, cond, extra = '') => {\n if (cond) { console.log('PASS:', label); pass++; }\n else { console.log('FAIL:', label, extra); fail++; }\n};\n\n// ── ① 出口:每一個非首頁的 view 都必須有 .pagehead ────────────────────────────\n// ensureBackControls() 是往 `#v- .pagehead` 裡塞返回鍵的。少了那一列 = 那個 view\n// 悄悄沒有出口,而且沒有人會發現(leo 就是這樣卡在總圖)。\nconst viewsLine = html.match(/var VIEWS = \\[(.*?)\\];/);\nif (!viewsLine) throw new Error('抽不到 VIEWS 清單');\nconst VIEWS = viewsLine[1].split(',').map((s) => s.trim().replace(/^'|'$/g, ''));\nconst HOME = (html.match(/var HOME = '([^']+)'/) || [])[1];\nchk('抽得到 VIEWS 與 HOME', VIEWS.length > 0 && !!HOME, `VIEWS=${VIEWS.length} HOME=${HOME}`);\n\nfor (const v of VIEWS) {\n if (v === HOME) continue;\n // 抓 `
        \">` 之後、下一個 view 之前那一段,看有沒有 pagehead\n const at = html.indexOf(`id=\"v-${v}\"`);\n if (at < 0) { chk(`view ${v} 存在於 HTML`, false); continue; }\n const nextView = html.indexOf('id=\"v-', at + 10);\n const block = html.slice(at, nextView < 0 ? at + 4000 : nextView);\n chk(`非首頁 view「${v}」有 .pagehead(返回鍵才掛得上)`, /class=\"pagehead\"/.test(block));\n}\n\n// ── ② 不准再有「某一頁自己的返回鍵」 ─────────────────────────────────────────\nchk('沒有殘留的個案返回鍵(app-back-btn 已撤)', !html.includes('app-back-btn'));\nchk('返回鍵只由 ensureBackControls() 產生(全檔只有一處 createElement 出 data-portal-back)',\n (html.match(/setAttribute\\('data-portal-back'/g) || []).length === 1);\nchk('返回鍵是頁首第一格(insertBefore firstChild)', /insertBefore\\(b, head\\.firstChild\\)/.test(html));\nchk('返回鍵一律回 HOME', /data-portal-back\\]'\\)\\) nav\\(HOME\\)/.test(html));\n\n// ── ③ 樣式:兩種模式與層序 ──────────────────────────────────────────────────\nchk('層序=arcrun-base < app < arcrun(全局在最上面才蓋得過 App)',\n html.includes('@layer arcrun-base, app, arcrun;'));\nchk('沒宣告=跟隨全局(預設 inherit,不是維持 App 原樣)',\n /var inherit = style !== 'own';/.test(html));\nchk('App 自己的 style 會被包進 app 層', /'@layer app\\{' \\+ st\\.textContent \\+ '\\}'/.test(html));\nchk('全局色票/CIS 都進 arcrun 層', (html.match(/'@layer arcrun\\{'/g) || []).length === 2);\nchk('樣式模式從 App 宣告來(不是 Portal 猜的)', /mountAppUi\\(host, app\\.ui_html, app\\.ui_style\\)/.test(html));\n\n// ── ④ 畫布是全局決定的(兩種模式都一樣)────────────────────────────────────\n// leo:「全局要給它多大的畫布也是全局決定的⋯⋯應該整個右側邊欄都是它的」\nchk('App 頁不吃 .page 的寬度上限與左右留白', /#v-app\\.on \\{[^}]*max-width: none;[^}]*padding: 0;/.test(html));\nchk('shadow host 由**外部文件**決定尺寸(App 在 shadow 裡改不動)',\n /#app-ui-mount \\{[^}]*width: 100%/.test(html));\n\n// ── ⑤ 隔離沒有被拆掉(這一輪的紅線)──────────────────────────────────────\nchk('App 仍然掛進 Shadow DOM', /host\\.attachShadow\\(\\{ mode: 'open' \\}\\)/.test(html));\n\nconsole.log(`\\n${fail === 0 ? '✅' : '❌'} pass=${pass} fail=${fail}`);\nprocess.exit(fail === 0 ? 0 : 1);\n"},"/portal/folder-tree.test.mjs":{"type":"application/javascript; charset=utf-8","b64":false,"data":"// 資料夾樹的畫面計算(InkStoneCo#44)——守 `gapWhy` 與 `rollupTree` 之間的**介面契約**。\n//\n// 🔴 這支測試存在的唯一理由,是一個 2026-08-17 真的送到瀏覽器才被抓到的 bug:\n//\n// `rollupTree()` 疊出來的合計物件用短鍵名 { total, synced, pending, unsupported, excluded }\n// 而 `gapWhy()` 當時讀的是節點的長鍵名 { total_files, unsupported_files, … }\n//\n// ⇒ 三個判斷全部拿到 undefined ⇒ **那行「為什麼沒上傳」永遠是空字串**。\n//\n// 症狀有多難發現:畫面完全正常——樹畫得出來、每個數字都對、也沒有任何 console 錯誤,\n// **只是那句解釋安靜地不見了**。而那句解釋正是 leo 要這個畫面回答的唯一那件事:\n// 「不上傳通常是不支援,比如程式碼、不支援的格式。」\n// 單元測試(雲端那半)全綠、`curl` 也看不出來(它不執行 JS)。\n//\n// ⇒ 所以這裡測的不是「gapWhy 會不會算數」,而是**它跟呼叫端講不講同一種話**。\n// 以後誰改了任一邊的鍵名,這支就會紅。\n\nimport fs from 'node:fs';\n\nconst html = fs.readFileSync(new URL('./index.html', import.meta.url).pathname, 'utf8');\n\n// 抽出 gapWhy + rollupTree 兩支(錨定「函式結束」這個結構,不綁文案——\n// 同 os-split.test.mjs 2026-08-05 的教訓:綁文案會讓改字就炸)。\nconst start = html.indexOf('function gapWhy');\nconst rollupAt = html.indexOf('function rollupTree', start);\nconst endMark = 'return { sums: sums, kids: kids };';\nconst end = rollupAt < 0 ? -1 : html.indexOf(endMark, rollupAt) + endMark.length + '\\n }'.length;\nif (start < 0 || rollupAt < 0 || end < start) throw new Error('抽不到函式區塊');\nconst src = html.slice(start, end);\n\nconst api = new Function(src + '; return { gapWhy: gapWhy, rollupTree: rollupTree };')();\n\nlet pass = 0, fail = 0;\nconst chk = (label, cond, extra = '') => {\n if (cond) { console.log('PASS:', label); pass++; }\n else { console.log('FAIL:', label, extra); fail++; }\n};\n\n// 小幫手真的會送上來的形狀(長鍵名),刻意混著支援/不支援/整棵被剪掉的目錄。\nconst nodes = [\n { path: '', name: 'kb', parent: '-', depth: 0, total_files: 2, synced_files: 1, pending_files: 1, unsupported_files: 0, excluded_files: 0 },\n { path: 'docs', name: 'docs', parent: '', depth: 1, total_files: 5, synced_files: 2, pending_files: 1, unsupported_files: 2, excluded_files: 0 },\n { path: 'docs/img', name: 'img', parent: 'docs', depth: 2, total_files: 4, synced_files: 0, pending_files: 0, unsupported_files: 4, excluded_files: 0 },\n { path: 'src', name: 'src', parent: '', depth: 1, total_files: 4, synced_files: 0, pending_files: 0, unsupported_files: 0, excluded_files: 4 },\n { path: 'node_modules', name: 'node_modules', parent: '', depth: 1, total_files: 0, synced_files: 0, pending_files: 0, unsupported_files: 0, excluded_files: 0, skipped: true, skip_reason: '這是工具產生的' },\n];\n\nconst r = api.rollupTree(nodes);\n\n// ① 子樹合計:根要把 docs/img/src 全部疊進來(skipped 的那棵不算——沒走進去就是不知道)\nconst root = r.sums[''];\nchk('根的合計=2+5+4+4(skipped 的 node_modules 不計)', root.total === 15, JSON.stringify(root));\nchk('根的已同步=1+2', root.synced === 3, JSON.stringify(root));\n\n// ② 🔴 本檔的重點:gapWhy 吃得下 rollupTree 吐出來的東西\nconst why = api.gapWhy(root);\nchk('gapWhy(合計) 不是空字串(鍵名對得上)', why.length > 0, JSON.stringify(why));\nchk('gapWhy 講得出不支援的份數', why.includes('6 份'), why); // 2(docs) + 4(img)\nchk('gapWhy 講得出不收的份數', why.includes('4 份'), why); // src\nchk('gapWhy 講得出處理中的份數', why.includes('2 份'), why); // 根 1 + docs 1\n\n// ③ 差額必須解釋得了(leo 的規格:兩個數字不相等是正常的,但差額要說得出來)\nconst gap = root.total - root.synced;\nchk('差額 == 不支援 + 不收 + 處理中', gap === root.unsupported + root.excluded + root.pending,\n `gap=${gap} unsup=${root.unsupported} excl=${root.excluded} pend=${root.pending}`);\n\n// ④ 完全同步的資料夾不該多嘴\nchk('沒有差額時 gapWhy 回空字串',\n api.gapWhy({ total: 3, synced: 3, pending: 0, unsupported: 0, excluded: 0 }) === '');\n\nconsole.log(`\\n${pass} passed, ${fail} failed`);\nprocess.exit(fail ? 1 : 0);\n"},"/portal/index.html":{"type":"text/html; charset=utf-8","b64":false,"data":"\n\n\n\n\nArcrun Portal\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n\n\n
        \n
        \n
        \n
        \"arc>run\" width=\"986\" height=\"176\">\"arc>run\" width=\"986\" height=\"176\">
        \n
        知識入口・Portal
        \n
        \n
        \n \n \n \n
        \n
        \n \n \n
        \n \n \n
        \n
        \n \n
        \n
        \n\n\n
        \n
        \n
        \n
        設定新密碼
        \n
        \n
        \n
        \n \n
        \n
        \n
        \n\n\n
        \n
        \n
        \n
        \"arc>run\" width=\"986\" height=\"176\">\"arc>run\" width=\"986\" height=\"176\">
        \n
        歡迎!先建立你的帳號
        \n
        \n
        \n \n \n \n \n
        \n \n \n
        \n
        這組帳密就是之後登入知識庫用的,只需要設定這一次。
        \n
        \n
        \n\n\n
        \n
        \n
        \"arcrun\"\"arcrun\"
        \n \n
        App 界面
        \n
        App 市集
        \n
        AR 設定
        \n
        App 開發
        \n
        ☾ 切深色
        \n
        \n
        \n
        \n\n \n
        \n
        App 界面
        \n
        載入中…
        \n
        \n\n \n
        \n
        搜尋
        \n
        \n
        \n \n \n
        \n
        \n \n \n \n
        \n \n
        \n
        AI 問答・答案附出處,可回搜尋驗證
        \n
        \n \n \n
        \n
        \n
        \n
        \n
        \n
        \n
        \n
        \n
        \n
        \n
        \n
        \n\n \n
        \n
        總圖
        \n
        \n
        \n
        \n 點節點可跳到該實體的圖譜搜尋。這張圖由知識庫的三元組即時算出——同一份地圖的文字版\n (給 AI 注入用的總庫目錄)。\n
        \n
        \n
        \n\n \n
        \n
        \n \n 知識卡片\n
        \n
        載入中…
        \n
        \n\n \n
        \n
        App 市集瀏覽、安裝、或上傳你自己做的 App
        \n
        \n
        \n
        \n
        \n
        上傳你的 App
        \n
        打包成一份宣告,讓其他人也能安裝——尚未接後端,先佔位
        \n
        \n
        \n
        已安裝
        \n
        載入中…
        \n
        可安裝
        \n \n
        載入中…
        \n
        \n
        \n\n \n
        \n
        上傳Markdown / 純文字
        \n
        \n 上傳的文件會進入知識庫收件管線:約 1 分鐘後可在搜尋頁找到;AI 整理後會以 wiki 卡形式出現。支援 .md / .txt(一律以 .md 收件)。\n
        \n
        \n
        把 .md / .txt 檔案拖放到這裡
        \n
        \n \n \n
        \n
        \n
        \n\n \n \n
        \n
        App 開發Workflows/零件/Recipe/Credential
        \n
        \n
        \n
        \n
        \n
        叫 AI 幫你做一個 App
        \n
        用一句話描述你要什麼——尚未接後端,先佔位
        \n
        \n
        \n
        工作環境
        \n
        \n
        \n Workflows\n
        \n
        \n 資料上傳\n
        \n
        零件尚未接進 Portal
        \n
        Recipe尚未接進 Portal
        \n
        Credential尚未接進 Portal
        \n
        \n
        \n
        \n\n
        \n
        工作流唯讀・系統狀態
        \n
        \n 此頁只顯示系統裡的工作流與最近執行狀態,不能觸發執行(觸發屬系統擁有者權限)。\n
        \n
        載入中…
        \n
        \n\n \n
        \n
        \n \n App\n \n
        \n
        載入中…
        \n
        \n\n \n
        \n
        設定
        \n \n
        \n
        帳號
        \n
        管理
        \n
        \n
        \n \n
        \n
        \n
        \n
        \n 版本\n \n
        \n
        查詢中…
        \n
        \n \n
        \n
        \n
        \n
        我的帳號
        \n
        載入中…
        \n
        \n
        \n
        \n
        \n
        深色模式
        \n
        預設淺色(紙感)。切換立即生效,選擇記在這台裝置。
        \n
        \n
        \n
        \n
        \n \n
        \n
        更改密碼
        \n
        需輸入舊密碼驗證身分;新密碼至少 8 碼
        \n
        \n \n \n \n \n
        \n
        \n
        \n \n
        \n \n
        \n 你的知識庫網址(小幫手連線用)\n \n \n
        \n
        同步小幫手
        \n
        把你電腦上的資料夾變成知識庫。換電腦或重裝時可以再下載一次。
        \n
        \n 下載 Mac 版\n
        封測版未簽章,第一次請右鍵→打開。裝好第一次開啟時,貼上這個網址+你的帳號密碼就連上了。
        \n
        \n
        \n \n
        \n
        \n 你的 MCP 網址(給你的 AI 連線用)\n \n \n
        \n
        接上你的 AI(MCP)
        \n
        把上面這串網址貼到 Claude、ChatGPT 等 AI 的「新增自訂連接器」欄位,就能讓你的 AI 直接查這個知識庫。
        \n \n
        \n
        允許連線的網址
        \n
        \n Claude 官方的網址本來就通,不必自己加。
        \n 要接自架的 n8n、Dify 這類工具時,把那個工具給你的「callback/redirect 網址」整條貼進來即可(會自動取出網域)。\n
        \n
        \n
        \n \n \n
        \n
        \n
        \n
        \n \n
        \n
        AI 設定
        \n
        \n 這裡不需要任何設定。聊天問答用的是你自己 Cloudflare 帳號內建的 AI,裝好就能直接問。
        \n 文件整理成知識卡的部分,請在同步小幫手(電腦上的托盤圖示)的「AI 設定…」填一把 Gemini API Key。\n
        \n
        \n \n
        \n
        疑難排解
        \n
        要回報問題,請到你電腦上的 Arcrun(同步小幫手)「版本與更新」頁——那裡的「疑難排解」按一下就能匯出完整診斷檔給我們(只有統計數字,不含你的任何文件內容)。
        \n
        \n \n
        \n
        \n\n \n
        \n
        管理帳號與知識庫授權
        \n
        \n
        帳號
        \n
        管理
        \n
        \n\n
        執行紀錄保留期
        \n
        \n
        保留天數
        \n
        執行紀錄是稽核資料,超過保留天數會被每日自動清除;預設 90 天(3 個月),也可設為「不刪除」(企業稽核用途)。
        \n
        \n \n \n \n
        \n
        \n
        \n
        \n\n
        帳號管理
        \n
        \n
        新增同仁帳號
        \n
        建立後會產生一組一次性密碼——只顯示這一次,請當場轉交同仁(同仁可在「設定」自行改密)。
        \n
        \n \n \n \n \n
        \n
        \n
        \n
        \n
        載入中…
        \n\n
        庫目錄管理
        \n
        \n 你的知識庫網址(小幫手連線用)\n \n \n
        \n
        \n \n 裝好同步小幫手並選好要看守的資料夾之後,每個資料夾會自動成為一個「庫」出現在下面——不需要人工新增。下面還是空的,代表小幫手還沒裝好或還沒選資料夾。\n
        \n
        載入中…
        \n
        \n\n
        \n
        \n\n\n
        \n
        App 界面
        \n
        App 市集
        \n
        AR 設定
        \n
        App 開發
        \n
        \n\n
        \n\n\n\n"},"/portal/libs-machine-group.test.mjs":{"type":"application/javascript; charset=utf-8","b64":false,"data":"// 同步庫總覽的**機器分組**(inkstone/Arcrun#180)——守「一眼分得出現役與殘留」這件事。\n//\n// 🔴 這支測試存在的理由是一句真的說出口的話。leo 2026-08-28 打開庫目錄看到:\n//\n// 總庫 · 16 個資料夾\n// 🖥 未知來源 · 同步小幫手還沒回報是哪一台機器\n// arcrun167-ship / rt-lib / pms / youlinhsieh-test1 / …(16 個全在這底下)\n//\n// 而他的桌面小幫手上只掛著 4 個。他的第一句話是:\n// 「**這是把別的帳號同步的資料夾外泄了?**」\n//\n// 不是外洩——16 個全是他自己歷史上同步過的。**但畫面說不清楚**,\n// 而說不清楚的代價(以為資料外洩的那幾分鐘)由使用者付。\n//\n// 病灶:機器那一層是**寫死的單一個節點**。原本的註解寫著\n// 「資料補上之後這一層會自然分裂成好幾台,畫面這端不需要再改」——**後半是錯的**:\n// 不分組就永遠只有一台,地端把 machine 送上來也不會分裂。\n//\n// 所以這裡測的不是「畫得好不好看」,而是四件會被踩壞的事:\n// 1. 有機器身分的庫**掛到那台機器底下**,而且顯示的是名字不是比對鍵\n// 2. 沒有機器身分的庫**自成一組**(那組就是「歷史殘留」的所在)——這正是「一眼分得出來」\n// 3. 同一台機器**不准裂成兩台**(改名前後、label 有時等於 id)\n// 4. 舊資料(沒有那兩格)**不准消失**:庫數進去多少就要出來多少\n\nimport fs from 'node:fs';\n\nconst html = fs.readFileSync(new URL('./index.html', import.meta.url).pathname, 'utf8');\n\n// 抽三支:machineLabelMap(既有,同一台只給一個名字)+ 這次改的兩支。\n// 數大括號取整支,不綁任何文案(同 tree-render.test.mjs 的既有慣例——\n// 綁文案的版本會在有人改一個字的時候炸,那是假紅)。\nconst grab = (name) => {\n const i = html.indexOf(`function ${name}(`);\n if (i < 0) throw new Error(`找不到 ${name}`);\n let d = 0;\n for (let k = html.indexOf('{', i); k < html.length; k++) { if (html[k] === '{') d++; if (html[k] === '}') { d--; if (!d) return html.slice(i, k + 1); } }\n throw new Error('括號不平衡');\n};\nconst src = ['machineLabelMap', 'adminLibsTreeData', 'machineTreeNode'].map(grab).join('\\n');\n\n// libTreeNode/esc 是別的職責(第三層那一列怎麼長、跳脫),這裡替身即可——\n// 本檔要守的是**分組**,不是那一列的內容。\nconst harness = `\n function esc(s) { return String(s == null ? '' : s); }\n function libTreeNode(l) { return { content: 'LIB:' + l.name, payload: {}, children: [] }; }\n`;\n\nconst api = new Function(harness + src + '; return { adminLibsTreeData: adminLibsTreeData };')();\n\nlet pass = 0, fail = 0;\nconst chk = (label, cond, extra = '') => {\n if (cond) { console.log('PASS:', label); pass++; }\n else { console.log('FAIL:', label, extra); fail++; }\n};\n\nconst MBA = 'youlinhsieh@Leo-MBA';\nconst IMAC = 'leo@Leo-iMac';\n\n/** 一個「現在正在同步」的庫:小幫手報過樹、樹上有機器身分、active 清單裡有它。 */\nfunction live(name, machine, label) {\n return {\n name, display_name: name, status: 'active', daemon_watching: true,\n folder_tree: { root: '/x/' + name, mode: 'all', reason: '', machine, machine_label: label, total_files: 3, synced_files: 3 },\n };\n}\n/** 一個「歷史殘留」的庫:從沒回報過樹(所以沒有機器身分),也不在 active 清單裡。 */\nfunction residue(name) {\n return { name, display_name: name, status: 'active', auto: true, daemon_watching: false };\n}\n\n// ═══ 1. 現役的掛到自己那台機器底下,殘留自成一組(=leo 的實況)═══\nconst libs = [\n live('youlinhsieh-test1', MBA, '教育部 Leo 的 Mac'),\n live('youlinhsieh-test2', MBA, '教育部 Leo 的 Mac'),\n live('pms', MBA, '教育部 Leo 的 Mac'),\n live('pms_v1_legacy', MBA, '教育部 Leo 的 Mac'),\n residue('rt-lib'), residue('arcrun167-ship'), residue('mira6demo'), residue('testkb'),\n];\nconst tree = api.adminLibsTreeData(libs);\n\nchk('機器層不再是寫死的一個節點', tree.children.length === 2, '實際 ' + tree.children.length + ' 組');\n\nconst named = tree.children.filter((m) => m.content.includes('教育部 Leo 的 Mac'));\nchk('有一組叫得出機器名(顯示的是 label 不是比對鍵)', named.length === 1, tree.children.map((m) => m.content).join(' | '));\nchk('那台底下掛的正是它同步的 4 個庫', named[0] && named[0].children.length === 4,\n named[0] ? String(named[0].children.length) : 'n/a');\n\nconst unknown = tree.children.filter((m) => m.content.includes('tt-unknown\">❔'));\nchk('拿不到機器身分的自成一組', unknown.length === 1);\nchk('殘留的 4 個全在那一組(沒有混進現役那台)', unknown[0] && unknown[0].children.length === 4,\n unknown[0] ? String(unknown[0].children.length) : 'n/a');\n\n// 🔴 這條是本票標題的前半:**不必逐列滑過去**就分得出哪些是殘留。\nchk('殘留那一組排在最後(現役的不該被殘留擋在後面)', tree.children[tree.children.length - 1] === unknown[0]);\nchk('殘留那一組的 ⏸ 數得出來(4 個都沒在同步)', unknown[0].content.includes('⏸ 4'), unknown[0].content);\n\n// 🔴 leo 2026-08-28:「GUI 是國際通用,圖形指示⋯⋯任何出現文字都要謹慎」\n// ⇒ 那一列上不准再出現「未知來源」四個字(理由搬進滑過去那一格)。\nchk('列上不再有「未知來源」四個字', !unknown[0].content.includes('未知來源'), unknown[0].content);\nchk('但理由沒有消失——它在滑過去那一格裡', unknown[0].payload.pop.includes('移除'), unknown[0].payload.pop);\n\n// ═══ 2. 兩台機器=兩組(原本那句「會自然分裂成好幾台」的兌現)═══\nconst two = api.adminLibsTreeData([\n live('a', MBA, '教育部 Leo 的 Mac'),\n live('b', IMAC, 'Leo 的 iMac'),\n live('c', IMAC, 'Leo 的 iMac'),\n]);\nchk('兩台機器分成兩組', two.children.length === 2, String(two.children.length));\nchk('沒有「不知道是哪一台」那組(全都認得出來)', !two.children.some((m) => m.content.includes('tt-unknown\">❔')));\nconst imac = two.children.find((m) => m.content.includes('Leo 的 iMac'));\nchk('iMac 底下 2 個庫', imac && imac.children.length === 2);\nchk('機器那一列數得出自己有幾個資料夾', imac.content.includes('🗂 2'), imac.content);\n\n// ═══ 3. 同一台機器不准裂成兩台(3492a3c 那次的病)═══\n// 實況:卡片那條路上,label 有時等於 id(沒取過名),有時是真名。分組鍵必須是 machine。\nconst renamed = api.adminLibsTreeData([\n live('a', MBA, MBA), // 還沒取名,label === id\n live('b', MBA, '教育部 Leo 的 Mac'), // 取過名\n]);\nchk('同一個比對鍵只長一台', renamed.children.length === 1, String(renamed.children.length));\nchk('取過的名字勝過原始 id', renamed.children[0].content.includes('教育部 Leo 的 Mac'), renamed.children[0].content);\n\n// ═══ 4. 舊資料不准消失(不是所有實例都會馬上升級小幫手)═══\n// 舊版小幫手:樹報得出來,但沒有 machine 那兩格;而且它**正在同步**。\nconst oldDaemon = [{\n name: 'oldlib', display_name: 'oldlib', status: 'active', daemon_watching: true,\n folder_tree: { root: '/x/oldlib', mode: 'all', reason: '', total_files: 5, synced_files: 5 },\n}];\nconst oldTree = api.adminLibsTreeData(oldDaemon);\nchk('舊版小幫手的庫沒有消失', oldTree.children.reduce((n, m) => n + m.children.length, 0) === 1);\nchk('它落在「不知道是哪一台」那組', oldTree.children[0].content.includes('tt-unknown\">❔'));\n// 🔴 誠實:它**還在同步**,不是殘留。那一格不准講成「這些都是移除掉的」。\nchk('滑過去那格說得出它還在同步', oldTree.children[0].payload.pop.includes('還在同步'), oldTree.children[0].payload.pop);\nchk('而且沒有把它講成殘留', !oldTree.children[0].payload.pop.includes('沒有小幫手在同步'), oldTree.children[0].payload.pop);\n\n// ═══ 5. 表頭的數字沒被分組弄壞(第一層還是講全部)═══\nchk('表頭仍然數全部 8 個庫', tree.content.includes('🗂 8'), tree.content);\nchk('表頭仍然數得出已同步/總共(4 個現役 × 3 份)', tree.content.includes('12 / 12'), tree.content);\n\nconsole.log(`\\n${pass} passed, ${fail} failed`);\nprocess.exit(fail ? 1 : 0);\n"},"/portal/os-split.test.mjs":{"type":"application/javascript; charset=utf-8","b64":false,"data":"import fs from 'node:fs';\nconst html = fs.readFileSync(new URL('./index.html', import.meta.url).pathname,'utf8');\n// 抽出 daemonPick 相關函式(從 DAEMON_BASE_DEFAULT 到 daemonHint 結尾)\n//\n// 🔴 2026-08-05:結尾標記本來寫死 daemonHint 的**整句文案**,於是同日改 Mac 提示語\n// (zip→DMG 的步驟不同)就讓這支自測直接炸「抽不到函式區塊」,而且沒人發現。\n// ⇒ 改成錨定「函式結束」這個結構,不再綁文案——文案本來就會改,測試不該為此壞掉。\nconst start = html.indexOf('var DAEMON_BASE_DEFAULT');\nconst hintAt = html.indexOf('function daemonHint', start);\nconst endMark = '\\n }';\nconst end = hintAt < 0 ? -1 : html.indexOf(endMark, hintAt) + endMark.length;\nif (start < 0 || hintAt < 0 || end < start) throw new Error('抽不到函式區塊');\nconst src = html.slice(start, end);\n\nconst cases = [\n ['Windows', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36'],\n ['Mac', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Safari/605.1.15'],\n ['iPhone', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Safari/604.1'],\n ['Linux', 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120 Safari/537.36'],\n];\nlet pass=0, fail=0;\nconst chk=(l,c,extra='')=>{ if(c){console.log('PASS:',l);pass++;} else {console.log('FAIL:',l,extra);fail++;} };\n\nfor (const [name, ua] of cases) {\n const fn = new Function('navigator','window', src + '; return {daemonPick:daemonPick, daemonHint:daemonHint, daemonBase:daemonBase};');\n const api = fn({userAgent: ua}, {});\n const d = api.daemonPick();\n const label = d.sure ? d.pick.label : '(兩個都給)';\n const url = d.sure ? d.pick.url : d.mac.url + ' + ' + d.win.url;\n console.log(`\\n[${name}] sure=${d.sure} → ${label}`);\n console.log(` url: ${url}`);\n if (name==='Windows') {\n chk('Windows 給 win zip', d.sure && d.pick.url.endsWith('ArcrunRAG-win-unsigned.zip'), d.pick&&d.pick.url);\n chk('Windows 另一版是 Mac', d.other && d.other.url.endsWith('ArcrunRAG-mac.dmg'));\n chk('Windows 話術提 藍色視窗', api.daemonHint('win').includes('仍要執行'));\n }\n if (name==='Mac') {\n // 2026-08-05:Mac 一律給 DMG(拖進 Applications 的標準安裝畫面),不再給 zip\n // ——zip 解開就是一個裸 .app,使用者會直接在「下載」資料夾雙擊執行,自更新會蓋錯位置。\n chk('Mac 給 dmg(不是 zip)', d.sure && d.pick.url.endsWith('ArcrunRAG-mac.dmg'));\n chk('Mac 另一版是 Windows', d.other && d.other.url.endsWith('win-unsigned.zip'));\n chk('Mac 話術提 右鍵打開', api.daemonHint('mac').includes('右鍵'));\n }\n if (name==='iPhone' || name==='Linux') {\n // iPhone 含 \"Mac OS X\" 但不是桌機 Mac;Linux 兩者皆非 → 都該落在「不確定=兩個都給」\n if (name==='Linux') chk('Linux 判不出來→兩個都給', d.sure===false);\n if (name==='iPhone') chk('iPhone 不該被判成 Mac(手機→兩個都給)', d.sure===false, 'sure='+d.sure);\n }\n}\n// 舊 key 相容\nconst fn2 = new Function('navigator','window', src + '; return daemonBase();');\nconsole.log('\\n[相容] daemonDownload 舊 key →', fn2({userAgent:''},{ARCRUN_CONFIG:{daemonDownload:'https://x.dev/d/ArcrunRAG-mac-unsigned.zip'}}));\nchk('舊 key 推得出目錄', fn2({userAgent:''},{ARCRUN_CONFIG:{daemonDownload:'https://x.dev/d/ArcrunRAG-mac-unsigned.zip'}})==='https://x.dev/d/');\nchk('daemonBase 新 key 優先', fn2({userAgent:''},{ARCRUN_CONFIG:{daemonBase:'https://y.dev/z'}})==='https://y.dev/z/');\nconsole.log(`\\n=== ${pass} passed, ${fail} failed ===`);\nprocess.exit(fail?1:0);\n"},"/portal/retrieval-tree.test.mjs":{"type":"application/javascript; charset=utf-8","b64":false,"data":"// 檢索過程樹(Arcrun#44 第二輪,leo 2026-08-18「希望以樹狀圖來展示」)的資料組裝測試。\n// 只測**樹的資料**,不測它怎麼被畫出來(那在 tree-render.test.mjs),\n// 我們自己要為之負責的是「層級對不對、有沒有把讀過的東西吞掉」。\n// 跑法:node console-ui/public/portal/retrieval-tree.test.mjs\nimport fs from 'node:fs';\nconst html = fs.readFileSync(new URL('./index.html', import.meta.url).pathname, 'utf8');\n\nconst grab = (name) => {\n const i = html.indexOf(`function ${name}(`);\n if (i < 0) throw new Error(`找不到 ${name}`);\n let d = 0, j = html.indexOf('{', i);\n for (let k = j; k < html.length; k++) { if (html[k] === '{') d++; if (html[k] === '}') { d--; if (!d) return html.slice(i, k + 1); } }\n throw new Error('括號不平衡');\n};\nconst src = ['esc', 'srcLocalPath', 'machineLabelMap', 'srcCrumbs', 'retrievalTreeData', 'normalizeTreeNode', 'visibleRows'].map(grab).join('\\n');\nconst fn = new Function(src + '\\nvar SEARCH_ROUTE_LABEL = { graph: \"圖譜命中子庫\", index: \"索引退回選庫\", all: \"全庫讀取\" };'\n + '\\nreturn { srcCrumbs, machineLabelMap, retrievalTreeData, normalizeTreeNode, visibleRows };')();\n\nlet pass = 0, fail = 0;\nconst t = (l, c, e = '') => { c ? (console.log('PASS:', l), pass++) : (console.log('FAIL:', l, e), fail++); };\nconst txt = (n) => String(n.content).replace(/<[^>]*>/g, '');\n\n// 2026-08-18 從 youlin stage 實跑 /portal/data/chat 抓下來的真回應形狀(narrative 真的是空字串)\nconst REAL = {\n route: 'graph', vector_used: false, libraries_considered: 8,\n libraries: [{ library: 'testkb', score: 1.69, via: 'graph', matched_entities: ['Arcrun124驗證卡'] }],\n indexes_read: [\n { library: 'general', narrative: '', selected: false, top_entities: [], triplet_count: 0, entry_count: 100 },\n { library: 'testkb', narrative: '', selected: true, top_entities: ['Arcrun124驗證卡'], triplet_count: 4, entry_count: 9 },\n { library: 'kb', narrative: '', selected: false, top_entities: ['knowledge-base'], triplet_count: 7, entry_count: 16 }\n ],\n pages_read: [{ page: 'Arcrun124驗證卡', library: 'testkb', chars: 231, source: 'kb://test/arcrun-124-verify.md#0' }]\n};\n\n// 🔴 inkstone/Arcrun#170(2026-08-27)改掉這裡好幾條斷言的期望值——\n// 不是測試鬆綁,是行為本身照票的要求換了:\n// 「子庫」「三元組」「索引沒有敘事」是內部詞,low-code 使用者看不懂\n// (leo 原話:「這是在跟誰溝通?」),全部換成使用者看得懂的說法;\n// 而「考慮過但沒讀原文」的收合桶本身也被拿掉——leo:「這張圖的目的是\n// 告訴他相關文件放哪裡……只該在真的有命中的時候出現,而且只畫命中的\n// 那條路徑」,收合桶再怎麼收合,都還是陪葬在畫面上的無關資訊。\n// (總管 2026-08-27 複驗指令:逐條判斷哪些是刻意改掉、哪些是改壞了,\n// 刻意改的要在這裡寫明原因,不准整支刪掉或跳過。)\n\n// ① 層級=leo 畫的那張:搜尋詞 → 命中資料夾 → 資料夾摘要 → 原文片段\nconst tree = fn.retrievalTreeData('Arcrun124驗證卡是什麼?', REAL);\nt('第 1 層是搜尋詞', txt(tree).indexOf('Arcrun124驗證卡是什麼?') === 0, txt(tree));\nconst lib = tree.children[0];\n// 「命中實體」→「找到關鍵字」(#170:使用者看得懂的字,意思不變)。\n// 🔴 同一次順便拿掉了原始相似度分數(`score 1.69`)與內部路由詞(`via: graph`)——\n// 兩個都是「對使用者沒有意義的數字/內部詞」,不服務「相關文件放哪裡」這個目的,\n// 分數沒有刻度、使用者不知道 1.69 是好是壞;拿掉比留著噪音更誠實。\nt('第 2 層是命中資料夾(帶找到的關鍵字,不帶原始分數/內部路由詞)',\n /testkb/.test(txt(lib)) && /找到關鍵字/.test(txt(lib)) && !/1\\.69/.test(txt(lib)) && !/\\bgraph\\b/.test(txt(lib)), txt(lib));\nconst idx = lib.children[0];\n// 「三元組」整個拿掉(使用者看不懂、對他沒意義),「筆條目」→「筆內容」(跟 assemble 的\n// prompt 用語一致,見 workflows/rag-chat.local.yaml 的 catalog 組字)\nt('第 3 層是該資料夾的摘要(不含三元組這種內部詞)', !/三元組/.test(txt(idx)) && /筆內容/.test(txt(idx)), txt(idx));\nconst page = idx.children[0];\nt('第 4 層是原文片段(掛在資料夾摘要底下)', /Arcrun124驗證卡/.test(txt(page)) && /231 字/.test(txt(page)), txt(page));\nt('原文片段帶資料夾麵包屑', /test › arcrun-124-verify\\.md/.test(txt(page)), txt(page));\n\n// ② narrative 是空字串時不假裝有敘事——但也不能講「索引沒有敘事」這種內部詞\n// (使用者看不懂「索引」「敘事」是什麼),改把這個資料夾主要談什麼(top_entities)\n// 擺出來,讓這一層仍然說得出讀到了什麼(REAL 的 testkb 剛好兩者都是這個情況:\n// narrative 空、top_entities 有)\nt('narrative 空時用「主要談」代替,不講內部詞「索引沒有敘事」', /主要談:Arcrun124驗證卡/.test(txt(idx)) && !/索引沒有敘事/.test(txt(idx)), txt(idx));\n// narrative 與 top_entities 都沒有時,這一層真的沒有東西可說——整行不顯示,\n// 不留一個「(索引沒有敘事)」式的空殼占位字(general 那筆兩者皆空,但 general\n// 沒被選用,不會出現在樹上;這裡直接測 idxNode 沒被匯出,改用等價的資料夾建構驗證)\nconst bothEmpty = fn.retrievalTreeData('問句', {\n route: 'graph',\n libraries: [{ library: 'blanklib', score: 1, via: 'graph', matched_entities: [] }],\n indexes_read: [{ library: 'blanklib', narrative: '', selected: true, top_entities: [], entry_count: 3 }],\n pages_read: [{ page: 'Q', library: 'blanklib', chars: 5, source: 'kb://q.md#0' }]\n});\nconst blankIdx = bothEmpty.children[0].children[0];\nt('narrative 與主要談都沒有時,那一行不留內部詞占位字', !/主要談|索引沒有敘事/.test(txt(blankIdx)) && /筆內容/.test(txt(blankIdx)), txt(blankIdx));\n\n// ③ 🔴 拿掉「沒被選用的資料夾收在收合節點」這整條行為(#170):\n// leo 的判準是「只畫命中的那條路徑」,收合起來還是陪葬在畫面上——\n// 使用者要的是「相關文件放哪裡」,不是「這次系統瞄過哪些資料夾」。\n// 新判準:沒被選用、沒讀到原文的資料夾**完全不出現**在樹上。\nt('沒被選用的資料夾完全不出現(不生收合桶,也不用任何形式露出)', !/general|(? 換行),那正是 leo 說的「上下間距很大」。\n// 一列放不下的部分由 CSS 截斷、全文掛 title,所以資料這端不准再自己換行。\nconst everyContent = [tree, lib, idx, page].map(n => String(n.content)).join('');\nt('節點內容不含換行標籤(一個節點=一列)', !//i.test(everyContent),\n (everyContent.match(/]*>/gi) || []).join(' '));\nt('同一列裡的多段資訊用分隔點接起來', //.test(String(page.content)), String(page.content));\n\n// ⑧ 判準第 2 條的算式:列數 × 一列 23px 要塞得進 1080p(收合的那支只算它自己一列)\n// #170 拿掉了「考慮過但沒讀原文」那個收合桶,這棵樹裡不再有任何 fold=1 的節點——\n// 但 visibleRows 的收合行為是共用機制(同一份 renderTree 服務三個入口,見檔頭註解),\n// 用一個手刻的 fixture 驗證它沒有被連帶改壞,不依賴這棵樹恰好長出一個折疊節點。\nconst foldedFixture = { content: 'x', payload: { fold: 1 },\n children: [{ content: 'a', children: [] }, { content: 'b', children: [] }] };\nt('收合的節點只算一列(就算底下藏著子節點)', fn.visibleRows(foldedFixture) === 1, String(fn.visibleRows(foldedFixture)));\n// 新樹形=根(1) → 資料夾(1) → 摘要(1) → 原文片段(1),沒有陪葬分支,共 4 列\n// (改前是 5:多算一列給已拿掉的「考慮過但沒讀原文」收合桶)\nt('整棵樹的列數=展開狀態下看得到幾列', fn.visibleRows(fn.normalizeTreeNode(tree)) === 4,\n String(fn.visibleRows(fn.normalizeTreeNode(tree))));\nt('單一節點也算一列(不會回 0)', fn.visibleRows({ content: 'x', children: [] }) === 1);\n\n// ⑨ 機器那一層(inkstone/mira#6 補上的兩格:machine=比對鍵、machine_label=顯示名)\nconst withM = fn.srcCrumbs('kb://RFP/daemon-真機驗收.md#0', 'mira6-watch',\n { machine: 'youlinhsieh@Leo-MBA', machine_label: '教育部 Leo 的 Mac' });\nt('有機器就拿顯示名', withM.machineLabel === '教育部 Leo 的 Mac', String(withM.machineLabel));\nt('比對鍵原樣留著(樹上分支要用它當 key)', withM.machine === 'youlinhsieh@Leo-MBA', String(withM.machine));\nt('機器名沒有混進路徑', withM.path === 'RFP/daemon-真機驗收.md' && withM.dirs.join('/') === 'RFP', withM.path);\n\nconst onlyKey = fn.srcCrumbs('kb://a/b.md#0', 'lib', { machine: 'u@Host' });\nt('只有比對鍵沒有顯示名 → 顯示名退回比對鍵', onlyKey.machineLabel === 'u@Host', String(onlyKey.machineLabel));\n\nconst noM = fn.srcCrumbs('kb://a/b.md#0', 'lib', {});\nt('舊資料沒有這兩格 → machine 是 null(畫面顯示未知來源)', noM.machine === null && noM.machineLabel === null, JSON.stringify(noM.machine));\nconst emptyM = fn.srcCrumbs('kb://a/b.md#0', 'lib', { machine: '', machine_label: '' });\nt('空字串一律當沒有,不顯示成空白機器', emptyM.machine === null && emptyM.machineLabel === null, JSON.stringify(emptyM));\n\n// 同一個相對路徑來自兩台機器 → 樹上要是兩支,不可以被併成一支\nconst twoMachines = fn.retrievalTreeData('問句', { route: 'graph',\n libraries: [{ library: 'mira6-watch', score: 2.1, via: 'graph', matched_entities: ['x'] }],\n indexes_read: [{ library: 'mira6-watch', narrative: '', selected: true, triplet_count: 1, entry_count: 2 }],\n pages_read: [\n { page: 'P', library: 'mira6-watch', chars: 10, source: 'kb://RFP/same.md#0', machine: 'u@Leo-MBA', machine_label: 'Leo 的 Mac' },\n { page: 'P', library: 'mira6-watch', chars: 10, source: 'kb://RFP/same.md#0', machine: 'u@Leo-iMac', machine_label: 'Leo 的 iMac' } ] });\nconst pageNodes = twoMachines.children[0].children[0].children;\nt('同路徑不同機器=兩支,不被併掉', pageNodes.length === 2, String(pageNodes.length));\nt('兩支各自標出自己的機器',\n /Leo 的 Mac/.test(pageNodes[0].content) && /Leo 的 iMac/.test(pageNodes[1].content),\n txt(pageNodes[0]) + ' / ' + txt(pageNodes[1]));\nconst noMachinePage = fn.retrievalTreeData('問句', { route: 'graph', libraries: [], indexes_read: [],\n pages_read: [{ page: 'P', library: 'kb', chars: 1, source: 'kb://a.md#0' }] });\nt('沒有機器的片段標「未知來源」,不留空', /未知來源/.test(noMachinePage.children[0].children[0].content));\n\n// ⑩ 同一台機器只能有一個顯示名(youlin stage 2026-08-18 實測到的真實情況:\n// 同一把 youlinhsieh@Leo-MBA,改名前那筆 label 就是 ID 本身,改名後是「教育部 Leo 的 Mac」)\nconst REALPAIR = [\n { page: '資料夾總覽:mira6-watch', library: 'mira6-watch', chars: 40, source: 'kb://x.md#0',\n machine: 'youlinhsieh@Leo-MBA', machine_label: 'youlinhsieh@Leo-MBA' },\n { page: 'daemon-真機驗收', library: 'mira6-watch', chars: 93, source: 'kb://RFP/daemon.md#0',\n machine: 'youlinhsieh@Leo-MBA', machine_label: '教育部 Leo 的 Mac' }\n];\nconst lb = fn.machineLabelMap(REALPAIR);\nt('有人取過名字就全篇用那個名字', lb['youlinhsieh@Leo-MBA'] === '教育部 Leo 的 Mac', JSON.stringify(lb));\nt('舊那筆也跟著顯示成新名字(同一台不會長成兩台)',\n fn.srcCrumbs('kb://x.md#0', 'mira6-watch', REALPAIR[0], lb).machineLabel === '教育部 Leo 的 Mac');\nt('比對鍵不受顯示名影響', fn.srcCrumbs('kb://x.md#0', 'mira6-watch', REALPAIR[0], lb).machine === 'youlinhsieh@Leo-MBA');\nt('誰都沒取過名字 → 退回顯示 ID', fn.machineLabelMap([{ machine: 'u@H', machine_label: 'u@H' }])['u@H'] === 'u@H');\nt('兩台不同機器不會被併成一個名字',\n Object.keys(fn.machineLabelMap([{ machine: 'a@X', machine_label: 'X 機' }, { machine: 'b@Y', machine_label: 'Y 機' }])).length === 2);\n\nconst treeReal = fn.retrievalTreeData('問句', { route: 'graph',\n libraries: [{ library: 'mira6-watch', score: 2.1, via: 'graph', matched_entities: ['x'] }],\n indexes_read: [{ library: 'mira6-watch', narrative: '', selected: true, triplet_count: 1, entry_count: 2 }],\n pages_read: REALPAIR });\nconst both = treeReal.children[0].children[0].children.map(n => n.content).join(' ');\nt('樹上兩個片段標的是同一個機器名', (both.match(/教育部 Leo 的 Mac/g) || []).length === 2, both.slice(0, 200));\n\nconsole.log(`\\n=== ${pass} passed, ${fail} failed ===`);\nprocess.exit(fail ? 1 : 0);\n"},"/portal/safejson.test.mjs":{"type":"application/javascript; charset=utf-8","b64":false,"data":"import fs from 'node:fs';\nconst html = fs.readFileSync(new URL('./index.html', import.meta.url).pathname,'utf8');\n\n// 抽出 safeJson 與 friendlyErr 求值\nconst grab = (name) => {\n const i = html.indexOf(`function ${name}(`);\n if (i < 0) throw new Error(`找不到 ${name}`);\n let d=0, j=html.indexOf('{', i);\n for (let k=j;k{c?(console.log('PASS:',l),pass++):(console.log('FAIL:',l,e),fail++)};\n\n// ① safeJson:非 JSON 不可拋例外(同事撞到的 404 HTML 頁)\nconst html404 = '404 Not Found';\nawait fn.safeJson({ text: () => Promise.resolve(html404) })\n .then(d => t('404 HTML → 回空物件不拋錯', typeof d === 'object' && d !== null))\n .catch(e => t('404 HTML → 不該拋錯', false, e.message));\n\nawait fn.safeJson({ text: () => Promise.resolve('') })\n .then(d => t('空回應 → 回空物件', JSON.stringify(d)==='{}'))\n .catch(() => t('空回應 → 不該拋錯', false));\n\nawait fn.safeJson({ text: () => Promise.resolve('{\"error\":\"帳號或密碼不對\"}') })\n .then(d => t('正常 JSON 仍要解析得出來', d.error === '帳號或密碼不對'), )\n .catch(() => t('正常 JSON 不該拋錯', false));\n\n// ② friendlyErr:不可把技術訊息噴給使用者\nconst leak = fn.friendlyErr(new Error('Unexpected non-whitespace character after JSON at position 4'));\nt('JSON 錯誤 → 不外洩原文', !/JSON|position/i.test(leak), `實得: ${leak}`);\nt('JSON 錯誤 → 說人話', /伺服器回應異常/.test(leak), `實得: ${leak}`);\n\nconst net = fn.friendlyErr(new Error('Failed to fetch'));\nt('網路錯誤 → 既有訊息保留', /連線中斷/.test(net), `實得: ${net}`);\n\nconst ours = fn.friendlyErr(new Error('帳號或密碼不對——用你在知識庫網站設定的那組'));\nt('我們自己的中文訊息 → 原樣顯示', /帳號或密碼不對/.test(ours), `實得: ${ours}`);\n\nconst stack = fn.friendlyErr(new Error('TypeError: Cannot read properties of undefined'));\nt('英文技術訊息 → 收斂不外洩', !/TypeError|undefined/.test(stack), `實得: ${stack}`);\n\nconsole.log(`\\n=== ${pass} passed, ${fail} failed ===`);\nprocess.exit(fail?1:0);\n"},"/portal/tree-render.test.mjs":{"type":"application/javascript; charset=utf-8","b64":false,"data":"// 樹的**畫法**測試(Arcrun#144,leo 2026-08-19 打回 markmap 之後)。\n//\n// 取代舊的 tree-links.test.mjs——那支測的是「把 markmap 的貝茲曲線改寫成 L 型折線」,\n// 而那整段程式碼連同 markmap 一起被移除了(形狀錯的不是線,是佈局;見 index.html\n// renderTree() 的技術選型註解)。\n//\n// 這支守的是**我們自己寫的那一段**:把樹資料攤成巢狀
        的字串拼接。\n// 摺疊行為本身是瀏覽器的,不測;縮排是 CSS 的,不測。\n// 跑法:node console-ui/public/portal/tree-render.test.mjs\nimport fs from 'node:fs';\nconst html = fs.readFileSync(new URL('./index.html', import.meta.url).pathname, 'utf8');\n\nconst grab = (name) => {\n const i = html.indexOf(`function ${name}(`);\n if (i < 0) throw new Error(`找不到 ${name}`);\n let d = 0;\n for (let k = html.indexOf('{', i); k < html.length; k++) { if (html[k] === '{') d++; if (html[k] === '}') { d--; if (!d) return html.slice(i, k + 1); } }\n throw new Error('括號不平衡');\n};\nconst fn = new Function(\n ['esc', 'treeHtml', 'treeKidsHtml', 'treeNodeHtml', 'titleAttr', 'normalizeTreeNode', 'visibleRows',\n 'gapWhy', 'rollupTree', 'folderTreeData', 'libTypeLabel', 'adminLibsTreeData', 'libTreeNode',\n // inkstone/Arcrun#180:機器那一層改成真的分組,於是多了這兩支相依\n //(machineLabelMap 是既有的——同一台機器在整頁只給一個名字,見 3492a3c)\n 'machineTreeNode', 'machineLabelMap',\n 'folderKidsData'].map(grab).join('\\n')\n // 同步庫總覽的第四層是「點開才抓」的,資料放在這兩張表裡(畫面端的快取)。\n // 測試自己當那份快取,就能把「還沒抓/抓過但沒有/抓到了」三種狀態都走一遍。\n + '\\nvar folderTrees = {}, treeOpen = {};'\n + '\\nreturn { treeHtml, treeKidsHtml, treeNodeHtml, titleAttr, normalizeTreeNode, visibleRows,'\n + ' adminLibsTreeData, folderTreeData,'\n + ' setFolderTree: function (n, t) { folderTrees[n] = t; },'\n + ' setOpen: function (n, v) { treeOpen[n] = v; } };')();\n\nlet pass = 0, fail = 0;\nconst t = (l, c, e = '') => { c ? (console.log('PASS:', l), pass++) : (console.log('FAIL:', l, e), fail++); };\n\nconst leaf = (c) => ({ content: c, children: [] });\nconst TREE = {\n content: '問句', children: [\n { content: 'kb', children: [leaf('頁 A'), leaf('頁 B')] },\n { content: '考慮過但沒讀原文', payload: { fold: 1 }, children: [leaf('other')] }\n ]\n};\n\n// ① 有小孩=可摺疊的
        ;沒小孩=一列,不生假的可展開節點\nconst out = fn.treeHtml(TREE);\nt('外層是 .ftree', out.startsWith('
        '), out.slice(0, 40));\nt('有小孩的節點是
        ', (out.match(/
        ,是一列 .ft-leaf', (out.match(/ft-row ft-leaf/g) || []).length === 3,\n String((out.match(/ft-row ft-leaf/g) || []).length));\nt('可摺疊的節點把標題放在 (點得到的就是這一列)', //.test(out));\n\n// ② 縮排靠巢狀,不靠算 padding ⇒ 深度沒有上限\nt('每一層小孩包在 .ft-kids 裡', (out.match(/
        /g) || []).length === 3,\n String((out.match(/
        /g) || []).length));\nlet deep = leaf('底'); for (let i = 0; i < 12; i++) deep = { content: 'L' + i, children: [deep] };\nt('12 層照樣攤得開(沒有寫死的深度上限)',\n (fn.treeHtml(deep).match(/
        /g) || []).length === 12);\n\n// ③ payload.fold ⇒ 預設收起來(那一支不能被展開,也不能被吞掉)\nconst folded = out.slice(out.indexOf('考慮過') - 200, out.indexOf('考慮過'));\nt('fold 的那一支沒有 open 屬性', /
        /.test(out), '');\nt('收起來的那支內容仍在 DOM 裡(不隱瞞,只是收合)', /other/.test(out));\n\n// ④ 節點內容是 HTML(三個入口的異質節點靠這個分)——不可以被跳脫掉\nt('節點的 HTML 原樣留著', /kb<\\/b>/.test(out) && /頁 A<\\/b>/.test(out));\n\n// ⑤ 一列放不下會截斷 ⇒ 全文必須掛在 title 上(=檔案總管對長檔名的做法)\nt('title 是剝掉標籤的純文字', fn.titleAttr('·乙') === ' title=\"甲·乙\"',\n fn.titleAttr('·乙'));\nt('title 裡的引號要跳脫,不能把屬性打斷', /"|"|'/.test(fn.titleAttr('say \"hi\"')) || !/\"hi\"/.test(fn.titleAttr('say \"hi\"')),\n fn.titleAttr('say \"hi\"'));\nt('沒有文字就不掛空的 title', fn.titleAttr('') === '');\n\n// ⑥ 多個根(資料夾樹會有)要全部畫出來,不是只畫第一個\nconst multi = fn.treeHtml([leaf('根一'), leaf('根二')]);\nt('陣列=多個根,全部畫出來', /根一/.test(multi) && /根二/.test(multi));\n\n// ⑦ 註腳(資料夾樹的「數字是已同步/總共」那段)掛得上去,且只在有內容時出現\nt('有註腳就畫在樹的後面', /
        說明<\\/div><\\/div>$/.test(fn.treeHtml(leaf('x'), '說明')));\nt('沒註腳就不生空的區塊', !/ft-foot/.test(fn.treeHtml(leaf('x'))));\n\n// ⑧ 呼叫端漏寫 children 不會炸(葉子寫成 { content } 是最常見的手滑)\nt('缺 children 的節點補得回來', fn.normalizeTreeNode({ content: 'x' }).children.length === 0);\nt('缺 children 也畫得出來', /ft-leaf/.test(fn.treeHtml({ content: 'x' })));\n\n// ⑨ 🔴 判準第 2 條的算式:列數 × 23px 要塞得進 1080p。\n// 這支測的是「列數算得對」——收合的那支只算它自己一列。\nt('列數=展開狀態下看得到的列', fn.visibleRows(fn.normalizeTreeNode(TREE)) === 5,\n String(fn.visibleRows(fn.normalizeTreeNode(TREE)))); // 根 + kb + 2 頁 + 收合的那支\nconst wide = { content: 'r', children: [] };\nfor (let i = 0; i < 8; i++) wide.children.push({ content: 'lib' + i, children: [{ content: 'idx', children: Array.from({ length: 2 }, () => leaf('p')) }] });\nt('8 子庫 × (索引+2 片原文) = 33 列,×23px ≈ 759px,1080p 放得下',\n fn.visibleRows(fn.normalizeTreeNode(wide)) === 33 && fn.visibleRows(fn.normalizeTreeNode(wide)) * 23 < 900,\n String(fn.visibleRows(fn.normalizeTreeNode(wide))));\n\n// ⑩ 行尾那一格(庫目錄的「移除」鈕)——名字被截斷時它不能跟著消失,\n// 所以它必須在 .ft-lbl **外面**,而且不出現在 title 裡(title 是「這一列叫什麼」)。\nconst withTail = fn.treeHtml({ content: '很長的名字', tail: '', children: [] });\nt('tail 畫在 .ft-lbl 外面(不參與截斷)', /<\\/span>