Compare commits

..

1 Commits

Author SHA1 Message Date
uncle6me-web 614fe44812 fix(kbdb): 藏書地圖加 entry_count——triplet_count=0 不再被誤讀成「沒有知識」(Arcrun#87 三次收尾)
leo21c 實測:kbdb_get_map() 9 庫裡 8 庫 triplet_count:0/top_entities:[](kb 以外全部),
一個全新 session 讀到這份地圖會合理但錯誤地判定「這些庫沒有知識」而放棄查詢——但
kbdb_search(mode=keyword) 找得到 42 筆真內容(來源 gitea:Leo/Arcrun@…/gitea:Leo/mira@…等),
其中 15 筆 entries 的 metadata_json.library 已正確標成 arcrun/mira/arcrun-harness/arcrun-rag。

查證(唯讀,未動 leo21c 任何寫入):
- entries(原始 ingest 內容)與 triplet(從 entries 萃取出的三元組)是兩個不同存放處。
- kb 以外 7 庫:entries 有、library 標記正確;triplet 一筆都沒有——不是「三元組沒貼標」,
  是「三元組從沒被萃取」(另一條偵察線在查斷在哪一段,跨 repo,本 PR 不處理)。
- general 庫(未標 library 的三元組兜底分類)即時計數也是 0 ⇒ 沒有任何「有三元組但沒標庫」
  的候選 ⇒ Leo/Arcrun#87 先前那條批次補標通道(PR #114)在目前資料現況下會補到 0 筆,
  它解的是另一個問題,不是這次「地圖看起來是空的」的真因。

本次修法(純讀端加欄位,不碰任何寫入/部署/線上資源):
- kbdb/src/actions/library-map.ts:新增 liveEntryCountsByLibrary(),依 entries 自己的
  metadata_json.$.library 分組即時計數(排除 entry_type='value' 儲存碎片與地圖自己的歷史
  摘要 block,避免自我膨脹)。listLibraryMaps/getLibraryMapDetail/recomputeLibraryMap
  的回傳都加上 entry_count,與 triplet_count 並排、互不覆蓋。
- mcp/src/lib/library-map.ts:renderLibraryMapLines(MCP 連線開場注入的那份地圖原文)
  triplet_count=0 但 entry_count>0 時改印「0 triplets/N 筆原始內容(尚未萃取關係,
  kbdb_search 查得到)」,不再只印「0 triplets」。
- mcp/src/tools/kbdb_map.ts:kbdb_get_map 工具的全館/單庫回應都帶 entry_count,並在符合
  條件時附加提示,明講「triplet_count=0 不代表沒有知識」。
- console-ui/public/console/index.html:藏書地圖看板卡片同步顯示,人類看的畫面同一件事。

順手修掉一個真 SQL bug:entry_count 排除條件原寫
`NOT (entry_type='block' AND json_extract(...)='library_map')`,SQL 三值邏輯下
metadata_json 沒有 $.kind 欄位時 json_extract 回 NULL、`NULL = 'library_map'` 為 NULL
(非 false),整條 WHERE 判定 NULL 而把所有列濾掉——改用 COALESCE(...,'') 修正
(新增測試以 sqlite 實跑驗證抓到並鎖住這個修法)。

測試:kbdb 21/21(新增 2 案,全套 215/215);mcp kbdb-map 33/33(新增 7 案,全套 120/120)。
cypher-executor 既有 map/portal-data 相關測試(library-map-scope-108/kbdb-map-proxy/
portal-data)103/104 綠,唯一失敗(/portal HTML 殼 404)在未動過的 main checkout 上同樣
失敗,環境既有問題、與本次改動無關。

CP:◐ 半通。程式碼在此分支,測試綠燈,未併 main、未部署 leo21c——entry_count 要讓
leo21c 的真實使用者看到,需部署 kbdb+mcp(cypher-executor 未改動,portal-data.ts
是透明轉發不需重部署)。三元組萃取斷在哪一段(真因)與 23/42 entries 未 embed
(語意搜尋覆蓋率)兩件不在本 PR 範圍,分別交給另一條偵察線與 embed reconcile 管線。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 12:36:03 +08:00
11 changed files with 333 additions and 559 deletions
+6 -1
View File
@@ -840,7 +840,12 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
'<div class="kt">' + esc(l.library) + (S.library === l.library ? ' <span class="tag" style="font-size:11.5px;vertical-align:middle">搜尋中</span>' : '') + '</div>' +
'<div class="ks">' + (l.narrative ? esc(l.narrative) : '<span class="dim">(此庫尚無 narrative——recompute 時可帶入)</span>') + '</div>' +
(ents && ents.length ? '<div style="display:flex;gap:6px;flex-wrap:wrap">' + lmEntityLine(ents) + '</div>' : '') +
'<div class="km"><span class="mono" style="color:var(--amber)">' + (Number(l.triplet_count) || 0) + ' 三元組</span>' +
// Arcrun#87 三次收尾(2026-08-13):triplet_count=0 不等於這庫沒有知識——entries 有標庫、
// 只是三元組萃取沒對它跑過(kb 以外幾乎全庫皆此況)。entry_count>0 時附一句,別讓看板
// 的「0 三元組」被讀成「這庫是空的」(同 MCP 端 kbdb_map.ts 的 entryOnlyHint 同一件事)。
'<div class="km"><span class="mono" style="color:var(--amber)">' + (Number(l.triplet_count) || 0) + ' 三元組' +
(!(Number(l.triplet_count) || 0) && (Number(l.entry_count) || 0) > 0
? '<span class="dim">・' + (Number(l.entry_count) || 0) + ' 筆原始內容(尚未萃取)</span>' : '') + '</span>' +
'<span class="dim" style="margin-left:auto;font-size:12.5px">' + stamp + '</span></div></div>';
}).join('');
}
+65 -11
View File
@@ -38,6 +38,13 @@ export interface LibraryMapRow {
narrative: string | null;
top_entities: string[]; // 全館視圖只回 top 3 名字(數百 token 內,design §4 MCP instructions 用)
triplet_count: number;
// Arcrun#87 三次收尾(2026-08-13,「藏書地圖說實話」):triplet_count=0 不等於「這庫沒有知識」——
// entriesingest 進來的原始卡片/skill 內容)與 triplet(從 entries 萃取出的三元組)是兩件事,
// 一個庫可能有大量 entries、但三元組萃取從沒對它跑過(實測:kb 以外 7 庫皆此況,entries 存在
// 且 library 標記正確,triplet 一筆都沒有)。entry_count 讓讀端(AIMCP 渲染層)分得清
// 「真的沒東西」與「有東西但還沒被萃取成三元組」,不再把後者誤讀成前者、誤判庫是空的而放棄查詢
// (見本檔 liveEntryCountsByLibrary 的計數口徑)。
entry_count: number;
updated_at: number;
}
@@ -50,6 +57,7 @@ export interface LibraryMapDetail {
relation_profile: RelationStat[];
bridges: Bridge[];
triplet_count: number;
entry_count: number; // 同 LibraryMapRow.entry_count(見該欄位註解)
commit_hash: string | null;
status: string;
updated_at: number;
@@ -285,6 +293,10 @@ export async function recomputeLibraryMap(db: D1Database, input: RecomputeInput)
superseded.push(row.rid);
}
// entry_countArcrun#87 三次收尾):recompute 只重算三元組那一半,entry_count 是即時算的
// 另一半,兩者同樣的道理一起回傳(見 LibraryMapDetail.entry_count 欄位註解)。
const entryCount = (await liveEntryCountsByLibrary(db, owner)).get(library) ?? 0;
return {
map: {
record_id: blockEntry.id,
@@ -295,6 +307,7 @@ export async function recomputeLibraryMap(db: D1Database, input: RecomputeInput)
relation_profile: relationProfile,
bridges,
triplet_count: tripletCount,
entry_count: entryCount,
commit_hash: input.commit_hash ?? null,
status: 'active',
updated_at: blockEntry.created_at,
@@ -367,6 +380,34 @@ async function liveTripletCountsByLibrary(
return m;
}
// 這個 owner 底下、依 entries 自己的 metadata_json.$.library 分組的即時「原始內容」數
//Arcrun#87 三次收尾,2026-08-13)——與 liveTripletCountsByLibrary 算的是兩張完全不同的帳:
// 那個算「萃取出幾條三元組」,這個算「ingest 進來幾筆原始卡片/skill/block」。兩者天生會不一樣
// (萃取是下游、有延遲甚至從沒對某些庫跑過),刻意分開算、分開回傳,不是同一數字的兩種寫法。
//
// 排除口徑(都是結構性排除,不是內容語意判斷——沒有違反「base 對內容語意無知」):
// - entry_type='value'record-crud.ts 的通用機制,任何 template(含 triplet 本身)建 record
// 時每個 slot 值都會落一筆這種 entry,是儲存實作細節,不是使用者看得到的「一筆知識」。
// - metadata.kind='library_map' 的 block:地圖自己每次 recompute 產出的摘要 block(含歷史
// superseded 的),算進去會自我膨脹、且無限接近雞生蛋——地圖不該把自己算進地圖裡。
async function liveEntryCountsByLibrary(db: D1Database, owner_id?: string): Promise<LibraryCountMap> {
const params: unknown[] = owner_id ? [owner_id] : [];
const res = await db
.prepare( // kbdb-sql-ok:牆內本體(kbdb/src/actions/),checkout 開在巢狀 worktree /private/tmp/wt-arcrun-library-map-honesty-87/(同 962d863/5919c6b 已記載的假警報成因:hook 逐字比對 matrix/arcrun/kbdb/src/ 吃不到中間多出的 worktree 目錄層,非繞牆)
`SELECT COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general') AS library,
COUNT(*) AS n
FROM entries
WHERE ${owner_id ? 'owner_id = ? AND ' : ''}entry_type != 'value'
AND NOT (entry_type = 'block' AND COALESCE(json_extract(metadata_json, '$.kind'), '') = 'library_map')
GROUP BY COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general')`,
)
.bind(...params)
.all<{ library: string; n: number }>();
const m: LibraryCountMap = new Map();
for (const r of res.results ?? []) m.set(r.library, r.n);
return m;
}
// 「已知庫名」集合:即使目前三元組數是 0,只要蓋過章(entries metadata.libraryt52 慣例)或
// 登記過(portal_library record),就不算「查無此庫」——用來分辨 GET /map/:library 的
// 「這庫是空的」(回 200triplet_count:0vs「查無此庫」(回 404)。kbdb base 對 portal_library
@@ -453,20 +494,26 @@ interface MapPivotRow {
ts: number;
}
// 全館地圖:每庫一行(librarynarrativetop 3 entitiestriplet_count),MCP instructions
// 直接嵌用(設計上限=數百 token,R3)。template 還不存在(從未 recompute)→ 誠實回空清單。
// 全館地圖:每庫一行(librarynarrativetop 3 entitiestriplet_countentry_count),MCP
// instructions 直接嵌用(設計上限=數百 token,R3)。template 還不存在(從未 recompute)→
// 誠實回空清單。entry_count 與 triplet_count 並排回傳(Arcrun#87 三次收尾):一個查「原始內容
// 有沒有」、一個查「萃取出幾條三元組」,兩者不同源、允許不一致(見 liveEntryCountsByLibrary
// 註解),讀端不該把其中一個的 0 當成另一個的答案。
export async function listLibraryMaps(db: D1Database, owner_id?: string): Promise<LibraryMapRow[]> {
const tpl = await getTemplate(db, LIBRARY_MAP_TEMPLATE_NAME);
if (!tpl) return [];
const params: unknown[] = owner_id ? [tpl.id, owner_id] : [tpl.id];
const res = await db
.prepare(
`WITH m AS (${mapPivotSql(!!owner_id)})
SELECT * FROM m WHERE COALESCE(m.status, 'active') = 'active' AND m.library IS NOT NULL
ORDER BY m.ts DESC`,
)
.bind(...params)
.all<MapPivotRow>();
const [res, entryCounts] = await Promise.all([
db
.prepare( // kbdb-sql-ok:牆內本體(kbdb/src/actions/),既有查詢(listLibraryMaps 原本就有)此次改包進 Promise.all 才重新觸發掃描,非新增違規;worktree 路徑假警報同上方 liveEntryCountsByLibrary 註解
`WITH m AS (${mapPivotSql(!!owner_id)})
SELECT * FROM m WHERE COALESCE(m.status, 'active') = 'active' AND m.library IS NOT NULL
ORDER BY m.ts DESC`,
)
.bind(...params)
.all<MapPivotRow>(),
liveEntryCountsByLibrary(db, owner_id),
]);
// 每庫只留最新 activesupersede 失敗殘留多個 active 時,讀端自癒取最新——順序安全的另一半)。
const byLib = new Map<string, LibraryMapRow>();
for (const r of res.results ?? []) {
@@ -476,6 +523,7 @@ export async function listLibraryMaps(db: D1Database, owner_id?: string): Promis
narrative: r.narrative || null,
top_entities: parseJsonArray<TopEntity>(r.top_entities).slice(0, 3).map((t) => t.name),
triplet_count: Number(r.triplet_count ?? 0) || 0,
entry_count: entryCounts.get(r.library) ?? 0,
updated_at: r.ts,
});
}
@@ -501,7 +549,12 @@ export async function getLibraryMapDetail(
.first<MapPivotRow>();
if (!row) return null;
// record_idmap block entry idrecompute 寫入時綁定);entry 若被外力刪除,content 誠實回 null。
const blockEntry = await getEntry(db, row.rid);
// entry_count 與全館視圖(listLibraryMaps)同一套計法(liveEntryCountsByLibrary),單庫詳圖
// 只取自己那一庫的數字——Arcrun#87 三次收尾,理由見 LibraryMapDetail.entry_count 欄位註解。
const [blockEntry, entryCounts] = await Promise.all([
getEntry(db, row.rid),
liveEntryCountsByLibrary(db, owner_id),
]);
return {
record_id: row.rid,
library,
@@ -511,6 +564,7 @@ export async function getLibraryMapDetail(
relation_profile: parseJsonArray<RelationStat>(row.relation_profile),
bridges: parseJsonArray<Bridge>(row.bridges),
triplet_count: Number(row.triplet_count ?? 0) || 0,
entry_count: entryCounts.get(library) ?? 0,
commit_hash: row.commit_hash || null,
status: row.status ?? 'active',
updated_at: row.ts,
+57
View File
@@ -361,6 +361,63 @@ describe('M3 收尾 — 即時新鮮度(ensureFreshLibraryMaps,讀端自動
expect(unknown.status).toBe(404); // 真的從沒出現過的名字才 404
});
it('entry_count 讓「有原始內容但三元組從沒萃取過」與「真的什麼都沒有」分得清(Arcrun#87 三次收尾)', async () => {
// 情境沿用上一案的 'hr' 庫(entries 蓋過章、triplet_count:0),這正是 leo21c 實測到的真實
// 現況(kb 以外 7 庫皆此況):地圖過去只回 triplet_count,AI 讀到 0 就誤判「這庫沒有知識」,
// 但實際上 kbdb_search 找得到內容——因為 entries 一直都在,只是沒被萃取成三元組。
const db = makeSqliteD1();
await seedTripletTemplate(db);
await ensureTripletLibrarySlot(db, 'triplet');
await createEntry(db, {
content: '人資資料 A',
entry_type: 'block',
owner_id: 'leo',
metadata_json: JSON.stringify({ library: 'hr' }),
});
await createEntry(db, {
content: '人資資料 B',
entry_type: 'block',
owner_id: 'leo',
metadata_json: JSON.stringify({ library: 'hr' }),
});
const { app, env } = makeApp(db);
// 單庫詳圖:triplet_count 仍是 0(沒騙這件事),但 entry_count 誠實回 2。
const detailRes = await app.request('/map/hr?owner_id=leo', {}, env);
const detailBody = (await detailRes.json()) as { map: { triplet_count: number; entry_count: number } };
expect(detailBody.map.triplet_count).toBe(0);
expect(detailBody.map.entry_count).toBe(2);
// 全館視圖:同一個庫、同一組數字,兩個 consumerGET /map、GET /map/:library)不能對不上。
const listRes = await app.request('/map?owner_id=leo', {}, env);
const listBody = (await listRes.json()) as { libraries: { library: string; triplet_count: number; entry_count: number }[] };
const hr = listBody.libraries.find((l) => l.library === 'hr');
expect(hr).toBeDefined();
expect(hr!.triplet_count).toBe(0);
expect(hr!.entry_count).toBe(2);
});
it('entry_count 的計數口徑排除 value 型 entries 與地圖自己的歷史摘要 block(不自我膨脹)', async () => {
// record-crud 建 triplet record 時,每個 slot 值都會落一筆 entry_type='value' 的 entry
// (儲存實作細節,不是「一筆知識」);地圖 recompute 也會建 entry_type='block' 且
// metadata.kind='library_map' 的摘要 entry——兩者都不該被算進 entry_count,否則地圖會把
// 自己的內部管線雜訊當成「使用者知識」回報,數字沒有意義。
const db = makeSqliteD1();
await seedTripletTemplate(db);
await ensureTripletLibrarySlot(db, 'triplet');
// 建一條 kb 三元組 → 連帶產生數筆 entry_type='value' 的 entries(不該被算進 entry_count)。
await seedTriplet(db, { s: 'A', p: '連結至', o: 'B', library: 'kb' });
// 觸發一次 recompute → 產生一筆 entry_type='block'/metadata.kind='library_map' 的摘要 entry
// (同樣不該被算進 entry_count)。
await recomputeLibraryMap(db, { library: 'kb' });
const detail = await getLibraryMapDetail(db, 'kb');
// 這個情境下 kb 沒有任何「真的 ingest 進來的原始內容」entry(只有 value 碎片與地圖自己的摘要),
// entry_count 應誠實回 0——不能因為底層 entries 表其實有好幾筆就報出一個誤導的非零數字。
expect(detail!.entry_count).toBe(0);
expect(detail!.triplet_count).toBe(1); // 對照:triplet_count 不受這個排除規則影響,維持原樣。
});
it('owner 隔離:即時新鮮度層不會把別的 owner 的三元組算進來', async () => {
const db = makeSqliteD1();
await seedTripletTemplate(db);
+22 -3
View File
@@ -27,6 +27,10 @@ export interface LibraryMapRow {
narrative: string | null;
top_entities: unknown; // 正常是 string[];防禦:舊部署可能回 JSON 字串形
triplet_count: number | string;
// Arcrun#87 三次收尾(2026-08-13,「藏書地圖說實話」):triplet_count=0 不等於這庫沒有知識——
// 可能只是三元組萃取從沒對它跑過,但 entries(原始 ingest 內容)還在,kbdb_search 找得到。
// 選填是因為舊部署(未帶這次修法)的 kbdb base 回應裡不會有這個欄位,容錯當 0。
entry_count?: number | string;
updated_at?: number;
}
@@ -65,7 +69,15 @@ export function entityNames(raw: unknown, limit: number): string[] {
const MAX_LIBRARY_LINES = 30;
const MAX_NARRATIVE_CHARS = 60;
/** 把 GET /map 的 libraries[] 渲染成緊湊文字(每庫一行,design §4 指定格式)。空清單回 null。 */
/**
* GET /map libraries[] design §4 null
*
* Arcrun#87 2026-08-13 session MCP
* triplet_countkb 0 triplets
* session 7 entries ingest
* kbdb_search triplet_count0
* entry_count0
*/
export function renderLibraryMapLines(libraries: LibraryMapRow[]): string | null {
const rows = libraries.filter((l) => l && typeof l.library === "string" && l.library);
if (rows.length === 0) return null;
@@ -74,8 +86,15 @@ export function renderLibraryMapLines(libraries: LibraryMapRow[]): string | null
const clipped =
narrative.length > MAX_NARRATIVE_CHARS ? `${narrative.slice(0, MAX_NARRATIVE_CHARS)}` : narrative;
const core = entityNames(l.top_entities, 3);
const count = Number(l.triplet_count ?? 0) || 0;
return `- ${l.library}${clipped}|核心:${core.length ? core.join("、") : "(尚無)"}${count} triplets`;
const tripletCount = Number(l.triplet_count ?? 0) || 0;
const entryCount = Number(l.entry_count ?? 0) || 0;
const countPart =
tripletCount > 0
? `${tripletCount} triplets`
: entryCount > 0
? `0 triplets${entryCount} 筆原始內容(尚未萃取關係,kbdb_search 查得到)`
: `0 triplets0 內容`;
return `- ${l.library}${clipped}|核心:${core.length ? core.join("、") : "(尚無)"}${countPart}`;
});
const omitted = rows.length > MAX_LIBRARY_LINES ? `\n(其餘 ${rows.length - MAX_LIBRARY_LINES} 庫略,kbdb_get_map 可看全部)` : "";
return lines.join("\n") + omitted;
+8 -94
View File
@@ -5,86 +5,20 @@ import { buildLibraryMapInstructions } from "./lib/library-map.js";
import type { KnowledgeIdentity } from "./lib/portal-client.js";
import { Env } from "./types.js";
/**
* ****2026-08-13leo
*
* leo ******** Arcrun
* Memory
*
* ****
* `kbdb_search(q="Arcrun 是什麼")` 33
* MCP session 682
* ****instructions
* ****1500ms null portal
* ** AI **
* Arcrun KBDB
*/
const KNOWLEDGE_FIRST = [
"【這條連線上有主人的知識庫——先查它,再查別的】",
"",
"這條 MCP 連線後面接著一個 **KBDB 知識庫**:這台實例的主人長期累積的筆記、決策、",
"踩過的坑、專案現況、skill 與工作流紀錄,都在裡面。**你不是從零開始的**——",
"你對這些專案的內建印象多半是錯的或過時的,庫裡那份才是主人認的版本。",
"",
"🔴 **有人問你「X 是什麼/為什麼這樣做/之前怎麼決定的/現在做到哪」——",
"你的第一個動作是 `kbdb_search`,不是 grep 原始碼、不是上網搜、不是回答「我不知道」。**",
"",
'- `kbdb_search({ q: "Arcrun 是什麼" })` — 關鍵字查(預設 `mode:\'keyword\'`,基本盤永遠可用)。',
" 換幾組講法再放棄;想要語義相似度用 `mode:'semantic'`。**這一支是你的第一站。**",
"- `kbdb_get_map()` — 不知道該進哪個庫時先看藏書地圖(下面若有【藏書地圖】就是它的快照)。",
'- `kbdb_graph_neighbors({ subject: "Arcrun" })` — 查某個東西跟誰有關係(三元組遍歷)。',
"- `kbdb_list_templates` / `kbdb_query` — 按 template 取整批結構化資料。",
"",
"🔴 **這三件事不可以講成同一句**(講成同一句就是在騙人):",
"① 「知識庫裡沒有」 ② 「我沒查」 ③ 「地圖沒取到/某庫顯示 0」。",
"查過真的沒有 → 明說「知識庫裡查不到,以下是我從原始碼/網路推的」,再去讀 code 或上網。",
"**沒查就回答=拿你的猜測冒充主人的知識,那是這條連線上最嚴重的錯。**",
"",
"🔴 **地圖是索引,不是庫存清單**:某庫顯示 `0 triplets`、或下面整段【藏書地圖】沒出現,",
"都**不代表**沒有知識(可能只是還沒重算、或這次沒抓到)。要知道有沒有,只有一個方法:`kbdb_search` 查過。",
"同理,任何工具回 401/連不上/沒權限,那是**讀不到**,不是**不存在**——照它給的 next_actions 修,",
"別把它改口講成「這裡沒有」(`arcrun_get_skill` 曾把 KBDB 的 401 講成「skill 不存在」,就是這個病)。",
].join("\n");
/** 地圖沒拿到時的**明講**(不可靜默):「沒取到」和「這裡沒有知識」不可以長得一樣。 */
const MAP_UNAVAILABLE_NOTE = [
"【藏書地圖:這次沒取到】",
"地圖抓取逾時/回錯/或它回報的清單是空的(也可能只是還沒重算過)。",
"🔴 **這是「地圖沒拿到」,不是「這裡沒有知識」。** 上面那條規則照舊:",
"要知道庫裡有什麼,直接 `kbdb_search`;想再抓一次地圖呼叫 `kbdb_get_map()`(它會回報真正的原因)。",
].join("\n");
/** 舊 tokenstale):拿不到地圖是**身分問題**,同樣要明講,並給可執行的修法。 */
const MAP_STALE_NOTE = [
"【藏書地圖:拿不到,因為這條連線是舊版簽發的 token】",
"這條連線的 token 沒帶登入者身分,`kbdb_*` 會回 `identity_missing`。",
"🔴 **這不代表知識庫是空的**——是這條連線還沒認得你。",
"仍然先呼叫一次 `kbdb_search` 確認錯誤碼;若真的是 `identity_missing`",
"請使用者到 claude.ai → Settings → Connectors 把這個 connector 重新連線一次(重新輸入 Portal 帳密),",
"**不要改口說「查不到資料」或自己去猜答案。**",
].join("\n");
/**
* server instructions`initialize` **AI **
*
* token
* AI
*/
export async function buildServerInstructions(
export async function handleMcpRequest(
request: Request,
env: Env,
orgNamespace: string,
partnerToken: string,
identity: KnowledgeIdentity,
): Promise<string> {
): Promise<Response> {
// library-map SDD M4design §4/§6):連線時把全館藏書地圖嵌進 server instructions
// session 一開就知道館裡有哪些庫(push 零查詢)。builder 內建 timeoutisolate TTL 快取
//(選型理由見 lib/library-map.ts 檔頭);任何失敗回 null → 絕不擋 MCP 連線(鐵律)。
//(選型理由見 lib/library-map.ts 檔頭);任何失敗回 null → 靜默略過,絕不擋 MCP 連線(鐵律)。
//
// 🔴 2026-08-12:以帳密連線時**改用登入者的身分**組地圖——否則 instructions 會把
// 整個知識庫的庫名一次推給一個可能只有部分權限的帳號(地圖本身就是情報)。
// 快取也因此改成 per-session key(見 lib/library-map.ts)。
//
// 🔴 2026-08-13:失敗**不再靜默略過**。原本 null → 整段消失,於是「地圖沒取到」與
// 「這裡沒有知識」在 AI 眼裡長得一模一樣(Arcrun#109 同一族:保險拒絕了 vs 程式碼不存在,
// 畫面上都是沉默)。現在改成印一句明話。鐵律沒變——**失敗仍然不擋連線**,只是不再無聲。
const mapInstructions = await buildLibraryMapInstructions(env, identity);
// 2026-07-30leo 問「人類說『幫我用 arcrun 寫 xxx』,Haiku 會知道要用這些資源嗎?
@@ -105,13 +39,9 @@ export async function buildServerInstructions(
"",
"1. `arcrun_get_skill('write_intent_workflow')` — **必讀第一支**。",
" 教你用 `>>` 寫「意圖工作流」。你**不需要先知道有哪些零件**,先寫意圖。",
" ⚠️ 這支若回錯(401/連不上/`kbdb_unreachable`),那是**這條連線讀不到 KBDB**",
" **不是 skill 不存在**——同一份內容用 `kbdb_search({ q: 'skill-write_intent_workflow' })` 撈得到。",
"2. `arcrun_whoami()` — 確認連到哪個帳號(勿自行 curl 猜帳號 URL)。",
"3. 把意圖丟 `POST /cypher/search` 或 `arcrun_validate_yaml` — 系統告訴你哪些零件存在。",
"4. 卡住/不知道該查什麼 → `arcrun_list_skills()` **看這台實例真的有哪幾支**,再挑一支讀。",
" 2026-08-13 實測:不同實例 seed 的 skill 不一樣,有的實例只有兩支、連 `INDEX` 都沒有。",
" **不要照教材直接指名一個 slug** ——先列清單,或 `kbdb_search({ q: 'skill' })` 直接在庫裡找。)",
"4. 卡住/不知道該查什麼 → `arcrun_get_skill('INDEX')`(全館導航:什麼問題查哪裡+已知的坑)。",
"5. 缺零件時:缺 API → 寫 recipe`arcrun_recipe_push`);缺能力 → 投稿零件 PR。",
" 🔴 **不要因為查不到零件就改寫成 `code` 節點**——那叫「腹語術」(表面用 Arcrun、",
" 實際全寫 JS)。`code` 只用於局部整形(例:剝掉 LLM 回應的雜訊)。",
@@ -128,23 +58,7 @@ export async function buildServerInstructions(
"第一個節點固定是 `input`。",
].join("\n");
// 地圖那段永遠有東西可印:拿到 → 印地圖;沒拿到 → 印「沒拿到」,不是消失。
// stale 與「抓失敗」分開講,因為修法不同(前者要使用者重新連線,後者只是這次沒抓到)。
const mapSection =
mapInstructions ?? (identity.kind === "stale" ? MAP_STALE_NOTE : MAP_UNAVAILABLE_NOTE);
// 知識段排在 Arcrun 指路段之前:AI 最常被問的是「X 是什麼」,那題的正解是查庫,不是查零件。
return [KNOWLEDGE_FIRST, startHere, mapSection].join("\n\n---\n\n");
}
export async function handleMcpRequest(
request: Request,
env: Env,
orgNamespace: string,
partnerToken: string,
identity: KnowledgeIdentity,
): Promise<Response> {
const instructions = await buildServerInstructions(env, identity);
const instructions = mapInstructions ? `${startHere}\n\n---\n\n${mapInstructions}` : startHere;
const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined });
const server = new McpServer(
+41 -126
View File
@@ -30,79 +30,6 @@ import { z } from "zod";
import type { Env } from "../types.js";
import { kbdbFetch } from "../lib/kbdb-client.js";
import { errorResponse, successResponse } from "../lib/cypher-client.js";
import { staleIdentityError, type KnowledgeIdentity } from "../lib/portal-client.js";
/**
* 🔴 2026-08-13****Arcrun#100#109
*
* leo21cportal-login
* `arcrun_get_skill('write_intent_workflow')` skill "write_intent_workflow" ****
* `kbdb_search` `page_name: "skill-write_intent_workflow"`
* `entry_type: "agent-skill"``source: "installer-seed"`****
* `kbdbGetByPageName` `if (!resp.ok) return null;`
* ** HTTP 401 **
*
* 401 code code `kbdb-client.ts`
* `env.KBDB_INTERNAL_TOKEN` Authorization KBDB 401
* `arcrun-mcp` token secret
* ** token leo code **
*
* instructions AI skill
* skill grep repo**AI **
*
* 2xx `KbdbAccessError` status `kbdbFailure()`
* 401/4035xx****`kbdb_search` AI
* `not_found` KBDB
*/
class KbdbAccessError extends Error {
constructor(
readonly status: number,
readonly what: string,
readonly detail?: string,
) {
super(`KBDB ${what} HTTP ${status}`);
}
}
/**
* KBDB
*
* `searchHint` registry skill/example
* KBDB entries `kbdb_search` **** portal
* 401 2026-08-13
*/
function kbdbFailure(e: unknown, searchHint: string, identity: KnowledgeIdentity) {
const portalNote =
identity.kind === "portal"
? "你這條是帳密登入的連線,但 skill/example 這批工具目前仍走**服務內部憑據**(還沒接上登入身分)——" +
"所以它讀不到,不代表你的帳號讀不到。"
: "";
if (e instanceof KbdbAccessError) {
const unauthorized = e.status === 401 || e.status === 403;
return errorResponse(
unauthorized ? "kbdb_unauthorized" : "kbdb_unreachable",
(unauthorized
? `讀不到 KBDBHTTP ${e.status}:這條連線的憑據被拒或根本沒帶)。`
: `讀不到 KBDBHTTP ${e.status})。`) +
"🔴 **這是「讀不到」,不是「不存在」**——內容還在庫裡,只是這條路被擋住了。" +
(portalNote ? ` ${portalNote}` : ""),
[
searchHint,
"kbdb_get_map() 看這台實例有哪些庫(那條走得通就更確定是這批工具的路壞了,不是庫空了)",
"🔴 不准把這個錯誤回報成「找不到/沒有這個 skill」——請照實說「KBDB 這條路讀不到」",
"持續失敗:告訴 leo 這台實例的 arcrun-mcp 少了 secret KBDB_INTERNAL_TOKEN",
],
e.detail,
);
}
return errorResponse(
"kbdb_unreachable",
`讀不到 KBDB${e instanceof Error ? e.message : String(e)}` +
"🔴 **這是「讀不到」,不是「不存在」**。" +
(portalNote ? ` ${portalNote}` : ""),
[searchHint, "稍後重試", "🔴 不准把它回報成「沒有這個 skillexample」"],
);
}
// 基本盤 entries row(與舊 v3 block 欄位 1:1,差別只在 type→entry_type
interface KbdbBlock {
@@ -118,25 +45,14 @@ interface KbdbBlock {
async function kbdbList(env: Env, entryType: string, limit = 100): Promise<KbdbBlock[]> {
const resp = await kbdbFetch(env, `/entries?entry_type=${encodeURIComponent(entryType)}&limit=${limit}`);
if (!resp.ok) {
throw new KbdbAccessError(resp.status, `list entry_type=${entryType}`, await resp.text().catch(() => ""));
}
if (!resp.ok) throw new Error(`KBDB list entry_type=${entryType} HTTP ${resp.status}`);
const data = await resp.json<{ entries?: KbdbBlock[] }>();
return data.entries ?? [];
}
/**
* page_name
*
* 🔴 `null` **KBDB **
* 4015xx `KbdbAccessError`** return null**
* 2026-08-13
*/
async function kbdbGetByPageName(env: Env, pageName: string): Promise<KbdbBlock | null> {
const resp = await kbdbFetch(env, `/entries?page_name=${encodeURIComponent(pageName)}&limit=1`);
if (!resp.ok) {
throw new KbdbAccessError(resp.status, `get page_name=${pageName}`, await resp.text().catch(() => ""));
}
if (!resp.ok) return null;
const data = await resp.json<{ entries?: KbdbBlock[] }>();
return data.entries?.[0] ?? null;
}
@@ -151,7 +67,7 @@ function parseTags(tagsJson?: string): string[] {
}
}
export function registerListSkills(server: McpServer, env: Env, identity: KnowledgeIdentity) {
export function registerListSkills(server: McpServer, env: Env) {
server.tool(
toolName("list_skills"),
"列所有 agent-skill blocks(從 arcrun/registry/skills/ 同步進 KBDB)。每個 skill 是個 markdown playbook,描述 AI 面對 X 問題該怎麼想 + 該用哪個 example。回 [{slug, title, tags}]。call get_skill(slug) 拿完整內文。",
@@ -159,7 +75,6 @@ export function registerListSkills(server: McpServer, env: Env, identity: Knowle
tag: z.string().optional().describe("optional 標籤過濾。如 'rag' / 'watcher' / 'debug'"),
},
async ({ tag }) => {
if (identity.kind === "stale") return staleIdentityError();
try {
const blocks = await kbdbList(env, "agent-skill", 100);
const skills = blocks
@@ -186,19 +101,20 @@ export function registerListSkills(server: McpServer, env: Env, identity: Knowle
skills.length === 0
? "沒有 skill 命中。試 list_skills() 不帶 tag 看全部"
: "call arcrun_get_skill(slug) 拿單個 skill 完整 markdown",
// 誠實:這裡回的是**這台實例被 seed 進去的那幾支**,不是「全世界的 skill 目錄」。
// 上面清單沒有的名字(例如 'INDEX')就是這台沒有——別照舊教材去猜一個 slug。
"🔴 只用上面清單裡真的有的 slug;清單沒有=這台實例沒 seed 進去,不要硬猜名字",
],
);
} catch (e) {
return kbdbFailure(e, "改用 kbdb_search({ q: 'skill' }) 直接在知識庫裡找 skill 卡片(那條路走的是另一組憑據)", identity);
return errorResponse(
"fetch_failed",
e instanceof Error ? e.message : String(e),
["稍後重試", "若持續失敗,告訴 leo"],
);
}
},
);
}
export function registerGetSkill(server: McpServer, env: Env, identity: KnowledgeIdentity) {
export function registerGetSkill(server: McpServer, env: Env) {
server.tool(
toolName("get_skill"),
"拿單一 agent-skill 完整 markdown playbook。slug 從 list_skills 取得。",
@@ -206,19 +122,15 @@ export function registerGetSkill(server: McpServer, env: Env, identity: Knowledg
slug: z.string().describe("skill slug,例如 'build_watcher_workflow' / 'rag_with_arcrun'"),
},
async ({ slug }) => {
if (identity.kind === "stale") return staleIdentityError();
try {
const pageName = slug.startsWith("skill-") ? slug : `skill-${slug}`;
const block = await kbdbGetByPageName(env, pageName);
if (!block) {
// 走到這裡=KBDB **有正常回答**,而它說沒有這張卡(讀不到的情況上面已經拋出去了)。
return errorResponse(
"not_found",
`KBDB 正常回應,但沒有 page_name="${pageName}" 這張卡——這台實例沒有 seed 這支 skill。` +
"(不同實例 seed 的 skill 不一樣,別照舊教材假設某個名字一定在。)",
`skill "${slug}" 不存在`,
[
"call arcrun_list_skills() 看**這台實例真的有**哪幾支",
`kbdb_search({ q: '${slug}' }) 看內容是不是被存成別的名字`,
"call arcrun_list_skills() 看可用 slug",
"確認拼字正確(不需要 'skill-' prefix",
],
);
@@ -230,17 +142,17 @@ export function registerGetSkill(server: McpServer, env: Env, identity: Knowledg
tags: parseTags(block.tags_json),
});
} catch (e) {
return kbdbFailure(
e,
`改用 kbdb_search({ q: 'skill-${slug}' }) 撈同一張卡片(skill 就住在知識庫的 entries 裡,那條路走另一組憑據)`,
identity,
return errorResponse(
"fetch_failed",
e instanceof Error ? e.message : String(e),
["稍後重試"],
);
}
},
);
}
export function registerListExamples(server: McpServer, env: Env, identity: KnowledgeIdentity) {
export function registerListExamples(server: McpServer, env: Env) {
server.tool(
toolName("list_examples"),
"列所有 workflow-example blocks(從 arcrun/registry/examples/ 同步進 KBDB)。每個 example 是可直接 push 的 workflow YAML 範本 + description。回 [{slug, tags}]。call get_example / search_examples 拿細節。",
@@ -248,7 +160,6 @@ export function registerListExamples(server: McpServer, env: Env, identity: Know
tag: z.string().optional().describe("optional 標籤過濾。如 'rag' / 'cron' / 'llm' / 'webhook'"),
},
async ({ tag }) => {
if (identity.kind === "stale") return staleIdentityError();
try {
const blocks = await kbdbList(env, "workflow-example", 200);
const examples = blocks
@@ -272,13 +183,17 @@ export function registerListExamples(server: McpServer, env: Env, identity: Know
],
);
} catch (e) {
return kbdbFailure(e, "改用 kbdb_search({ q: 'example' }) 直接在知識庫裡找 example 卡片(那條路走另一組憑據)", identity);
return errorResponse(
"fetch_failed",
e instanceof Error ? e.message : String(e),
["稍後重試"],
);
}
},
);
}
export function registerGetExample(server: McpServer, env: Env, identity: KnowledgeIdentity) {
export function registerGetExample(server: McpServer, env: Env) {
server.tool(
toolName("get_example"),
"拿單一 workflow-example 完整 YAML + description。slug 從 list_examples / search_examples 取得。可直接拿 YAML 改成你自己的 → push。",
@@ -286,19 +201,16 @@ export function registerGetExample(server: McpServer, env: Env, identity: Knowle
slug: z.string().describe("example slug,例如 'rag-search-answer' / 'cron-watcher'"),
},
async ({ slug }) => {
if (identity.kind === "stale") return staleIdentityError();
try {
const pageName = slug.startsWith("example-") ? slug : `example-${slug}`;
const block = await kbdbGetByPageName(env, pageName);
if (!block) {
// KBDB 好好回答了,而它說沒有這張卡(讀不到的情況已在 kbdbGetByPageName 拋出)。
return errorResponse(
"not_found",
`KBDB 正常回應,但沒有 page_name="${pageName}" 這張卡——這台實例沒 seed 這個 example。`,
`example "${slug}" 不存在`,
[
"call arcrun_list_examples() 看**這台實例真的有**哪些 slug",
"或 arcrun_search_examples(use_case) 用關鍵字找",
`kbdb_search({ q: '${slug}' }) 看內容是不是被存成別的名字`,
"call arcrun_list_examples() 看可用 slug",
"或 arcrun_search_examples(use_case) 用自然語言找",
],
);
}
@@ -320,17 +232,17 @@ export function registerGetExample(server: McpServer, env: Env, identity: Knowle
"看 description_md 了解設計意圖 / 改造方向",
]);
} catch (e) {
return kbdbFailure(
e,
`改用 kbdb_search({ q: 'example-${slug}' }) 撈同一張卡片(example 就住在知識庫的 entries 裡)`,
identity,
return errorResponse(
"fetch_failed",
e instanceof Error ? e.message : String(e),
["稍後重試"],
);
}
},
);
}
export function registerSearchExamples(server: McpServer, env: Env, identity: KnowledgeIdentity) {
export function registerSearchExamples(server: McpServer, env: Env) {
server.tool(
toolName("search_examples"),
"用 use case 關鍵字搜 workflow examples,回最相關 N 個。" +
@@ -341,7 +253,6 @@ export function registerSearchExamples(server: McpServer, env: Env, identity: Kn
top_k: z.number().int().min(1).max(20).optional().describe("回幾個結果(預設 5"),
},
async ({ query, top_k }) => {
if (identity.kind === "stale") return staleIdentityError();
try {
const k = top_k ?? 5;
const q = query.trim();
@@ -398,16 +309,20 @@ export function registerSearchExamples(server: McpServer, env: Env, identity: Kn
],
);
} catch (e) {
return kbdbFailure(e, `改用 kbdb_search({ q: '${query.trim()}' }) 直接查知識庫(那條路走另一組憑據)`, identity);
return errorResponse(
"internal_error",
e instanceof Error ? e.message : String(e),
["重試一次"],
);
}
},
);
}
export function registerAllSkillExampleTools(server: McpServer, env: Env, identity: KnowledgeIdentity) {
registerListSkills(server, env, identity);
registerGetSkill(server, env, identity);
registerListExamples(server, env, identity);
registerGetExample(server, env, identity);
registerSearchExamples(server, env, identity);
export function registerAllSkillExampleTools(server: McpServer, env: Env) {
registerListSkills(server, env);
registerGetSkill(server, env);
registerListExamples(server, env);
registerGetExample(server, env);
registerSearchExamples(server, env);
}
+37 -3
View File
@@ -5,13 +5,20 @@
* MCP KBDB service bindingkbdbFetch GET /map/map/:library
* D1 binding #68 kbdb_graph_neighbors D17 KBDB MCP kbdb_*
*
* kbdb/src/routes/map.tsM2 merge
* kbdb/src/routes/map.tsM2 mergeentry_count Arcrun#87
* GET /map { success, libraries:[{library, narrative, top_entities( top3),
* triplet_count, updated_at}], count }
* triplet_count, entry_count, updated_at}], count }
* GET /map/:library { success, map:{record_id, library, narrative, content, top_entities,
* relation_profile, bridges, triplet_count, commit_hash, status, updated_at} }
* relation_profile, bridges, triplet_count, entry_count, commit_hash,
* status, updated_at} }
* 404 { success:false, error:'not found' } recompute
*
* entry_count vs triplet_count
* triplet_countentry_countingest /skill/block
* 0 0 triplet_count0
* entry_count0 kbdb_search triplet_count0
*
*
* slot top_entities/relation_profile/bridges JSON base parse
* live parse crash
*/
@@ -59,11 +66,23 @@ interface LibraryMapDetail {
relation_profile?: unknown;
bridges?: unknown;
triplet_count?: number | string;
// Arcrun#87 三次收尾:同 lib/library-map.ts LibraryMapRow.entry_count 的欄位(見該檔註解)。
entry_count?: number | string;
commit_hash?: string | null;
status?: string;
updated_at?: number;
}
/** triplet_count0 但 entry_count0 時給的提示(別讓「0 triplets」被讀成「這庫沒有知識」)。 */
function entryOnlyHint(tripletCount: number, entryCount: number, library?: string): string[] {
if (tripletCount > 0 || entryCount <= 0) return [];
const lib = library ? `${library}` : "這個庫";
return [
`${lib} triplet_count0,但有 ${entryCount} 筆原始內容(entries)——三元組萃取還沒對它跑過,` +
"不代表沒有知識。用 kbdb_search(關鍵字或語義)直接查得到內容。",
];
}
/**
* kbdb_get_map library
* design §6 retrieval get_map(library) graph/search
@@ -113,6 +132,7 @@ export function registerGetMap(server: McpServer, env: Env, identity: KnowledgeI
// 防禦:top_entities 若是 JSON 字串形就 parse 成名字清單(失敗當空,誠實不 crash)。
top_entities: entityNames(l.top_entities, 3),
triplet_count: Number(l.triplet_count ?? 0) || 0,
entry_count: Number(l.entry_count ?? 0) || 0,
}));
if (libraries.length === 0) {
// 空庫誠實回報:不是錯誤(端點正常)。地圖是讀時即時核對重算的(見 RECOMPUTE_HINTS
@@ -128,9 +148,21 @@ export function registerGetMap(server: McpServer, env: Env, identity: KnowledgeI
...RECOMPUTE_HINTS,
]);
}
// Arcrun#87 三次收尾:某些庫 triplet_count0 但 entry_count0entries 有、三元組
// 萃取沒跑過)——這件事只在「這種庫真的存在」時才提一次,不是每個庫都印一行(避免洗版),
// 讓讀這份地圖的 session 知道「觸目所及的 0」不能直接當成「沒有知識」。
const entryOnlyLibs = libraries.filter(
(l) => (l.triplet_count ?? 0) === 0 && (l.entry_count ?? 0) > 0,
);
return successResponse({ libraries, count: libraries.length }, [
"要看某庫細節:kbdb_get_map(library='庫名')",
"進庫查內容:kbdb_search(關鍵字/語義);查關係:kbdb_graph_neighbors",
...(entryOnlyLibs.length > 0
? [
`${entryOnlyLibs.map((l) => l.library).join("、")} 這幾庫 triplet_count0 但 entry_count0` +
"有原始內容,只是還沒萃取出三元組關係——別把 0 triplets 讀成「沒有知識」,直接 kbdb_search 進去查。",
]
: []),
]);
}
@@ -167,10 +199,12 @@ export function registerGetMap(server: McpServer, env: Env, identity: KnowledgeI
relation_profile: parseSlotArray<{ predicate: string; count: number }>(raw.relation_profile),
bridges: parseSlotArray<{ entity: string; libraries: string[] }>(raw.bridges),
triplet_count: Number(raw.triplet_count ?? 0) || 0,
entry_count: Number(raw.entry_count ?? 0) || 0,
};
return successResponse({ map }, [
"bridges=此庫 entity 同時出現在哪些其他庫(只有兩側三元組都標了 library 值才抓得到,舊資料若沒標會偏稀疏,是誠實現況不是 bug)",
"沿核心 entity 挖關係:kbdb_graph_neighbors(subject=entity 名)",
...entryOnlyHint(map.triplet_count, map.entry_count, library),
]);
} catch (e) {
return errorResponse("internal_error", e instanceof Error ? e.message : String(e), ["稍後重試"]);
+1 -5
View File
@@ -55,11 +55,7 @@ export function registerAllTools(
registerAllWorkflowCrudTools(server, env);
// LI SDD M3.2: skills + examples lookupKBDB-backed
// 走 sync-registry-to-kbdb.py 把 registry/{skills,examples} 同步進 KBDB
// 2026-08-13:吃 identity 只為了「把話講對」——舊 token 誠實回 identity_missing
// 且 401 時能告訴登入者「是這批工具還走服務憑據,不是你的帳號讀不到」。
// ⚠️ 這批**尚未**改走 portal 資料面(portal 沒有 by-entry_type 的 listing 端點;
// 要接得補 API,不是在這層拼裝——rule 07 §3.1)。見該檔檔頭。
registerAllSkillExampleTools(server, env, identity);
registerAllSkillExampleTools(server, env);
// kbdb-base §7.5.i: recipe 公庫/私庫工具(與 CLI 六能力對齊,rule 07 §5 MCP 不落後)
registerAllRecipeTools(server, env);
// kbdb-base Phase 9.1: KBDB 資料層薄殼(template/record/query/searchHANDOFF §2
-130
View File
@@ -1,130 +0,0 @@
/**
* server instructions`initialize` AI ****
*
* 2026-08-13 leo
* ********
*
* instructions ****
* 1500ms null portal session
* Arcrun 682 `kbdb_search` 33
*
*
* ** instructions **
*/
import { describe, it, expect, beforeEach } from "vitest";
import { buildServerInstructions } from "../../src/mcp-handler.js";
import { __resetLibraryMapInstructionsCacheForTests } from "../../src/lib/library-map.js";
import type { Env } from "../../src/types.js";
import type { KnowledgeIdentity } from "../../src/lib/portal-client.js";
const SERVICE: KnowledgeIdentity = { kind: "service" };
const STALE: KnowledgeIdentity = { kind: "stale" };
/** 假 KBDB service binding(服務級憑據路徑)。 */
function makeEnv(respond: () => Response | Promise<Response>): Env {
return { KBDB: { fetch: async () => respond() } } as unknown as Env;
}
const MAP_OK = () =>
new Response(
JSON.stringify({
success: true,
libraries: [
{ library: "kb", narrative: "leo 的知識庫主庫", top_entities: ["Arcrun"], triplet_count: 1854 },
],
count: 1,
}),
);
/**
* AI
* instructions
*/
function expectPointsAtKnowledge(text: string) {
// ① 明講這條連線後面有主人的知識庫
expect(text).toContain("知識庫");
// ② 指名工具與呼叫法(AI 不必猜工具名)
expect(text).toContain("kbdb_search");
expect(text).toContain("Arcrun 是什麼");
// ③ 明確排除三條錯路
expect(text).toContain("不是 grep 原始碼");
expect(text).toContain("不是上網搜");
expect(text).toContain("不是回答「我不知道」");
// ④ 「沒查到」與「沒有」不可以混為一談
expect(text).toContain("那是**讀不到**,不是**不存在**");
}
describe("buildServerInstructions — 三種情境都必須指向知識庫", () => {
beforeEach(() => __resetLibraryMapInstructionsCacheForTests());
it("① 地圖抓得到:地圖照舊注入,且知識段仍在", async () => {
const text = await buildServerInstructions(makeEnv(MAP_OK), SERVICE);
expectPointsAtKnowledge(text);
expect(text).toContain("【藏書地圖】");
expect(text).toContain("kbleo 的知識庫主庫");
// 地圖成功時不該同時出現「沒取到」的話
expect(text).not.toContain("【藏書地圖:這次沒取到】");
});
it("② 地圖抓不到(HTTP 500):明講「沒取到」,不靜默、也不等於沒有知識", async () => {
const text = await buildServerInstructions(makeEnv(() => new Response("boom", { status: 500 })), SERVICE);
expectPointsAtKnowledge(text);
expect(text).toContain("【藏書地圖:這次沒取到】");
expect(text).toContain("這是「地圖沒拿到」,不是「這裡沒有知識」");
});
it("② 地圖逾時/binding 爆炸:同樣明講,不擋連線(不 throw)", async () => {
const env = {
KBDB: {
fetch: async () => {
throw new Error("kbdb down");
},
},
} as unknown as Env;
const text = await buildServerInstructions(env, SERVICE);
expectPointsAtKnowledge(text);
expect(text).toContain("【藏書地圖:這次沒取到】");
});
it("② 地圖回空清單:也算沒取到,不可以長得像「這裡沒有知識」", async () => {
const text = await buildServerInstructions(
makeEnv(() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 }))),
SERVICE,
);
expect(text).toContain("【藏書地圖:這次沒取到】");
expect(text).toContain("不是「這裡沒有知識」");
});
it("③ 舊 token(stale):講成身分問題+給可執行的修法,仍指向知識庫", async () => {
const text = await buildServerInstructions(makeEnv(MAP_OK), STALE);
expectPointsAtKnowledge(text);
expect(text).toContain("舊版簽發的 token");
expect(text).toContain("identity_missing");
expect(text).toContain("重新連線");
// 🔴 不可以講成「知識庫是空的」
expect(text).toContain("這不代表知識庫是空的");
});
it("stale 不去打 KBDB(地圖本身就是情報,舊 token 不給)", async () => {
let called = 0;
const env = {
KBDB: {
fetch: async () => {
called += 1;
return MAP_OK();
},
},
} as unknown as Env;
await buildServerInstructions(env, STALE);
expect(called).toBe(0);
});
it("Arcrun 指路段照舊存在(知識段是新增的,不是取代)", async () => {
const text = await buildServerInstructions(makeEnv(MAP_OK), SERVICE);
expect(text).toContain("# Arcrun — 你已經配備了這套工具,別上網找");
expect(text).toContain("【先讀這裡】");
// 步驟 4 不再指名一個可能沒被 seed 的 slugleo21c 實測連 INDEX 都沒有)
expect(text).toContain("arcrun_list_skills()");
expect(text).not.toContain("arcrun_get_skill('INDEX')");
});
});
+96
View File
@@ -121,6 +121,46 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
expect(calls[0].url.searchParams.get("owner_id")).toBe("leo");
});
it("Arcrun#87 三次收尾:triplet_count=0 但 entry_count>0 的庫附上「別當成沒有知識」的提示", async () => {
// leo21c 實測現況:kb 以外幾乎每庫都長這樣——entries 有(且 library 標對),三元組萃取沒跑過。
const emptyTripletRow = {
library: "arcrun",
narrative: null,
top_entities: [],
triplet_count: 0,
entry_count: 15,
updated_at: 1786330534,
};
const { server, tools } = makeServer();
const { env } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [emptyTripletRow], count: 1 })),
);
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({});
const body = parseResult(res);
const data = body.data as { libraries: { library: string; triplet_count: number; entry_count: number }[] };
expect(data.libraries[0].entry_count).toBe(15);
expect(data.libraries[0].triplet_count).toBe(0);
// 提示要點名該庫、講清楚「有內容只是沒萃取」,別讓 AI 讀到 0 triplets 就跳過這個庫。
const hints = (body.hints as string[]).join(" ");
expect(hints).toContain("arcrun");
expect(hints).toContain("entry_count");
expect(hints).toContain("kbdb_search");
});
it("entry_count 缺席(舊部署未帶此欄位)→ 容錯當 0,不 crash、不誤發提示", async () => {
const legacyRow = { ...KB_ROW }; // KB_ROW 本身沒有 entry_count 欄位(模擬舊部署回應)
const { server, tools } = makeServer();
const { env } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [legacyRow], count: 1 })),
);
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({});
const body = parseResult(res);
const data = body.data as { libraries: { entry_count: number }[] };
expect(data.libraries[0].entry_count).toBe(0);
});
it("top_entities in JSON-string form is parsed defensively (live-observed slot shape)", async () => {
const { server, tools } = makeServer();
const row = {
@@ -213,6 +253,39 @@ describe("kbdb_get_map: 單庫詳圖(library 參數)", () => {
expect(map.bridges).toEqual([{ entity: "Gitea", libraries: ["notes"] }]);
});
it("Arcrun#87 三次收尾:單庫詳圖 triplet_count=0、entry_count>0 → 附「別當空庫」提示", async () => {
const emptyTripletDetail = {
...DETAIL,
library: "arcrun",
triplet_count: 0,
entry_count: 15,
top_entities: [],
relation_profile: [],
bridges: [],
};
const { server, tools } = makeServer();
const { env } = makeEnv(() => new Response(JSON.stringify({ success: true, map: emptyTripletDetail })));
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({ library: "arcrun" });
const body = parseResult(res);
const map = (body.data as { map: { entry_count: number; triplet_count: number } }).map;
expect(map.triplet_count).toBe(0);
expect(map.entry_count).toBe(15);
const hints = (body.hints as string[]).join(" ");
expect(hints).toContain("arcrun");
expect(hints).toContain("kbdb_search");
});
it("triplet_count>0 的庫不附「別當空庫」提示(不需要時別洗版)", async () => {
const { server, tools } = makeServer();
const { env } = makeEnv(() => new Response(JSON.stringify({ success: true, map: DETAIL }))); // DETAIL.triplet_count=111
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" });
const body = parseResult(res);
const hints = (body.hints as string[]).join(" ");
expect(hints).not.toContain("entry_count");
});
it("JSON-string slot values are parsed into objects(任務規格); broken JSON → 空陣列", async () => {
const { server, tools } = makeServer();
const raw = {
@@ -343,6 +416,29 @@ describe("renderLibraryMapLines", () => {
it("empty input → null", () => {
expect(renderLibraryMapLines([])).toBeNull();
});
it("Arcrun#87 三次收尾:triplet_count=0 但 entry_count>0 → 開場那行不再讀成「這庫沒有知識」", () => {
// 這是 leo21c 的實際現況(kb 以外 7 庫皆此況)——這段渲染出的文字是全新 session 連上 MCP
// 第一眼看到的地圖,過去只印「0 triplets」,讀起來像空庫,session 因此跳過不查。
const text = renderLibraryMapLines([
{ library: "arcrun", narrative: null, top_entities: [], triplet_count: 0, entry_count: 15 },
]);
expect(text).toBe("- arcrun:(narrative 待補)|核心:(尚無)|0 triplets/15 筆原始內容(尚未萃取關係,kbdb_search 查得到)");
});
it("triplet_count=0 且 entry_count=0(真的沒有任何內容)→ 誠實講兩個都是 0", () => {
const text = renderLibraryMapLines([
{ library: "empty-lib", narrative: null, top_entities: [], triplet_count: 0, entry_count: 0 },
]);
expect(text).toBe("- empty-lib:(narrative 待補)|核心:(尚無)|0 triplets0 內容");
});
it("entry_count 缺席(舊部署未帶欄位)→ 容錯當 0,維持既有 triplet-only 措辭不 crash", () => {
const text = renderLibraryMapLines([
{ library: "kb", narrative: "摘要", top_entities: ["A"], triplet_count: 5 },
]);
expect(text).toBe("- kb:摘要|核心:A5 triplets");
});
});
// ── 2026-08-12:地圖也要跟著登入者的權限走 ────────────────────────────────────
@@ -1,186 +0,0 @@
/**
* skillexample ****2026-08-13Arcrun#100#109
*
* `arcrun_get_skill('write_intent_workflow')` skill ... ****
* `kbdb_search` `page_name: "skill-write_intent_workflow"`
*`entry_type: agent-skill``source: installer-seed`****
* `if (!resp.ok) return null;` HTTP 401
*
* instructions AI skill
* skill grep repo
*
* **401/403/5xx/KBDB **
* `kbdb_search`
*/
import { describe, it, expect } from "vitest";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { Env } from "../../../src/types.js";
import {
registerGetSkill,
registerListSkills,
registerGetExample,
} from "../../../src/tools/arcrun_skills_examples.js";
import type { KnowledgeIdentity } from "../../../src/lib/portal-client.js";
const SERVICE: KnowledgeIdentity = { kind: "service" };
const PORTAL: KnowledgeIdentity = {
kind: "portal",
portal: { session: "sess-abc", display_name: "Leo", role: "admin", libraries: ["*"] },
};
const STALE: KnowledgeIdentity = { kind: "stale" };
type ToolHandler = (args: Record<string, unknown>) => Promise<{
content: { type: string; text: string }[];
isError?: boolean;
}>;
function fakeServer() {
const tools = new Map<string, ToolHandler>();
const server = {
tool: (name: string, _desc: string, _schema: unknown, handler: ToolHandler) => {
tools.set(name, handler);
},
} as unknown as McpServer;
return { server, tools };
}
function makeEnv(respond: (url: string) => Response): Env {
return {
KBDB_INTERNAL_TOKEN: "",
KBDB: { fetch: async (url: string) => respond(url) },
} as unknown as Env;
}
const parse = (res: { content: { text: string }[] }) => JSON.parse(res.content[0].text);
describe("arcrun_get_skill — 401 不准講成「不存在」", () => {
it("KBDB 回 401 → kbdb_unauthorized,訊息明說「讀不到不是不存在」並給 kbdb_search 這條路", async () => {
const { server, tools } = fakeServer();
registerGetSkill(server, makeEnv(() => new Response("unauthorized", { status: 401 })), SERVICE);
const res = await tools.get("arcrun_get_skill")!({ slug: "write_intent_workflow" });
expect(res.isError).toBe(true);
const body = parse(res);
expect(body.error_code).toBe("kbdb_unauthorized");
// 🔴 這句是本次事故的核心:不可以再出現舊版那句「skill "x" 不存在」的結論
expect(body.human_message).not.toMatch(/skill "[^"]*" 不存在/);
expect(body.human_message).toContain("這是「讀不到」,不是「不存在」");
expect(body.human_message).toContain("讀不到");
expect(body.human_message).toContain("HTTP 401");
// 還走得通的那條路要交到 AI 手上(實測 kbdb_search 撈得到同一張卡)
expect(body.next_actions.join("\n")).toContain("kbdb_search({ q: 'skill-write_intent_workflow' })");
// 並明白禁止它改口
expect(body.next_actions.join("\n")).toContain("不准把這個錯誤回報成");
});
it("以帳密登入的連線收到 401 → additionally 說清楚「不是你的帳號讀不到」", async () => {
const { server, tools } = fakeServer();
registerGetSkill(server, makeEnv(() => new Response("unauthorized", { status: 401 })), PORTAL);
const body = parse(await tools.get("arcrun_get_skill")!({ slug: "write_recipe" }));
expect(body.human_message).toContain("服務內部憑據");
expect(body.human_message).toContain("不代表你的帳號讀不到");
});
it("KBDB 5xx → kbdb_unreachable(連不上,也不是不存在)", async () => {
const { server, tools } = fakeServer();
registerGetSkill(server, makeEnv(() => new Response("boom", { status: 503 })), SERVICE);
const body = parse(await tools.get("arcrun_get_skill")!({ slug: "whatever" }));
expect(body.error_code).toBe("kbdb_unreachable");
expect(body.human_message).toContain("HTTP 503");
});
it("binding 直接爆炸 → 也回 kbdb_unreachable,不吞成 not_found", async () => {
const { server, tools } = fakeServer();
const env = {
KBDB: {
fetch: async () => {
throw new Error("kbdb down");
},
},
} as unknown as Env;
registerGetSkill(server, env, SERVICE);
const body = parse(await tools.get("arcrun_get_skill")!({ slug: "x" }));
expect(body.error_code).toBe("kbdb_unreachable");
expect(body.human_message).toContain("kbdb down");
});
it("KBDB 正常回應但真的沒這張卡 → not_found,且說明是「這台實例沒 seed」不是全世界沒有", async () => {
const { server, tools } = fakeServer();
registerGetSkill(
server,
makeEnv(() => new Response(JSON.stringify({ entries: [] }))),
SERVICE,
);
const body = parse(await tools.get("arcrun_get_skill")!({ slug: "INDEX" }));
expect(body.error_code).toBe("not_found");
expect(body.human_message).toContain("KBDB 正常回應");
expect(body.human_message).toContain("skill-INDEX");
expect(body.next_actions.join("\n")).toContain("arcrun_list_skills()");
});
it("卡片真的在 → 照舊回內容(沒改壞正路)", async () => {
const { server, tools } = fakeServer();
let seen = "";
registerGetSkill(
server,
makeEnv((url) => {
seen = url;
return new Response(
JSON.stringify({
entries: [{ id: "e1", page_name: "skill-write_intent_workflow", content: "# 意圖工作流", tags_json: '["skill:core"]' }],
}),
);
}),
SERVICE,
);
const body = parse(await tools.get("arcrun_get_skill")!({ slug: "write_intent_workflow" }));
expect(body.ok).toBe(true);
expect(body.data.content).toBe("# 意圖工作流");
expect(seen).toContain("page_name=skill-write_intent_workflow");
});
it("舊 tokenstale)→ identity_missing(誠實要求重新連線,不謊稱找不到)", async () => {
const { server, tools } = fakeServer();
registerGetSkill(server, makeEnv(() => new Response("{}")), STALE);
const body = parse(await tools.get("arcrun_get_skill")!({ slug: "x" }));
expect(body.error_code).toBe("identity_missing");
});
});
describe("arcrun_list_skills / arcrun_get_example — 同一條規則", () => {
it("list_skills 撞 401 → kbdb_unauthorized(舊版是 fetch_failed 一句技術話)", async () => {
const { server, tools } = fakeServer();
registerListSkills(server, makeEnv(() => new Response("nope", { status: 401 })), SERVICE);
const body = parse(await tools.get("arcrun_list_skills")!({}));
expect(body.error_code).toBe("kbdb_unauthorized");
expect(body.next_actions.join("\n")).toContain("kbdb_search");
});
it("list_skills 成功時提醒「只用清單裡真的有的 slug」(別再猜 INDEX", async () => {
const { server, tools } = fakeServer();
registerListSkills(
server,
makeEnv(() =>
new Response(
JSON.stringify({
entries: [{ id: "e1", page_name: "skill-write_recipe", content: "x", tags_json: "[]" }],
}),
),
),
SERVICE,
);
const body = parse(await tools.get("arcrun_list_skills")!({}));
expect(body.ok).toBe(true);
expect(body.data.count).toBe(1);
expect(body.hints.join("\n")).toContain("不要硬猜名字");
});
it("get_example 撞 401 → 同樣是讀不到,不是「example 不存在」", async () => {
const { server, tools } = fakeServer();
registerGetExample(server, makeEnv(() => new Response("nope", { status: 401 })), SERVICE);
const body = parse(await tools.get("arcrun_get_example")!({ slug: "rag-search-answer" }));
expect(body.error_code).toBe("kbdb_unauthorized");
expect(body.human_message).not.toMatch(/example "[^"]*" 不存在/);
expect(body.next_actions.join("\n")).toContain("kbdb_search({ q: 'example-rag-search-answer' })");
});
});