Compare commits

..

1 Commits

Author SHA1 Message Date
uncle6me-web c2897ba68b fix(mcp): AI 一連上就知道「這裡有主人的知識庫」+「讀不到」不再講成「不存在」
leo 2026-08-13:「它應該是**你的知識來源**⋯⋯**沒有它你是瞎的**,
從你對 Arcrun 的認知就知道你的內建 Memory 是沒用的。」

## 病灶一:指路的話被綁在一個會無聲消失的段落上

instructions 裡唯一提到「這裡有知識可查」的,是 buildLibraryMapInstructions
拼上去的【藏書地圖】——而那段是**選配的**(1500ms 逾時、catch 回 null、失敗無聲)。
必定出現的那段靜態文字(startHere)從頭到尾只講工作流、零件、recipe、邊的語法,
`kbdb_search`/`kbdb_get_map` 一個字都沒有。

實害:同一天,一條 portal 連線的 session 為了回答「Arcrun 是什麼」去讀了 682 行
原始碼——而 `kbdb_search(q="Arcrun 是什麼")` 當下回得出 33 筆真答案。

修法:
- 新增 `KNOWLEDGE_FIRST` 靜態段(與 startHere 同級,KBDB 掛了照樣出現),
  排在最前面:這條連線後面有主人的知識庫、第一個動作是 kbdb_search、
  工具怎麼呼叫、以及「沒查到/沒查/地圖沒取到」三件事不可以講成同一句。
