@@ -1,20 +1,38 @@
import type { ParsedTriplets , NodeRole } from './triplet-parser' ;
import { resolveNodeRole } from './triplet-parser' ;
import { resolveNodeRole , isVirtualIoName } from './triplet-parser' ;
import { wasmWorkerUrl } from '../lib/component-loader' ;
import { resolveRecipe } from '../routes/recipes' ;
import type { RecipeDefinition } from '../routes/recipes' ;
export type NodeStatus = 'found' | 'missing' | 'unknown' ;
/**
* `not_found` 而非 `missing`:欄位契約以頂層機械考
* `system-dev/docs/3-specs/arcrun-usable/verify.sh` 為準(01 組 grep `not_found`)。
*/
export type NodeStatus = 'found' | 'not_found' | 'unknown' ;
export type NodeInfo = {
status : NodeStatus ;
componentId? : string ;
type : NodeRole ;
/** found 時標來源庫:零件 registry( component)或 recipe 庫(recipe)。 */
source ? : 'component' | 'recipe' ;
/** 零件契約(found 時附上,讓 AI 知道怎麼填 payload)。 */
input_schema? : unknown ;
/** 成功率(found 時附上,讓「被測過幾次」看得見)。 */
success_rate? : number ;
stability? : string ;
/** missing 時給的相近零件建議(避免 AI 只知道「沒有」卻不知道該用什麼 )。 */
sugges tions ? : string [ ] ;
/** recipe found 時附上(AI 看得懂這個 recipe 在打哪個 API )。 */
descrip tion? : string ;
endpoint? : string ;
/**
* not_found 時的分型指路(task 3.7):兩庫(零件 registry+ recipe 庫)都查過才點名,
* 並告訴 AI 該走哪條補件路+去哪裡看做法。欄位名 `suggestion`(單數字串)=verify.sh 03 組契約。
*/
suggestion? : string ;
/** not_found 時的相近零件候選(自然語言節點名 → 既有零件的媒合)。 */
similar_components? : string [ ] ;
/** not_found 時的相近 recipe 候選。 */
similar_recipes? : string [ ] ;
} ;
export type SearchResult = {
@@ -22,13 +40,26 @@ export type SearchResult = {
missingNodes : string [ ] ;
} ;
/** searchNodes 需要的環境子集(cypher-handlers 傳整份 Bindings 進來也相容)。 */
export type SearchNodesEnv = {
WORKER_SUBDOMAIN? : string ;
/**
* registry 位置覆蓋(可選,非機密)。未設 → 用 wasmWorkerUrl('registry', WORKER_SUBDOMAIN)
* 現算(比照 KBDB_GRAPH_URL 慣例)。本地 wrangler dev / self-hosted 把 registry 掛別處時用。
*/
REGISTRY_BASE_URL? : string ;
/** recipe 庫(本 worker 自己的 KV;task 3.6 兩庫都查的第二庫)。 */
RECIPES? : KVNamespace ;
} ;
/**
* 對所有節點進行解析,確認每個節點對應的零件 ID 與**是否 真的存在**。
* 對所有節點進行解析,確認每個節點對應的零件/ recipe 是否** 真的存在**。
*
* ⚠️ 2026-07-30 改為會查 registry( workflow-discovery task 3.x) 。
* ⚠️ 2026-07-30 改為會查 registry( workflow-discovery task 3.x) ;
* 2026-07-31 再加 recipe 庫查詢+缺件分型指路(task 3.6/3.7)。
*
* 改之前的行為(病灶):無條件回 `status: 'found'`、`missingNodes` 永遠是空陣列——
* 型別雖宣告了 `'missing'` 但程式碼從不使用。 實測「完全不存在的東西xyz」也回 found。
* 實測「完全不存在的東西xyz」也回 found。
*
* 為什麼這是嚴重問題(leo 2026-07-30 定性「腹語術」):
* AI 寫意圖 → 查詢回「都 found」(假信號)→ 實際零件不存在
@@ -36,23 +67,32 @@ export type SearchResult = {
* → 於是正式 workflow 只用 2 個零件、8 個 code 節點含 if× 61
* ⇒ 「零件被測過 1000 次所以 AI 只要填 payload」的價值完全落空。
*
* 誠實限制:查不到 registry(未部署/網路失敗)時回 `'unknown'` 而不是 `'missing'` ——
* 設計基調(leo 2026-07-31 二次定調):**回覆的重點是「缺哪些」不是「有哪些」** ——
* 有的照常編圖不必報告;缺的要兩庫(零件 registry+recipe 庫)都搜過後點名+給正確指示:
* 缺外部 API → 自己寫 recipe( skill `write_recipe`);
* 缺計算原語 → 投稿零件 PR( skill `add_new_wasm_component`)。
*
* 誠實限制:查不到 registry(未部署/網路失敗)時回 `'unknown'` 而不是 `'not_found'`——
* 不能因為查詢失敗就宣告零件不存在(那會讓 AI 誤判而重寫 code,正是要避免的事)。
*/
export async function searchNodes (
parsed : ParsedTriplets ,
config? : Record < string , Record < string , unknown > > ,
env ? : { WORKER_SUBDOMAIN? : string } ,
env? : SearchNodesEnv ,
) : Promise < SearchResult > {
const nodeResults : Record < string , NodeInfo > = { } ;
const missingNodes : string [ ] = [ ] ;
const sub = env ? . WORKER_SUBDOMAIN ;
const registryBase = env ? . REGISTRY_BASE_URL ? ? ( sub ? wasmWorkerUrl ( 'registry' , sub ) : undefined ) ;
for ( const nodeName of parsed . nodeNames ) {
const role = resolveNodeRole ( nodeName , parsed ) ;
if ( role === 'Input' || role === 'Output' ) {
// 只有**字面上的虛擬 IO 名**( input/trigger/…/output/done)才免查——
// 位置上是頭節點但名字是真零件(`aes_encrypt >> … >> code` 的頭,role 也是 Input)
// 仍要照常查兩庫,否則缺件被角色掩蓋、又回到「假 found」。
if ( ( role === 'Input' || role === 'Output' ) && isVirtualIoName ( nodeName ) ) {
nodeResults [ nodeName ] = { status : 'found' , componentId : nodeName.toLowerCase ( ) , type : role } ;
continue ;
}
@@ -61,21 +101,22 @@ export async function searchNodes(
const componentId = configComponent ? ? nodeName ;
// config 明確給了 component(多半是安裝器代入的 worker URL 或既有 workflow)
// → 不判 missing 。這條路徑的存在性由 component-loader 在執行時決定(原行為)。
// → 不判 not_found 。這條路徑的存在性由 component-loader 在執行時決定(原行為)。
if ( configComponent ) {
nodeResults [ nodeName ] = { status : 'found' , componentId , type : role } ;
continue ;
}
if ( ! sub ) {
if ( ! registryBase ) {
nodeResults [ nodeName ] = { status : 'unknown' , componentId , type : role } ;
continue ;
}
const q = await fetchComponent ( sub , componentId ) ;
// ── 第一庫:零件 registry ────────────────────────────────────────────────
const q = await fetchComponent ( registryBase , componentId ) ;
if ( ! q . ok ) {
// registry 查不通(未部署/網路失敗)⇒ 誠實回 unknown。
// **不能誤判 missing **——那會讓 AI 以為零件不存在而重寫 code,正是要避免的事。
// **不能誤判 not_found **——那會讓 AI 以為零件不存在而重寫 code,正是要避免的事。
nodeResults [ nodeName ] = { status : 'unknown' , componentId , type : role } ;
continue ;
}
@@ -84,6 +125,7 @@ export async function searchNodes(
status : 'found' ,
componentId ,
type : role ,
source : 'component' ,
input_schema : q.entry.input_schema ,
success_rate : q.entry.success_rate ,
stability : q.entry.stability ,
@@ -91,13 +133,99 @@ export async function searchNodes(
continue ;
}
nodeResults [ nodeName ] = { status : 'missing' , componentId , type : role } ;
// ── 第二庫:recipe 庫(task 3.6——「說不出某 recipe 沒有」的病灶就在漏了這步)──
const recipe = env ? . RECIPES ? await resolveRecipe ( componentId , env . RECIPES ) : null ;
if ( recipe ) {
nodeResults [ nodeName ] = {
status : 'found' ,
componentId : recipe.canonical_id ,
type : role ,
source : 'recipe' ,
description : recipe.description ,
endpoint : recipe.endpoint ,
} ;
continue ;
}
// ── 兩庫都沒有 ⇒ not_found + 分型指路(task 3.7)+ 相近候選 ────────────
const [ similarComponents , similarRecipes ] = await Promise . all ( [
searchSimilarComponents ( registryBase , nodeName ) ,
env ? . RECIPES ? searchSimilarRecipes ( env . RECIPES , nodeName ) : Promise . resolve ( [ ] ) ,
] ) ;
nodeResults [ nodeName ] = {
status : 'not_found' ,
componentId ,
type : role ,
suggestion : buildSuggestion ( componentId ) ,
. . . ( similarComponents . length > 0 ? { similar_components : similarComponents } : { } ) ,
. . . ( similarRecipes . length > 0 ? { similar_recipes : similarRecipes } : { } ) ,
} ;
missingNodes . push ( nodeName ) ;
}
return { nodeResults , missingNodes } ;
}
// ── 缺件分型(task 3.7)────────────────────────────────────────────────────────
//
// 分型判準(刻意用簡單可解釋的規則,不接 LLM——查詢端點要快、要可預測):
// 1) 名字含**外部服務詞**( google/ telegram/ slack…)→「外部 API 樣貌」
// → recipe 路:recipe 是 http_request+參數模板的具名封裝,用戶自己就能寫,不用改平台。
// 2) 否則名字含**計算原語詞**( encrypt/ hash/ encode…)→「計算原語樣貌」
// → 零件路:純計算得進 WASM 沙箱跑,要走 GitHub PR 投稿(人 merge=人類閘門,mindset §4)。
// 3) 都不含 → 判不出型,誠實說判不出,兩條路都給(不硬猜——猜錯會把人指去錯的路)。
// 判斷順序:服務詞優先於計算詞——「google_sheets_parse」雖含 parse,本質仍是打外部 API。
const SERVICE_HINTS = [
'google' , 'gmail' , 'sheets' , 'slides' , 'gdocs' , 'drive' , 'calendar' , 'youtube' ,
'slack' , 'telegram' , 'discord' , 'line' , 'whatsapp' , 'twilio' ,
'notion' , 'airtable' , 'trello' , 'jira' , 'asana' , 'linear' ,
'github' , 'gitea' , 'gitlab' , 'bitbucket' ,
'stripe' , 'paypal' , 'shopify' , 'hubspot' , 'salesforce' ,
'openai' , 'anthropic' , 'claude' , 'gemini' , 'groq' ,
'twitter' , 'facebook' , 'instagram' , 'linkedin' , 'dropbox' , 'zoom' ,
'sendgrid' , 'mailgun' , 'kbdb' ,
] ;
const COMPUTE_HINTS = [
'encrypt' , 'decrypt' , 'cipher' , 'aes' , 'rsa' , 'sha' , 'md5' , 'hmac' , 'hash' ,
'sign' , 'verify' , 'encode' , 'decode' , 'base64' , 'hex' ,
'compress' , 'decompress' , 'zip' , 'gzip' ,
'uuid' , 'random' , 'regex' , 'math' , 'calc' ,
'sort' , 'dedup' , 'diff' , 'template' , 'render' , 'convert' , 'transform' ,
'parse' , 'format' , 'csv' , 'xml' ,
] ;
function buildSuggestion ( componentId : string ) : string {
const lower = componentId . toLowerCase ( ) ;
const serviceHit = SERVICE_HINTS . find ( w = > lower . includes ( w ) ) ;
const computeHit = COMPUTE_HINTS . find ( w = > lower . includes ( w ) ) ;
if ( serviceHit ) {
return (
` 兩庫都查過,零件 registry 與 recipe 庫皆無「 ${ componentId } 」。 ` +
` 名字含服務詞「 ${ serviceHit } 」=外部 API 樣貌 → 沒有此 recipe,可自己寫: ` +
` 寫法看 skill「write_recipe」(arcrun_get_skill('write_recipe')), ` +
` 寫好用 acr recipe push 或 POST /recipes 裝上即可用,不用改平台。 `
) ;
}
if ( computeHit ) {
return (
` 兩庫都查過,零件 registry 與 recipe 庫皆無「 ${ componentId } 」。 ` +
` 名字含計算詞「 ${ computeHit } 」=計算原語樣貌 → 沒有此零件,可投稿 PR 新增 WASM component: ` +
` 做法看 skill「add_new_wasm_component」(arcrun_get_skill('add_new_wasm_component'))。 `
) ;
}
return (
` 兩庫都查過,零件 registry 與 recipe 庫皆無「 ${ componentId } 」,且名字判不出型。 ` +
` 缺外部 API → 自己寫 recipe( skill「write_recipe」); ` +
` 缺計算能力 → 投稿零件 PR( skill「add_new_wasm_component」,component 進 WASM 沙箱)。 `
) ;
}
// ── registry 查詢 ─────────────────────────────────────────────────────────────
type CatalogEntry = {
input_schema? : unknown ;
success_rate? : number ;
@@ -112,16 +240,15 @@ type CatalogEntry = {
* 這是 CP2-B 記載的缺口(「修 /components 404」)——補了列表端點後可改為抓一次。
* 現階段逐個查:節點數通常 <10,且有 5s timeout,可接受。
*
* 回傳 `null ` 代表「查不到 registry 或該零件不存在 」,由呼叫端區分:
* 整體查不通 → `unknown`;查得通但這顆沒有 → `missing` 。
* 回傳 `ok:false ` 代表「查不到 registry」,由呼叫端區分:
* 整體查不通 → `unknown`;查得通但這顆沒有 → 繼續查 recipe 庫 。
*/
async function fetchComponent (
subdomain : string ,
registryBase : string ,
id : string ,
) : Promise < { ok : boolean ; entry? : CatalogEntry } > {
try {
const base = wasmWorkerUrl ( 'registry' , subdomain ) ;
const res = await fetch ( ` ${ base } /components/ ${ encodeURIComponent ( id ) } ` , {
const res = await fetch ( ` ${ registryBase } /components/ ${ encodeURIComponent ( id ) } ` , {
signal : AbortSignal.timeout ( 5000 ) ,
} ) ;
if ( res . status === 404 ) return { ok : true } ; // registry 活著,但沒這顆
@@ -141,3 +268,76 @@ async function fetchComponent(
return { ok : false } ;
}
}
// ── 相近候選(自然語言節點名 → 既有零件/recipe 的媒合)──────────────────────────
//
// 節點名常是自然語言(例「判斷有沒有新資料」)。leo:「AI 不用知道零件存在」——
// 所以 not_found 時要主動給相近候選,讓 AI 看回覆就知道「其實有 if_control 可用」。
// 做法:先拿全名打 registry `/components/search`;沒中再斷詞重試——
// ASCII 取 3 字以上的詞、中日韓取 2-gram( registry search 是子字串比對,整句中文必落空,
// 2-gram 才撈得到「判斷」→ if_control( display_name「條件判斷」)這種命中)。
function extractTokens ( name : string ) : string [ ] {
const tokens : string [ ] = [ ] ;
const ascii = name . toLowerCase ( ) . match ( /[a-z0-9]{3,}/g ) ? ? [ ] ;
tokens . push ( . . . ascii ) ;
const cjkRuns = name . match ( /[一-鿿]+/g ) ? ? [ ] ;
for ( const run of cjkRuns ) {
for ( let i = 0 ; i + 2 <= run . length ; i ++ ) tokens . push ( run . slice ( i , i + 2 ) ) ;
}
return [ . . . new Set ( tokens ) ] . slice ( 0 , 8 ) ; // 上限 8 個 token,避免對 registry 掃太多輪
}
async function searchRegistryIds ( registryBase : string , q : string ) : Promise < string [ ] > {
try {
const res = await fetch ( ` ${ registryBase } /components/search?q= ${ encodeURIComponent ( q ) } ` , {
signal : AbortSignal.timeout ( 5000 ) ,
} ) ;
if ( ! res . ok ) return [ ] ;
const body = ( await res . json ( ) ) as { data ? : { results? : Array < { canonical_id? : string } > } } ;
return ( body . data ? . results ? ? [ ] ) . map ( r = > r . canonical_id ) . filter ( ( s ) : s is string = > ! ! s ) ;
} catch {
return [ ] ;
}
}
async function searchSimilarComponents ( registryBase : string , nodeName : string ) : Promise < string [ ] > {
// 1) 全名直接搜
const direct = await searchRegistryIds ( registryBase , nodeName ) ;
if ( direct . length > 0 ) return direct . slice ( 0 , 3 ) ;
// 2) 斷詞搜,依命中次數排序
const tokens = extractTokens ( nodeName ) ;
if ( tokens . length === 0 ) return [ ] ;
const hits = await Promise . all ( tokens . map ( t = > searchRegistryIds ( registryBase , t ) ) ) ;
const count = new Map < string , number > ( ) ;
for ( const ids of hits ) {
for ( const id of ids ) count . set ( id , ( count . get ( id ) ? ? 0 ) + 1 ) ;
}
return [ . . . count . entries ( ) ] . sort ( ( a , b ) = > b [ 1 ] - a [ 1 ] ) . slice ( 0 , 3 ) . map ( ( [ id ] ) = > id ) ;
}
/** recipe 庫的相近候選:KV 全列(本部署 recipe 數量小)後子字串比對。 */
async function searchSimilarRecipes ( kv : KVNamespace , nodeName : string ) : Promise < string [ ] > {
try {
const list = await kv . list ( { prefix : 'recipe:' } ) ;
const all = ( await Promise . all (
list . keys . map ( k = > kv . get ( k . name , 'json' ) as Promise < RecipeDefinition | null > ) ,
) ) . filter ( Boolean ) as RecipeDefinition [ ] ;
const tokens = [ nodeName . toLowerCase ( ) , . . . extractTokens ( nodeName ) ] ;
const seen = new Set < string > ( ) ;
const matched : string [ ] = [ ] ;
for ( const r of all ) {
if ( seen . has ( r . canonical_id ) ) continue ;
const hay = ` ${ r . canonical_id } ${ r . display_name ? ? '' } ${ r . description ? ? '' } ` . toLowerCase ( ) ;
if ( tokens . some ( t = > hay . includes ( t ) ) ) {
seen . add ( r . canonical_id ) ;
matched . push ( r . canonical_id ) ;
}
}
return matched . slice ( 0 , 3 ) ;
} catch {
return [ ] ;
}
}