- 地圖失敗**不再靜默**:拿不到就印【藏書地圖:這次沒取到】,
  stale token 另印身分版(要使用者重新連線)——「沒取到」與「這裡沒有知識」
  不可以長得一樣(Arcrun#109 同族)。鐵律不變:失敗仍然不擋連線。
- 抽出 `buildServerInstructions(env, identity)` 供測試印出完整字串。

## 病灶二:一個 401 被逐字翻譯成「不存在」

`arcrun_get_skill('write_intent_workflow')` 回「skill ... 不存在」,
但 `kbdb_search` 撈得到 `page_name: "skill-write_intent_workflow"`
(entry_type: agent-skill、source: installer-seed)——**與本工具查的鍵逐字相符**。
真兇是 `kbdbGetByPageName` 的 `if (!resp.ok) return null;`。
而 401 的來源是該實例的 arcrun-mcp 沒有 KBDB_INTERNAL_TOKEN
(kbdb-client 沒 token 就匿名送出)=部署面的事,但這支 code 把它講成了假話。

修法:
- 非 2xx 一律拋 KbdbAccessError(帶真 status)→ kbdb_unauthorized/kbdb_unreachable,
  訊息明說「這是讀不到,不是不存在」,並交出還走得通的那條路(kbdb_search)。
- `not_found` 只留給「KBDB 正常回應但真的沒這張卡」,並說明是「這台實例沒 seed」。
- 五支工具吃 identity:stale → identity_missing;portal 撞 401 時額外說明
  「是這批工具還走服務憑據,不是你的帳號讀不到」。
- instructions 步驟 4 不再指名 `arcrun_get_skill('INDEX')`(leo21c 實測根本沒 seed
  這支)——改成先 `arcrun_list_skills()` 看這台實例真的有哪幾支。

## 驗

- `mcp` 測試 130 passed(新增 mcp-instructions 7 項+skills-examples-honesty 10 項),tsc exit 0。
- 三種情境(地圖抓得到/抓不到/舊 token)各印一份完整 instructions 實測,
  三份的開場都指向 kbdb_search。

⚠️ 未部署(部署是 leo 的手動閘)。上線後才會到用戶手上。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 14:09:35 +08:00
10 changed files with 541 additions and 531 deletions
-39
View File
@@ -111,45 +111,6 @@ kbdbProxyRouter.get('/kbdb/records/by-template/:template', async (c) => {
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } }); return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
}); });
// POST /kbdb/records/backfill-library — 三元組版標庫補存量對外通道(Arcrun#87 二次收尾,2026-08-13)。
// 基本盤(kbdb/src/routes/records.ts)的 POST /records/backfill-library 缺對外通道——同
// PATCH /kbdb/records/:recordId 那條的破口(能力在 base,插件/工作流打不到)。純轉發,
// owner_id 強制用租戶身份(同本檔 POST /kbdb/records 的既有慣例,不信任 caller 自帶 owner_id)。
// 此路由必須在 '/:recordId' 之前註冊,否則 'backfill-library' 會被當成 recordId 參數。
kbdbProxyRouter.post('/kbdb/records/backfill-library', async (c) => {
const owner = tenant(c);
if (!owner) return c.json(NEED_KEY, 401);
const body = await c.req.json().catch(() => null);
if (!body || !body.library) return c.json({ error: 'library 必填' }, 400);
const { base, headers } = kbdbBase(c.env);
const res = await fetch(`${base}/records/backfill-library`, {
method: 'POST',
headers,
body: JSON.stringify({
library: body.library,
owner_id: owner,
triplet_template: body.triplet_template,
source_prefix: body.source_prefix,
limit: body.limit,
}),
});
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
});
// GET /kbdb/records/backfill-library/status — 待補標統計(三元組版),owner_id 同樣強制用租戶身份。
kbdbProxyRouter.get('/kbdb/records/backfill-library/status', async (c) => {
const owner = tenant(c);
if (!owner) return c.json(NEED_KEY, 401);
const { base, headers } = kbdbBase(c.env);
const params = new URLSearchParams({ owner_id: owner });
for (const k of ['triplet_template', 'source_prefix']) {
const v = c.req.query(k);
if (v) params.set(k, v);
}
const res = await fetch(`${base}/records/backfill-library/status?${params.toString()}`, { headers });
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
});
// GET /kbdb/records/:recordId — 取單筆 record。 // GET /kbdb/records/:recordId — 取單筆 record。
kbdbProxyRouter.get('/kbdb/records/:recordId', async (c) => { kbdbProxyRouter.get('/kbdb/records/:recordId', async (c) => {
if (!tenant(c)) return c.json(NEED_KEY, 401); if (!tenant(c)) return c.json(NEED_KEY, 401);
@@ -1,100 +0,0 @@
/**
* POST /kbdb/records/backfill-library + GET .../status proxy 測試(Arcrun#87 二次收尾,2026-08-13
*
* 背景:基本盤 kbdb/src/routes/records.ts 新增了三元組版的批次標庫補存量端點
* backfillTripletLibraryTags——藏書地圖讀的是三元組 record 自己的 'library' slot,跟
* entries 版 backfill-library 補的 metadata_json.library 是不同存放處)。這條 cypher proxy
* 之前完全沒轉發這兩支——同 kbdb-records-patch-proxy.test.ts 那次的破口(能力在 base,
* 插件/工作流打不到)。純轉發,owner_id 強制用租戶身份(不信任 caller 自帶 owner_id
* 同本檔既有 POST /kbdb/records 的慣例)。
*
* 驗證 IO 接線(聚合真身在 KBDB 基本盤,這裡只測轉發,比照 kbdb-records-patch-proxy.test.ts 慣例):
* 1. 租戶閘:無 X-Arcrun-API-Key → 401 不碰 KBDB
* 2. body 沒有 library → 400,不轉發
* 3. 轉發:owner_id 一律用租戶身份覆蓋(即使 caller 自帶了別的 owner_id 也被忽略)
* 4. GET statusowner_id 同樣強制用租戶身份,query 參數透傳
*
* KBDB 打 fetchMock 假 hostdisableNetConnect——測試絕不外連。
*/
import { SELF, fetchMock } from 'cloudflare:test';
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
const KEY = { 'X-Arcrun-API-Key': 'leo', 'Content-Type': 'application/json' };
beforeAll(() => {
fetchMock.activate();
fetchMock.disableNetConnect();
});
afterEach(() => fetchMock.assertNoPendingInterceptors());
describe('POST /kbdb/records/backfill-library — 租戶閘', () => {
it('無 X-Arcrun-API-Key → 401,不碰 KBDB', async () => {
const res = await SELF.fetch('http://localhost/kbdb/records/backfill-library', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ library: 'arcrun' }),
});
expect(res.status).toBe(401);
});
});
describe('POST /kbdb/records/backfill-library — 參數驗證', () => {
it('body 沒有 library → 400,不轉發', async () => {
const res = await SELF.fetch('http://localhost/kbdb/records/backfill-library', {
method: 'POST',
headers: KEY,
body: JSON.stringify({ source_prefix: 'gitea:Leo/Arcrun@' }),
});
expect(res.status).toBe(400);
});
});
describe('POST /kbdb/records/backfill-library — 轉發', () => {
it('owner_id 一律用租戶身份覆蓋,即使 caller 自帶了別的 owner_id', async () => {
fetchMock
.get('https://kbdb.test')
.intercept({
path: '/records/backfill-library',
method: 'POST',
body: JSON.stringify({
library: 'arcrun',
owner_id: 'leo', // 來自 X-Arcrun-API-Key,不是 body 裡的 'someone-else'
triplet_template: undefined,
source_prefix: 'gitea:Leo/Arcrun@',
limit: 200,
}),
})
.reply(200, { success: true, library: 'arcrun', scanned: 208, tagged: 200, remaining: 8, quota_limit: 2000, quota_used_today: 200, quota_exceeded: true });
const res = await SELF.fetch('http://localhost/kbdb/records/backfill-library', {
method: 'POST',
headers: KEY,
body: JSON.stringify({ library: 'arcrun', owner_id: 'someone-else', source_prefix: 'gitea:Leo/Arcrun@', limit: 200 }),
});
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean; tagged: number; remaining: number };
expect(data.success).toBe(true);
expect(data.tagged).toBe(200);
expect(data.remaining).toBe(8);
});
});
describe('GET /kbdb/records/backfill-library/status — 租戶閘 + 轉發', () => {
it('無 X-Arcrun-API-Key → 401', async () => {
const res = await SELF.fetch('http://localhost/kbdb/records/backfill-library/status?source_prefix=gitea:Leo/Arcrun@');
expect(res.status).toBe(401);
});
it('owner_id 強制用租戶身份,其餘 query 參數透傳', async () => {
fetchMock
.get('https://kbdb.test')
.intercept({ path: '/records/backfill-library/status?owner_id=leo&source_prefix=gitea%3ALeo%2FArcrun%40', method: 'GET' })
.reply(200, { success: true, pending: 8 });
const res = await SELF.fetch('http://localhost/kbdb/records/backfill-library/status?source_prefix=gitea:Leo/Arcrun@', {
headers: KEY,
});
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean; pending: number };
expect(data.success).toBe(true);
expect(data.pending).toBe(8);
});
});
-129
View File
@@ -29,8 +29,6 @@
// 這件事不只管向量化,也要管補標,否則做標庫時就會把補算的閘繞過去」)。 // 這件事不只管向量化,也要管補標,否則做標庫時就會把補算的閘繞過去」)。
import type { Bindings } from '../types'; import type { Bindings } from '../types';
import { maintenanceBudgetToday, addMaintenanceUsage } from './maintenance-quota'; import { maintenanceBudgetToday, addMaintenanceUsage } from './maintenance-quota';
import { updateRecord } from './record-crud';
import { ensureTripletLibrarySlot, DEFAULT_TRIPLET_TEMPLATE } from './library-map';
// IN 清單長度上限(避開 D1/SQLite bound-parameter 上限;一次點名這麼多張卡已經很夠用, // IN 清單長度上限(避開 D1/SQLite bound-parameter 上限;一次點名這麼多張卡已經很夠用,
// 呼叫端清單更長就自然分批呼叫,跟 limit 分頁是同一種節奏)。 // 呼叫端清單更長就自然分批呼叫,跟 limit 分頁是同一種節奏)。
@@ -168,130 +166,3 @@ export async function libraryBackfillStatus(
.first<{ c: number }>(); .first<{ c: number }>();
return { pending: row?.c ?? 0 }; return { pending: row?.c ?? 0 };
} }
// ── 三元組(triplet)版:藏書地圖真正讀的那一半(Arcrun#87 二次收尾,2026-08-13)──────────
//
// 上面 backfillEntryLibraryTags 補的是 entries.metadata_json.$.library(卡片/搜尋/embed 層)。
// 藏書地圖(library-map.ts 的 recomputeLibraryMapliveTripletCountsByLibrary)讀的是**三元組
// record 自己的 'library' slot**entry_values,經 record-crud 的 updateRecord 寫入)——兩者是
// 兩個互不相干的存放處(票上 2026-08-11 14:21 comment「庫值有兩個互不相干的存放處」段已釐清),
// 補了前者地圖依然是 0。
//
// 既有通道只有「單筆 PATCH /kbdb/records/:id」(b6ef0f02026-08-11)——沒有批次版本。
// 補標母體上千筆時逐筆 PATCH 不現實(也不安全:呼叫端要自己刻節流/冪等,容易漏做)。
// 本函式是三元組版的批次 backfill,安全原則與上面 entries 版逐條對齊,不重新發明:
// - 呼叫端決定 library 值+篩選條件(base 對內容語意無知,不猜哪個 source_uri 該歸哪個庫)
// - 冪等:只選「目前沒有 library slot 值」的候選(NOT EXISTS 找缺 library 的那半,同 entries
// 版用「library 為空」而非覆蓋已標記過的)
// - D69 節流:與 entries 版、embed reconcile 共用同一顆每日 D1 寫入額度計數器(不共用會被繞過)
// - owner_id 必填(2026-08-11 leo 直令:批次改一大片既有資料不准無租戶範圍地掃)
// - 寫入沿用既有 updateRecordrecord-crud.ts)——不手刻第二套 entry_values UPSERT SQL
// 這條寫入路徑已經被 triplet-library-backfill.test.ts 驗證過語意正確(源頭順序/存量補標/
// 冪等三案),本函式只是把它包成「呼叫端給 library+source_prefix,一次處理一批」的批次版。
export interface TripletLibraryBackfillCriteria {
owner_id?: string;
triplet_template?: string; // 預設 DEFAULT_TRIPLET_TEMPLATE'triplet'
source_prefix?: string; // source_uri LIKE prefix%(本票的規則:^gitea:Leo/<repo>@ → 各庫;^kb:// → kb
}
function tripletCriteriaSql(c: TripletLibraryBackfillCriteria & { owner_id: string }): { where: string; params: unknown[] } {
// 候選:這個 template 底下、有 source_uri 值、owner 符合、source_uri 符合前綴、
// 且目前這個 record 沒有任何 'library' slot 值的 record_idNOT EXISTS 保冪等)。
const conds = [
'ev.slot_name = ?',
'e.content LIKE ? || \'%\'',
'e.owner_id = ?',
`NOT EXISTS (SELECT 1 FROM entry_values lev WHERE lev.record_id = ev.record_id AND lev.slot_name = 'library')`,
];
const params: unknown[] = ['source_uri', c.source_prefix ?? '', c.owner_id];
return { where: conds.join(' AND '), params };
}
/**
* 對「符合條件、目前未標記 library」的既有三元組 record 批次補上 target library 值。
* 冪等 + 分批(單次 limit 上限)+ budget(與 entries 版/embed reconcile 共用每日 D1 寫入額度)。
* 呼叫端(daemon/來源標籤系統/#87)決定「這批 source_uri 前綴對應哪個 library」,
* 本函式只負責安全、節流地把值寫進三元組 record(base 不猜語意,同 backfillEntryLibraryTags)。
*/
export async function backfillTripletLibraryTags(
db: D1Database,
env: Pick<Bindings, 'KBDB_MAINTENANCE_DAILY_WRITE_LIMIT'>,
opts: { library: string; owner_id: string; limit?: number } & TripletLibraryBackfillCriteria,
): Promise<LibraryBackfillResult> {
const library = (opts.library ?? '').trim();
if (!library) throw new Error('library required');
const ownerId = (opts.owner_id ?? '').trim();
if (!ownerId) throw new Error('owner_id required(標庫是跨大量既有資料的批次寫入,不准無租戶範圍地掃全庫——2026-08-11 leo 直令)');
const limit = Math.min(Math.max(opts.limit ?? 100, 1), HARD_LIMIT_CAP);
const tripletTemplateName = opts.triplet_template ?? DEFAULT_TRIPLET_TEMPLATE;
// 冪等地確保 template 有 library slot(同 recomputeLibraryMap 的既有慣例,不動表)。
await ensureTripletLibrarySlot(db, tripletTemplateName);
const tpl = await db.prepare(`SELECT id FROM templates WHERE name = ?`).bind(tripletTemplateName).first<{ id: string }>();
if (!tpl) throw new Error(`triplet template not found: ${tripletTemplateName}`);
const sel = tripletCriteriaSql({ ...opts, owner_id: ownerId });
const res = await db
.prepare(
`SELECT ev.record_id AS id FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
WHERE ev.template_id = ? AND ${sel.where} ORDER BY ev.record_id ASC LIMIT ?`,
)
.bind(tpl.id, ...sel.params, limit)
.all<{ id: string }>();
const scannedIds = (res.results ?? []).map((r) => r.id);
const scanned = scannedIds.length;
// D69:額度截斷——每個候選最多 1 次 D1 writeupdateRecord 對「缺 slot」的 grow 路徑正是 1 次
// INSERT),與 entries 版/reconcile 共用同一顆計數器。
const budget = await maintenanceBudgetToday(env, db);
const ids = scannedIds.slice(0, budget.remaining);
const quotaExceeded = scanned > ids.length;
let tagged = 0;
for (const id of ids) {
const updated = await updateRecord(db, id, { library });
if (updated) tagged += 1;
}
try {
await addMaintenanceUsage(db, tagged);
} catch {
// fail-open:額度計數寫入失敗不影響已經完成的標庫寫入(精神同 embed.ts 的做法)。
}
const remRow = await db
.prepare(
`SELECT COUNT(*) as c FROM entry_values ev JOIN entries e ON ev.entry_id = e.id WHERE ev.template_id = ? AND ${sel.where}`,
)
.bind(tpl.id, ...sel.params)
.first<{ c: number }>();
return {
library,
scanned,
tagged,
remaining: remRow?.c ?? 0,
quota_limit: budget.limit,
quota_used_today: budget.used + tagged,
quota_exceeded: quotaExceeded,
};
}
/** 待補標統計(三元組版,回報用):符合條件、目前未標記 library 的三元組筆數。 */
export async function tripletLibraryBackfillStatus(
db: D1Database,
opts: TripletLibraryBackfillCriteria & { owner_id: string },
): Promise<{ pending: number }> {
const tripletTemplateName = opts.triplet_template ?? DEFAULT_TRIPLET_TEMPLATE;
const tpl = await db.prepare(`SELECT id FROM templates WHERE name = ?`).bind(tripletTemplateName).first<{ id: string }>();
if (!tpl) return { pending: 0 };
const sel = tripletCriteriaSql(opts);
const row = await db
.prepare(
`SELECT COUNT(*) as c FROM entry_values ev JOIN entries e ON ev.entry_id = e.id WHERE ev.template_id = ? AND ${sel.where}`,
)
.bind(tpl.id, ...sel.params)
.first<{ c: number }>();
return { pending: row?.c ?? 0 };
}
-49
View File
@@ -2,7 +2,6 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import type { Bindings } from '../types'; import type { Bindings } from '../types';
import { createRecord, deleteRecord, getRecord, searchByTemplate, updateRecord } from '../actions/record-crud'; import { createRecord, deleteRecord, getRecord, searchByTemplate, updateRecord } from '../actions/record-crud';
import { backfillTripletLibraryTags, tripletLibraryBackfillStatus } from '../actions/library-backfill';
export const recordRoutes = new Hono<{ Bindings: Bindings }>(); export const recordRoutes = new Hono<{ Bindings: Bindings }>();
@@ -58,54 +57,6 @@ recordRoutes.get('/by-template/:template', async (c) => {
return c.json({ success: true, records, count: records.length }); return c.json({ success: true, records, count: records.length });
}); });
// POST /records/backfill-library — 三元組版標庫補存量(Arcrun#87 二次收尾,2026-08-13)。
// entries 版(POST /entries/backfill-libraryArcrun#85)補的是卡片層 metadata_json.library
// 藏書地圖讀的是三元組 record 自己的 'library' slot,兩者是不同存放處——本端點補後者,
// 地圖(GET /map)才會真的從 0 變成有意義的數字。
// body(必填 library + owner_id):{ library, owner_id, triplet_template?(預設 'triplet',
// source_prefix?(必要篩選:本票的規則=gitea:Leo/<repo>@ 前綴 → 對應庫;kb:// → 'kb',
// limit?(1-500,預設100) }。
// 冪等:只選「目前沒有 library slot 值」的三元組;分批:單次 limit 上限,remaining>0 → 重複呼叫直到 0。
// budget:與 /entries/backfill-library、/embed/reconcile 共用同一顆每日 D1 寫入額度(D69)。
// 此路由必須在 '/:recordId' 之前註冊,否則 'backfill-library' 會被當成 recordId 參數。
recordRoutes.post('/backfill-library', async (c) => {
const body = (await c.req.json().catch(() => ({}))) as {
library?: string;
owner_id?: string;
triplet_template?: string;
source_prefix?: string;
limit?: number | string;
};
const library = String(body.library ?? '').trim();
const ownerId = String(body.owner_id ?? '').trim();
if (!library || !ownerId) return c.json({ success: false, error: 'library 與 owner_id 必填' }, 400);
try {
const result = await backfillTripletLibraryTags(c.env.DB, c.env, {
library,
owner_id: ownerId,
triplet_template: body.triplet_template || undefined,
source_prefix: body.source_prefix || undefined,
limit: body.limit !== undefined ? Number(body.limit) : undefined,
});
return c.json({ success: true, ...result });
} catch (e) {
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 400);
}
});
// GET /records/backfill-library/status?owner_id=&triplet_template=&source_prefix=
// — 符合條件、目前未標記 library 的三元組筆數(backfill 前後都能查,判斷還剩多少)。
recordRoutes.get('/backfill-library/status', async (c) => {
const ownerId = c.req.query('owner_id') || '';
if (!ownerId) return c.json({ success: false, error: 'owner_id 必填' }, 400);
const status = await tripletLibraryBackfillStatus(c.env.DB, {
owner_id: ownerId,
triplet_template: c.req.query('triplet_template') || undefined,
source_prefix: c.req.query('source_prefix') || undefined,
});
return c.json({ success: true, ...status });
});
// GET /records/:recordId // GET /records/:recordId
recordRoutes.get('/:recordId', async (c) => { recordRoutes.get('/:recordId', async (c) => {
const rec = await getRecord(c.env.DB, c.req.param('recordId')); const rec = await getRecord(c.env.DB, c.req.param('recordId'));
@@ -1,164 +0,0 @@
// 三元組版標庫「批次」backfillArcrun#87 二次收尾,2026-08-13)測試。
//
// 背景:kbdb/tests/triplet-library-backfill.test.ts2026-08-11)已經證明「逐筆 updateRecord
// 補標庫」這條寫入路徑語意正確(源頭順序/存量補標/冪等三案)。但那份測試操作的是逐筆手刻的
// backfillPass() helper,不是可對外呼叫的批次能力——通道只有單筆 PATCH /kbdb/records/:id
// (b6ef0f0),母體上千筆時逐筆 PATCH 給呼叫端自己刻節流/冪等不現實。本檔驗證新加的
// backfillTripletLibraryTagskbdb/src/actions/library-backfill.ts):把「呼叫端給 library
// + source_prefix,一次處理一批」包成一個安全、節流的函式,語意與 entries 版
// library-backfill.test.ts)逐條對齊。
//
// 覆蓋:
// 1. 只補「符合 source_prefix、目前未標記 library」的候選三元組;已標記的不動(冪等)
// 2. owner_id 必填(缺了要拋錯,同 2026-08-11 leo 直令)
// 3. 與 entries 版 backfillEntryLibraryTags 共用同一顆每日 D1 寫入額度(D69:兩者都要受管,
// 任一邊獨立跑就是把另一邊的閘繞過去)
// 4. status 欄位存在的 record 也一起處理(backfill 不管 active/superseded,那是地圖聚合層的
// 篩選責任,不是補標層的責任——見 library-map.ts withLib 的 COALESCE 判準)
//
// 測試手法沿 triplet-library-backfill.test.tslibrary-backfill.test.ts 慣例:真 node:sqlite
// 套 migrations/0001_base.sql 原檔(本檔只用得到 templates/entries/entry_values 三張表,
// 不涉 library_map,不需 0003),kbdb-sql-ok 行尾標記同既有慣例(測試治具本身,非牆外業務邏輯繞過 API)。
import { describe, it, expect } from 'vitest';
import { DatabaseSync } from 'node:sqlite';
import { readFileSync } from 'node:fs';
import { backfillTripletLibraryTags, tripletLibraryBackfillStatus } from '../src/actions/library-backfill';
import { backfillEntryLibraryTags } from '../src/actions/library-backfill';
import { createTemplate, createRecord, getRecord } from '../src/actions/record-crud';
import type { Bindings } from '../src/types';
function makeSqliteD1(): D1Database {
const raw = new DatabaseSync(':memory:');
raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8')); // kbdb-sql-ok:測試治具(node:sqlite→D1 shim)套 migration 原檔
function stmt(sql: string, params: unknown[]) {
const s = {
bind(...args: unknown[]) { return stmt(sql, args); },
async all<T>() { return { results: raw.prepare(sql).all(...params) as T[] }; }, // kbdb-sql-ok:測試治具(node:sqlite→D1 shim
async first<T>() { return (raw.prepare(sql).get(...params) ?? null) as T | null; }, // kbdb-sql-ok:測試治具(node:sqlite→D1 shim
async run() {
const r = raw.prepare(sql).run(...params); // kbdb-sql-ok:測試治具(node:sqlite→D1 shim),非牆外業務邏輯繞過 API
return { success: true, meta: { changes: r.changes } };
},
};
return s;
}
return { prepare: (sql: string) => stmt(sql, []) } as unknown as D1Database;
}
function makeEnv(db: D1Database, opts: { maintenanceLimit?: string } = {}): Bindings {
return {
DB: db,
ENVIRONMENT: 'test',
KBDB_MAINTENANCE_DAILY_WRITE_LIMIT: opts.maintenanceLimit,
} as unknown as Bindings;
}
// prod 實際 triplet template 的 slotslibrary-map.test.tstriplet-library-backfill.test.ts 同款常數)。
const PROD_TRIPLET_SLOTS = [
'subject', 'predicate', 'object', 'source_block_id', 'confidence', 'clusters_json',
'bridge_score', 'subject_entity_type', 'object_entity_type', 'status', 'superseded_by',
'source_uri', 'content_hash', 'source_anchor', 'predicate_embed',
];
async function seedTripletTemplate(db: D1Database): Promise<void> {
await createTemplate(db, { id: 'tpl-triplet-test', name: 'triplet', slots: PROD_TRIPLET_SLOTS, created_by: 'kbdb-graph' });
}
describe('backfillTripletLibraryTags — 批次補三元組的 library slot(藏書地圖真正讀的那一半)', () => {
it('只補符合 source_prefix、目前缺 library 的三元組;owner 不符/已標記過的不動', async () => {
const db = makeSqliteD1();
await seedTripletTemplate(db);
const env = makeEnv(db);
const r1 = await createRecord(db, { template: 'triplet', values: { subject: 'A', predicate: 'r', object: 'B', source_uri: 'gitea:Leo/Arcrun@a.md' }, owner_id: 'bfezv28v' });
const r2 = await createRecord(db, { template: 'triplet', values: { subject: 'C', predicate: 'r', object: 'D', source_uri: 'gitea:Leo/Arcrun@b.md' }, owner_id: 'bfezv28v' });
// 別的 owner,同前綴——不該被補到(跨租戶隔離)。
const r3 = await createRecord(db, { template: 'triplet', values: { subject: 'E', predicate: 'r', object: 'F', source_uri: 'gitea:Leo/Arcrun@c.md' }, owner_id: 'someone-else' });
// 不符 source_prefix——不該被補到。
const r4 = await createRecord(db, { template: 'triplet', values: { subject: 'G', predicate: 'r', object: 'H', source_uri: 'gitea:Leo/mira@d.md' }, owner_id: 'bfezv28v' });
const result = await backfillTripletLibraryTags(db, env, {
library: 'arcrun',
owner_id: 'bfezv28v',
source_prefix: 'gitea:Leo/Arcrun@',
});
expect(result.scanned).toBe(2);
expect(result.tagged).toBe(2);
expect(result.remaining).toBe(0);
const rec1 = await getRecord(db, r1.record_id);
const rec2 = await getRecord(db, r2.record_id);
const rec3 = await getRecord(db, r3.record_id);
const rec4 = await getRecord(db, r4.record_id);
expect(rec1!.values.library).toBe('arcrun');
expect(rec2!.values.library).toBe('arcrun');
expect(rec3!.values.library).toBeUndefined(); // 別的 owner,沒被動到
expect(rec4!.values.library).toBeUndefined(); // 不符前綴,沒被動到
});
it('冪等:已標記過 library 的三元組不會被第二輪 touch(不會覆蓋成別的值)', async () => {
const db = makeSqliteD1();
await seedTripletTemplate(db);
const env = makeEnv(db);
const r1 = await createRecord(db, { template: 'triplet', values: { subject: 'A', predicate: 'r', object: 'B', source_uri: 'gitea:Leo/kb@a.md' }, owner_id: 'bfezv28v' });
const first = await backfillTripletLibraryTags(db, env, { library: 'kb', owner_id: 'bfezv28v', source_prefix: 'gitea:Leo/kb@' });
expect(first.tagged).toBe(1);
// 第二輪同條件再跑一次:這筆已經有 library 值了,不該再被選中,即使再給一個不同的 library 值。
const second = await backfillTripletLibraryTags(db, env, { library: 'something-else', owner_id: 'bfezv28v', source_prefix: 'gitea:Leo/kb@' });
expect(second.scanned).toBe(0);
expect(second.tagged).toBe(0);
const rec1 = await getRecord(db, r1.record_id);
expect(rec1!.values.library).toBe('kb'); // 沒被第二輪的 'something-else' 洗掉
});
it('owner_id 必填 — 缺了直接拋錯,不准無租戶範圍地掃全庫', async () => {
const db = makeSqliteD1();
await seedTripletTemplate(db);
const env = makeEnv(db);
await expect(backfillTripletLibraryTags(db, env, { library: 'arcrun', owner_id: '' })).rejects.toThrow(/owner_id required/);
});
it('與 entries 版 backfillEntryLibraryTags 共用同一顆每日 D1 寫入額度(D69:任一邊都不能繞過去)', async () => {
const db = makeSqliteD1();
await seedTripletTemplate(db);
const env = makeEnv(db, { maintenanceLimit: '3' }); // 每日只剩 3 次背景維護寫入額度
// entries 版先用掉 2 筆額度。
await db.prepare(`INSERT INTO entries (id, content, entry_type, owner_id, created_at, updated_at) VALUES (?, 'x', 'block', 'bfezv28v', 1000, 1000)`).bind('e1').run(); // kbdb-sql-ok:測試治具灌資料
await db.prepare(`INSERT INTO entries (id, content, entry_type, owner_id, created_at, updated_at) VALUES (?, 'x', 'block', 'bfezv28v', 1000, 1000)`).bind('e2').run(); // kbdb-sql-ok:測試治具灌資料
const entriesResult = await backfillEntryLibraryTags(db, env, { library: 'notes', owner_id: 'bfezv28v' });
expect(entriesResult.tagged).toBe(2);
// 三元組版此時只剩 1 次額度可用,即使候選有 2 筆,也只能補 1 筆、如實回報 quota_exceeded。
await createRecord(db, { template: 'triplet', values: { subject: 'A', predicate: 'r', object: 'B', source_uri: 'gitea:Leo/Arcrun@a.md' }, owner_id: 'bfezv28v' });
await createRecord(db, { template: 'triplet', values: { subject: 'C', predicate: 'r', object: 'D', source_uri: 'gitea:Leo/Arcrun@b.md' }, owner_id: 'bfezv28v' });
const tripletResult = await backfillTripletLibraryTags(db, env, { library: 'arcrun', owner_id: 'bfezv28v', source_prefix: 'gitea:Leo/Arcrun@' });
expect(tripletResult.scanned).toBe(2);
expect(tripletResult.tagged).toBe(1); // 只剩 1 額度,被截斷
expect(tripletResult.quota_exceeded).toBe(true);
expect(tripletResult.remaining).toBe(1); // 還有 1 筆沒補到
});
it('tripletLibraryBackfillStatus 回報還剩多少待補(backfill 前後都能查)', async () => {
const db = makeSqliteD1();
await seedTripletTemplate(db);
const env = makeEnv(db);
await createRecord(db, { template: 'triplet', values: { subject: 'A', predicate: 'r', object: 'B', source_uri: 'gitea:Leo/notes@a.md' }, owner_id: 'bfezv28v' });
await createRecord(db, { template: 'triplet', values: { subject: 'C', predicate: 'r', object: 'D', source_uri: 'gitea:Leo/notes@b.md' }, owner_id: 'bfezv28v' });
const before = await tripletLibraryBackfillStatus(db, { owner_id: 'bfezv28v', source_prefix: 'gitea:Leo/notes@' });
expect(before.pending).toBe(2);
await backfillTripletLibraryTags(db, env, { library: 'notes', owner_id: 'bfezv28v', source_prefix: 'gitea:Leo/notes@' });
const after = await tripletLibraryBackfillStatus(db, { owner_id: 'bfezv28v', source_prefix: 'gitea:Leo/notes@' });
expect(after.pending).toBe(0);
});
});
+94 -8
View File
@@ -5,20 +5,86 @@ import { buildLibraryMapInstructions } from "./lib/library-map.js";
import type { KnowledgeIdentity } from "./lib/portal-client.js"; import type { KnowledgeIdentity } from "./lib/portal-client.js";
import { Env } from "./types.js"; import { Env } from "./types.js";
export async function handleMcpRequest( /**
request: Request, * 【這條連線上有主人的知識庫】——**靜態、必定出現**的指路段(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(
env: Env, env: Env,
orgNamespace: string,
partnerToken: string,
identity: KnowledgeIdentity, identity: KnowledgeIdentity,
): Promise<Response> { ): Promise<string> {
// library-map SDD M4design §4/§6):連線時把全館藏書地圖嵌進 server instructions // library-map SDD M4design §4/§6):連線時把全館藏書地圖嵌進 server instructions
// session 一開就知道館裡有哪些庫(push 零查詢)。builder 內建 timeoutisolate TTL 快取 // session 一開就知道館裡有哪些庫(push 零查詢)。builder 內建 timeoutisolate TTL 快取
//(選型理由見 lib/library-map.ts 檔頭);任何失敗回 null → 靜默略過,絕不擋 MCP 連線(鐵律)。 //(選型理由見 lib/library-map.ts 檔頭);任何失敗回 null → 絕不擋 MCP 連線(鐵律)。
// //
// 🔴 2026-08-12:以帳密連線時**改用登入者的身分**組地圖——否則 instructions 會把 // 🔴 2026-08-12:以帳密連線時**改用登入者的身分**組地圖——否則 instructions 會把
// 整個知識庫的庫名一次推給一個可能只有部分權限的帳號(地圖本身就是情報)。 // 整個知識庫的庫名一次推給一個可能只有部分權限的帳號(地圖本身就是情報)。
// 快取也因此改成 per-session key(見 lib/library-map.ts)。 // 快取也因此改成 per-session key(見 lib/library-map.ts)。
//
// 🔴 2026-08-13:失敗**不再靜默略過**。原本 null → 整段消失,於是「地圖沒取到」與
// 「這裡沒有知識」在 AI 眼裡長得一模一樣(Arcrun#109 同一族:保險拒絕了 vs 程式碼不存在,
// 畫面上都是沉默)。現在改成印一句明話。鐵律沒變——**失敗仍然不擋連線**,只是不再無聲。
const mapInstructions = await buildLibraryMapInstructions(env, identity); const mapInstructions = await buildLibraryMapInstructions(env, identity);
// 2026-07-30leo 問「人類說『幫我用 arcrun 寫 xxx』,Haiku 會知道要用這些資源嗎? // 2026-07-30leo 問「人類說『幫我用 arcrun 寫 xxx』,Haiku 會知道要用這些資源嗎?
@@ -39,9 +105,13 @@ export async function handleMcpRequest(
"", "",
"1. `arcrun_get_skill('write_intent_workflow')` — **必讀第一支**。", "1. `arcrun_get_skill('write_intent_workflow')` — **必讀第一支**。",
" 教你用 `>>` 寫「意圖工作流」。你**不需要先知道有哪些零件**,先寫意圖。", " 教你用 `>>` 寫「意圖工作流」。你**不需要先知道有哪些零件**,先寫意圖。",
" ⚠️ 這支若回錯(401/連不上/`kbdb_unreachable`),那是**這條連線讀不到 KBDB**",
" **不是 skill 不存在**——同一份內容用 `kbdb_search({ q: 'skill-write_intent_workflow' })` 撈得到。",
"2. `arcrun_whoami()` — 確認連到哪個帳號(勿自行 curl 猜帳號 URL)。", "2. `arcrun_whoami()` — 確認連到哪個帳號(勿自行 curl 猜帳號 URL)。",
"3. 把意圖丟 `POST /cypher/search` 或 `arcrun_validate_yaml` — 系統告訴你哪些零件存在。", "3. 把意圖丟 `POST /cypher/search` 或 `arcrun_validate_yaml` — 系統告訴你哪些零件存在。",
"4. 卡住/不知道該查什麼 → `arcrun_get_skill('INDEX')`(全館導航:什麼問題查哪裡+已知的坑)。", "4. 卡住/不知道該查什麼 → `arcrun_list_skills()` **看這台實例真的有哪幾支**,再挑一支讀。",
" 2026-08-13 實測:不同實例 seed 的 skill 不一樣,有的實例只有兩支、連 `INDEX` 都沒有。",
" **不要照教材直接指名一個 slug** ——先列清單,或 `kbdb_search({ q: 'skill' })` 直接在庫裡找。)",
"5. 缺零件時:缺 API → 寫 recipe`arcrun_recipe_push`);缺能力 → 投稿零件 PR。", "5. 缺零件時:缺 API → 寫 recipe`arcrun_recipe_push`);缺能力 → 投稿零件 PR。",
" 🔴 **不要因為查不到零件就改寫成 `code` 節點**——那叫「腹語術」(表面用 Arcrun、", " 🔴 **不要因為查不到零件就改寫成 `code` 節點**——那叫「腹語術」(表面用 Arcrun、",
" 實際全寫 JS)。`code` 只用於局部整形(例:剝掉 LLM 回應的雜訊)。", " 實際全寫 JS)。`code` 只用於局部整形(例:剝掉 LLM 回應的雜訊)。",
@@ -58,7 +128,23 @@ export async function handleMcpRequest(
"第一個節點固定是 `input`。", "第一個節點固定是 `input`。",
].join("\n"); ].join("\n");
const instructions = mapInstructions ? `${startHere}\n\n---\n\n${mapInstructions}` : startHere; // 地圖那段永遠有東西可印:拿到 → 印地圖;沒拿到 → 印「沒拿到」,不是消失。
// 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 transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined });
const server = new McpServer( const server = new McpServer(
+126 -41
View File
@@ -30,6 +30,79 @@ import { z } from "zod";
import type { Env } from "../types.js"; import type { Env } from "../types.js";
import { kbdbFetch } from "../lib/kbdb-client.js"; import { kbdbFetch } from "../lib/kbdb-client.js";
import { errorResponse, successResponse } from "../lib/cypher-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/403=讀不到;5xx/連不上=連不上),並把**還走得通的那條路**(`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 // 基本盤 entries row(與舊 v3 block 欄位 1:1,差別只在 type→entry_type
interface KbdbBlock { interface KbdbBlock {
@@ -45,14 +118,25 @@ interface KbdbBlock {
async function kbdbList(env: Env, entryType: string, limit = 100): Promise<KbdbBlock[]> { async function kbdbList(env: Env, entryType: string, limit = 100): Promise<KbdbBlock[]> {
const resp = await kbdbFetch(env, `/entries?entry_type=${encodeURIComponent(entryType)}&limit=${limit}`); const resp = await kbdbFetch(env, `/entries?entry_type=${encodeURIComponent(entryType)}&limit=${limit}`);
if (!resp.ok) throw new Error(`KBDB list entry_type=${entryType} HTTP ${resp.status}`); if (!resp.ok) {
throw new KbdbAccessError(resp.status, `list entry_type=${entryType}`, await resp.text().catch(() => ""));
}
const data = await resp.json<{ entries?: KbdbBlock[] }>(); const data = await resp.json<{ entries?: KbdbBlock[] }>();
return data.entries ?? []; return data.entries ?? [];
} }
/**
* 依 page_name 取一張卡。
*
* 🔴 回 `null` **只代表「KBDB 好好回答了,而它說沒有這張卡」**。
* 讀不到(4015xx/連不上)一律拋 `KbdbAccessError`——**絕不 return null**
* 否則呼叫端會把它講成「不存在」(2026-08-13 的真實事故,見檔頭)。
*/
async function kbdbGetByPageName(env: Env, pageName: string): Promise<KbdbBlock | null> { async function kbdbGetByPageName(env: Env, pageName: string): Promise<KbdbBlock | null> {
const resp = await kbdbFetch(env, `/entries?page_name=${encodeURIComponent(pageName)}&limit=1`); const resp = await kbdbFetch(env, `/entries?page_name=${encodeURIComponent(pageName)}&limit=1`);
if (!resp.ok) return null; if (!resp.ok) {
throw new KbdbAccessError(resp.status, `get page_name=${pageName}`, await resp.text().catch(() => ""));
}
const data = await resp.json<{ entries?: KbdbBlock[] }>(); const data = await resp.json<{ entries?: KbdbBlock[] }>();
return data.entries?.[0] ?? null; return data.entries?.[0] ?? null;
} }
@@ -67,7 +151,7 @@ function parseTags(tagsJson?: string): string[] {
} }
} }
export function registerListSkills(server: McpServer, env: Env) { export function registerListSkills(server: McpServer, env: Env, identity: KnowledgeIdentity) {
server.tool( server.tool(
toolName("list_skills"), toolName("list_skills"),
"列所有 agent-skill blocks(從 arcrun/registry/skills/ 同步進 KBDB)。每個 skill 是個 markdown playbook,描述 AI 面對 X 問題該怎麼想 + 該用哪個 example。回 [{slug, title, tags}]。call get_skill(slug) 拿完整內文。", "列所有 agent-skill blocks(從 arcrun/registry/skills/ 同步進 KBDB)。每個 skill 是個 markdown playbook,描述 AI 面對 X 問題該怎麼想 + 該用哪個 example。回 [{slug, title, tags}]。call get_skill(slug) 拿完整內文。",
@@ -75,6 +159,7 @@ export function registerListSkills(server: McpServer, env: Env) {
tag: z.string().optional().describe("optional 標籤過濾。如 'rag' / 'watcher' / 'debug'"), tag: z.string().optional().describe("optional 標籤過濾。如 'rag' / 'watcher' / 'debug'"),
}, },
async ({ tag }) => { async ({ tag }) => {
if (identity.kind === "stale") return staleIdentityError();
try { try {
const blocks = await kbdbList(env, "agent-skill", 100); const blocks = await kbdbList(env, "agent-skill", 100);
const skills = blocks const skills = blocks
@@ -101,20 +186,19 @@ export function registerListSkills(server: McpServer, env: Env) {
skills.length === 0 skills.length === 0
? "沒有 skill 命中。試 list_skills() 不帶 tag 看全部" ? "沒有 skill 命中。試 list_skills() 不帶 tag 看全部"
: "call arcrun_get_skill(slug) 拿單個 skill 完整 markdown", : "call arcrun_get_skill(slug) 拿單個 skill 完整 markdown",
// 誠實:這裡回的是**這台實例被 seed 進去的那幾支**,不是「全世界的 skill 目錄」。
// 上面清單沒有的名字(例如 'INDEX')就是這台沒有——別照舊教材去猜一個 slug。
"🔴 只用上面清單裡真的有的 slug;清單沒有=這台實例沒 seed 進去,不要硬猜名字",
], ],
); );
} catch (e) { } catch (e) {
return errorResponse( return kbdbFailure(e, "改用 kbdb_search({ q: 'skill' }) 直接在知識庫裡找 skill 卡片(那條路走的是另一組憑據)", identity);
"fetch_failed",
e instanceof Error ? e.message : String(e),
["稍後重試", "若持續失敗,告訴 leo"],
);
} }
}, },
); );
} }
export function registerGetSkill(server: McpServer, env: Env) { export function registerGetSkill(server: McpServer, env: Env, identity: KnowledgeIdentity) {
server.tool( server.tool(
toolName("get_skill"), toolName("get_skill"),
"拿單一 agent-skill 完整 markdown playbook。slug 從 list_skills 取得。", "拿單一 agent-skill 完整 markdown playbook。slug 從 list_skills 取得。",
@@ -122,15 +206,19 @@ export function registerGetSkill(server: McpServer, env: Env) {
slug: z.string().describe("skill slug,例如 'build_watcher_workflow' / 'rag_with_arcrun'"), slug: z.string().describe("skill slug,例如 'build_watcher_workflow' / 'rag_with_arcrun'"),
}, },
async ({ slug }) => { async ({ slug }) => {
if (identity.kind === "stale") return staleIdentityError();
try { try {
const pageName = slug.startsWith("skill-") ? slug : `skill-${slug}`; const pageName = slug.startsWith("skill-") ? slug : `skill-${slug}`;
const block = await kbdbGetByPageName(env, pageName); const block = await kbdbGetByPageName(env, pageName);
if (!block) { if (!block) {
// 走到這裡=KBDB **有正常回答**,而它說沒有這張卡(讀不到的情況上面已經拋出去了)。
return errorResponse( return errorResponse(
"not_found", "not_found",
`skill "${slug}" 不存在`, `KBDB 正常回應,但沒有 page_name="${pageName}" 這張卡——這台實例沒有 seed 這支 skill。` +
"(不同實例 seed 的 skill 不一樣,別照舊教材假設某個名字一定在。)",
[ [
"call arcrun_list_skills() 看可用 slug", "call arcrun_list_skills() 看**這台實例真的有**哪幾支",
`kbdb_search({ q: '${slug}' }) 看內容是不是被存成別的名字`,
"確認拼字正確(不需要 'skill-' prefix", "確認拼字正確(不需要 'skill-' prefix",
], ],
); );
@@ -142,17 +230,17 @@ export function registerGetSkill(server: McpServer, env: Env) {
tags: parseTags(block.tags_json), tags: parseTags(block.tags_json),
}); });
} catch (e) { } catch (e) {
return errorResponse( return kbdbFailure(
"fetch_failed", e,
e instanceof Error ? e.message : String(e), `改用 kbdb_search({ q: 'skill-${slug}' }) 撈同一張卡片(skill 就住在知識庫的 entries 裡,那條路走另一組憑據)`,
["稍後重試"], identity,
); );
} }
}, },
); );
} }
export function registerListExamples(server: McpServer, env: Env) { export function registerListExamples(server: McpServer, env: Env, identity: KnowledgeIdentity) {
server.tool( server.tool(
toolName("list_examples"), toolName("list_examples"),
"列所有 workflow-example blocks(從 arcrun/registry/examples/ 同步進 KBDB)。每個 example 是可直接 push 的 workflow YAML 範本 + description。回 [{slug, tags}]。call get_example / search_examples 拿細節。", "列所有 workflow-example blocks(從 arcrun/registry/examples/ 同步進 KBDB)。每個 example 是可直接 push 的 workflow YAML 範本 + description。回 [{slug, tags}]。call get_example / search_examples 拿細節。",
@@ -160,6 +248,7 @@ export function registerListExamples(server: McpServer, env: Env) {
tag: z.string().optional().describe("optional 標籤過濾。如 'rag' / 'cron' / 'llm' / 'webhook'"), tag: z.string().optional().describe("optional 標籤過濾。如 'rag' / 'cron' / 'llm' / 'webhook'"),
}, },
async ({ tag }) => { async ({ tag }) => {
if (identity.kind === "stale") return staleIdentityError();
try { try {
const blocks = await kbdbList(env, "workflow-example", 200); const blocks = await kbdbList(env, "workflow-example", 200);
const examples = blocks const examples = blocks
@@ -183,17 +272,13 @@ export function registerListExamples(server: McpServer, env: Env) {
], ],
); );
} catch (e) { } catch (e) {
return errorResponse( return kbdbFailure(e, "改用 kbdb_search({ q: 'example' }) 直接在知識庫裡找 example 卡片(那條路走另一組憑據)", identity);
"fetch_failed",
e instanceof Error ? e.message : String(e),
["稍後重試"],
);
} }
}, },
); );
} }
export function registerGetExample(server: McpServer, env: Env) { export function registerGetExample(server: McpServer, env: Env, identity: KnowledgeIdentity) {
server.tool( server.tool(
toolName("get_example"), toolName("get_example"),
"拿單一 workflow-example 完整 YAML + description。slug 從 list_examples / search_examples 取得。可直接拿 YAML 改成你自己的 → push。", "拿單一 workflow-example 完整 YAML + description。slug 從 list_examples / search_examples 取得。可直接拿 YAML 改成你自己的 → push。",
@@ -201,16 +286,19 @@ export function registerGetExample(server: McpServer, env: Env) {
slug: z.string().describe("example slug,例如 'rag-search-answer' / 'cron-watcher'"), slug: z.string().describe("example slug,例如 'rag-search-answer' / 'cron-watcher'"),
}, },
async ({ slug }) => { async ({ slug }) => {
if (identity.kind === "stale") return staleIdentityError();
try { try {
const pageName = slug.startsWith("example-") ? slug : `example-${slug}`; const pageName = slug.startsWith("example-") ? slug : `example-${slug}`;
const block = await kbdbGetByPageName(env, pageName); const block = await kbdbGetByPageName(env, pageName);
if (!block) { if (!block) {
// KBDB 好好回答了,而它說沒有這張卡(讀不到的情況已在 kbdbGetByPageName 拋出)。
return errorResponse( return errorResponse(
"not_found", "not_found",
`example "${slug}" 不存在`, `KBDB 正常回應,但沒有 page_name="${pageName}" 這張卡——這台實例沒 seed 這個 example。`,
[ [
"call arcrun_list_examples() 看可用 slug", "call arcrun_list_examples() 看**這台實例真的有**哪些 slug",
"或 arcrun_search_examples(use_case) 用自然語言找", "或 arcrun_search_examples(use_case) 用關鍵字找",
`kbdb_search({ q: '${slug}' }) 看內容是不是被存成別的名字`,
], ],
); );
} }
@@ -232,17 +320,17 @@ export function registerGetExample(server: McpServer, env: Env) {
"看 description_md 了解設計意圖 / 改造方向", "看 description_md 了解設計意圖 / 改造方向",
]); ]);
} catch (e) { } catch (e) {
return errorResponse( return kbdbFailure(
"fetch_failed", e,
e instanceof Error ? e.message : String(e), `改用 kbdb_search({ q: 'example-${slug}' }) 撈同一張卡片(example 就住在知識庫的 entries 裡)`,
["稍後重試"], identity,
); );
} }
}, },
); );
} }
export function registerSearchExamples(server: McpServer, env: Env) { export function registerSearchExamples(server: McpServer, env: Env, identity: KnowledgeIdentity) {
server.tool( server.tool(
toolName("search_examples"), toolName("search_examples"),
"用 use case 關鍵字搜 workflow examples,回最相關 N 個。" + "用 use case 關鍵字搜 workflow examples,回最相關 N 個。" +
@@ -253,6 +341,7 @@ export function registerSearchExamples(server: McpServer, env: Env) {
top_k: z.number().int().min(1).max(20).optional().describe("回幾個結果(預設 5"), top_k: z.number().int().min(1).max(20).optional().describe("回幾個結果(預設 5"),
}, },
async ({ query, top_k }) => { async ({ query, top_k }) => {
if (identity.kind === "stale") return staleIdentityError();
try { try {
const k = top_k ?? 5; const k = top_k ?? 5;
const q = query.trim(); const q = query.trim();
@@ -309,20 +398,16 @@ export function registerSearchExamples(server: McpServer, env: Env) {
], ],
); );
} catch (e) { } catch (e) {
return errorResponse( return kbdbFailure(e, `改用 kbdb_search({ q: '${query.trim()}' }) 直接查知識庫(那條路走另一組憑據)`, identity);
"internal_error",
e instanceof Error ? e.message : String(e),
["重試一次"],
);
} }
}, },
); );
} }
export function registerAllSkillExampleTools(server: McpServer, env: Env) { export function registerAllSkillExampleTools(server: McpServer, env: Env, identity: KnowledgeIdentity) {
registerListSkills(server, env); registerListSkills(server, env, identity);
registerGetSkill(server, env); registerGetSkill(server, env, identity);
registerListExamples(server, env); registerListExamples(server, env, identity);
registerGetExample(server, env); registerGetExample(server, env, identity);
registerSearchExamples(server, env); registerSearchExamples(server, env, identity);
} }
+5 -1
View File
@@ -55,7 +55,11 @@ export function registerAllTools(
registerAllWorkflowCrudTools(server, env); registerAllWorkflowCrudTools(server, env);
// LI SDD M3.2: skills + examples lookupKBDB-backed // LI SDD M3.2: skills + examples lookupKBDB-backed
// 走 sync-registry-to-kbdb.py 把 registry/{skills,examples} 同步進 KBDB // 走 sync-registry-to-kbdb.py 把 registry/{skills,examples} 同步進 KBDB
registerAllSkillExampleTools(server, env); // 2026-08-13:吃 identity 只為了「把話講對」——舊 token 誠實回 identity_missing
// 且 401 時能告訴登入者「是這批工具還走服務憑據,不是你的帳號讀不到」。
// ⚠️ 這批**尚未**改走 portal 資料面(portal 沒有 by-entry_type 的 listing 端點;
// 要接得補 API,不是在這層拼裝——rule 07 §3.1)。見該檔檔頭。
registerAllSkillExampleTools(server, env, identity);
// kbdb-base §7.5.i: recipe 公庫/私庫工具(與 CLI 六能力對齊,rule 07 §5 MCP 不落後) // kbdb-base §7.5.i: recipe 公庫/私庫工具(與 CLI 六能力對齊,rule 07 §5 MCP 不落後)
registerAllRecipeTools(server, env); registerAllRecipeTools(server, env);
// kbdb-base Phase 9.1: KBDB 資料層薄殼(template/record/query/searchHANDOFF §2 // kbdb-base Phase 9.1: KBDB 資料層薄殼(template/record/query/searchHANDOFF §2
+130
View File
@@ -0,0 +1,130 @@
/**
* 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')");
});
});
@@ -0,0 +1,186 @@
/**
* 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' })");
});
});