feat: 階段 5 wiki 知識庫(讀寫、10 分鐘快取、動態註冊指令)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 06:15:03 +00:00
co-authored by Claude Sonnet 5.5
parent c890059a50
commit 0c525d07b2
5 changed files with 379 additions and 3 deletions
+14 -2
View File
@@ -1,6 +1,6 @@
{
"name": "jsc-anima",
"version": "0.1.0",
"version": "0.2.0",
"description": "動漫助理:用喜愛角色的口吻互動,附單行狀態列",
"license": "MIT",
"userConfig": {
@@ -103,9 +103,21 @@
"title": "情緒外顯 (0-10,-1 沿用角色包)",
"description": "覆蓋目前角色的數值軸,只存本機",
"default": -1
},
"wikiUrl": {
"type": "string",
"title": "wiki 位址",
"description": "知識庫的 Gitea wiki git 位址;留空則由目前專案的 origin 推得(認證優先讀 tea 登入,其次 GITEA_TOKEN)",
"default": ""
},
"wikiCacheDir": {
"type": "string",
"title": "wiki 本機快取目錄",
"description": "10 分鐘 git 快取的位置;留空用 ~/.cache/jsc-anima/wiki",
"default": ""
}
},
"author": {
"name": "jsc"
}
}
}
+9
View File
@@ -4,6 +4,15 @@
動漫助理:用喜愛角色的口吻和你互動,並在輸入框上方顯示單行狀態列(角色、模型、目錄、分支、5h/7d 剩餘額度、ctx、系統資源)。
## 知識庫(wiki)
啟動角色後可用:
- `/jsc-anima:anima-wiki <領域> <主題> [出處]`(下一行起為內容):寫入 Gitea wiki,每次一個中文 commit,並更新 `Home` 目錄與 `_Sidebar`。領域與主題只能用英數與底線。
- `/jsc-anima:<領域>-<主題>`:讀取知識,指令在啟動時依 wiki 目錄註冊。
讀寫都在 wiki,本機只做 10 分鐘快取,離線時沿用快取。認證優先讀 tea 登入,其次 `GITEA_TOKEN`,token 不寫入任何檔案。
## 安裝
```
+116 -1
View File
@@ -4,9 +4,14 @@ import { CHARACTERS, applyOverrides, findCharacter, personaSection } from './cha
import type { Axes } from './characters'
import { layout, meter, parseFields, remainColor, SEPARATOR, usedColor } from './statusline'
import type { Segment } from './statusline'
import { CACHE_MS, addEntry, commandName, commitMessage, pageName, parseCatalog, parseWriteArgs, renderHome, renderPage, renderSidebar, teaToken, wikiUrlFromRemote } from './wiki'
import type { Entry } from './wiki'
const ACTIVE_KEY = 'active'
const OFF_COMMAND = 'anima-off'
const WIKI_COMMAND = 'anima-wiki'
const SYNCED_KEY = 'wikiSyncedAt'
const NEED_ACTIVE = '請先啟動角色(/jsc-anima:{作品代號}-{角色代號})再使用這個工具。'
const AXIS_OPTION: Record<keyof Axes, string> = {
gentle: 'axisGentle',
@@ -54,6 +59,106 @@ async function readSys($: Engine, holder: { sys?: Sys }, refreshMs: number): Pro
return holder.sys
}
type WikiCfg = { url: string; cacheDir: string }
type Wiki = { dir: string; url: string; auth: string[] }
async function wikiCtx($: Engine, cfg: WikiCfg): Promise<Wiki | undefined> {
let url = cfg.url
if (!url) {
const cwd = await $.session.cwd().catch(() => '')
const r = await $.process.run(['git', 'remote', 'get-url', 'origin'], { cwd: cwd || undefined, timeoutMs: 3000 }).catch(() => undefined)
url = (r && r.exitCode === 0 && wikiUrlFromRemote(String(r.stdout))) || ''
}
if (!url) return undefined
const home = (await $.env.get('HOME')) ?? ''
const dir = cfg.cacheDir || `${home}/.cache/jsc-anima/wiki`
// 認證:優先 tea 登入,其次 GITEA_TOKEN;token 只放在指令參數,不寫入任何檔案。
let token: string | undefined
try {
token = teaToken(await $.fs.read(`${home}/.config/tea/config.yml`), new URL(url).host)
} catch {}
token ??= (await $.env.get('GITEA_TOKEN')) || undefined
return { dir, url, auth: token ? ['-c', `http.extraHeader=Authorization: token ${token}`] : [] }
}
async function git($: Engine, w: Wiki, args: string[], inDir = true) {
const r = await $.process.run(['git', ...w.auth, ...(inDir ? ['-C', w.dir] : []), ...args], { timeoutMs: 60000 }).catch(() => undefined)
return r && r.exitCode === 0 ? r : undefined
}
// 本機只做 10 分鐘快取;離線時沿用已有的快取。
async function sync($: Engine, w: Wiki, force: boolean): Promise<'fresh' | 'cached' | 'none'> {
const cloned = await $.fs.exists(`${w.dir}/.git`).catch(() => false)
const now = await $.clock.now()
const last = Number((await $.store.get(SYNCED_KEY)) ?? 0)
if (cloned && !force && now - last < CACHE_MS) return 'fresh'
const ok = cloned ? await git($, w, ['pull', '--ff-only']) : await git($, w, ['clone', w.url, w.dir], false)
if (ok) {
await $.store.set(SYNCED_KEY, now)
return 'fresh'
}
return cloned ? 'cached' : 'none'
}
async function readCatalog($: Engine, w: Wiki): Promise<Entry[]> {
return parseCatalog(await $.fs.read(`${w.dir}/Home.md`).catch(() => ''))
}
async function registerEntry($: Engine, known: Set<string>, e: Entry) {
const name = commandName(e)
if (known.has(name)) return
known.add(name)
await $.command.register({ name, description: `讀取知識庫:${e.domain}/${e.topic}` })
}
// 工具三的指令只在啟動角色後註冊,依 wiki 目錄動態註冊。
async function registerWikiTools($: Engine, cfg: WikiCfg, known: Set<string>) {
if (!known.has(WIKI_COMMAND)) {
known.add(WIKI_COMMAND)
await $.command.register({ name: WIKI_COMMAND, description: '整理資料寫入 wiki 知識庫', argumentHint: '<領域> <主題> [出處](下一行起為內容)' })
}
try {
const w = await wikiCtx($, cfg)
if (!w) return
await sync($, w, false)
for (const e of await readCatalog($, w)) await registerEntry($, known, e)
} catch {}
}
async function writeWiki($: Engine, cfg: WikiCfg, known: Set<string>, args: string): Promise<string> {
const parsed = parseWriteArgs(args)
if (!parsed) return '用法:/jsc-anima:anima-wiki <領域> <主題> [出處],下一行起寫內容。領域與主題只能用英數與底線(會成為指令名稱)。'
const w = await wikiCtx($, cfg)
if (!w) return '找不到 wiki 位址:請在 /config 設定 wikiUrl,或在 Gitea 專案目錄中使用。'
if ((await sync($, w, true)) === 'none') return '無法連線到 wiki,且本機沒有快取,未寫入。'
const { entry, source, body } = parsed
const file = `${w.dir}/${pageName(entry)}.md`
const existed = await $.fs.exists(file).catch(() => false)
const catalog = addEntry(await readCatalog($, w), entry)
await $.fs.write(file, renderPage(entry, body, source))
await $.fs.write(`${w.dir}/Home.md`, renderHome(catalog))
await $.fs.write(`${w.dir}/_Sidebar.md`, renderSidebar(catalog))
if (!(await git($, w, ['add', '-A'])) || !(await git($, w, ['commit', '-m', commitMessage(entry, existed)]))) {
return '寫入失敗:無法建立 commit。'
}
if (!(await git($, w, ['push']))) {
await git($, w, ['reset', '--hard', 'HEAD~1'])
return '推送 wiki 失敗,已還原本機變更,請稍後再試。'
}
await $.store.set(SYNCED_KEY, await $.clock.now())
await registerEntry($, known, entry)
return `已${existed ? '更新' : '新增'}知識「${entry.domain}/${entry.topic}」,指令:/jsc-anima:${commandName(entry)}`
}
async function readWiki($: Engine, cfg: WikiCfg, name: string): Promise<string | undefined> {
const w = await wikiCtx($, cfg)
if (!w) return undefined
await sync($, w, false)
const e = (await readCatalog($, w)).find(x => commandName(x) === name)
if (!e) return undefined
return await $.fs.read(`${w.dir}/${pageName(e)}.md`).catch(() => undefined)
}
export const register: Register = (on, options) => {
const opt = options as Record<string, unknown>
const flag = (k: string, d: boolean) => (typeof opt[k] === 'boolean' ? (opt[k] as boolean) : d)
@@ -63,6 +168,8 @@ export const register: Register = (on, options) => {
const isEnabled = flag('enabled', true)
let active: string | undefined
const holder: { sys?: Sys } = {}
const wikiCommands = new Set<string>()
const wikiCfg = (): WikiCfg => ({ url: str('wikiUrl', ''), cacheDir: str('wikiCacheDir', '') })
const overrides = (): Partial<Axes> => {
const out: Partial<Axes> = {}
@@ -81,6 +188,7 @@ export const register: Register = (on, options) => {
await $.command.register({ name: OFF_COMMAND, description: '離開角色,回到一般的 Claude Code' })
const saved = await $.store.get(ACTIVE_KEY)
active = flag('persist', true) && typeof saved === 'string' && findCharacter(saved) ? saved : undefined
if (active) await registerWikiTools($, wikiCfg(), wikiCommands)
}
return next(e)
})
@@ -93,9 +201,16 @@ export const register: Register = (on, options) => {
return { text: '已離開角色,回到一般模式。' }
}
const c = findCharacter(e.command)
if (!c) return next(e)
if (!c) {
if (!wikiCommands.has(e.command)) return next(e)
if (!findCharacter(active)) return { text: NEED_ACTIVE }
if (e.command === WIKI_COMMAND) return { text: await writeWiki($, wikiCfg(), wikiCommands, String(e.args ?? '')) }
const page = await readWiki($, wikiCfg(), e.command)
return { text: page ?? `找不到知識「${e.command}」。` }
}
active = c.slug
await $.store.set(ACTIVE_KEY, c.slug)
await registerWikiTools($, wikiCfg(), wikiCommands)
return { text: `已啟動:${c.name}(${c.work})。用 /jsc-anima:${OFF_COMMAND} 離開。` }
})
+148
View File
@@ -0,0 +1,148 @@
import { expect, mock, test } from 'claude-code/testing'
const OPTS = { options: { wikiCacheDir: '/cache/wiki' } }
// 記憶體內的假 wiki:以 process.run/fs.* 事件邊界替代 git 與檔案系統。
const setup = (on: any, opts: { registered?: string[]; online?: boolean; seed?: Record<string, string>; pushOk?: boolean } = {}) => {
const files = new Map<string, string>(Object.entries(opts.seed ?? {}))
const calls: string[][] = []
const state = { online: opts.online ?? true, pushOk: opts.pushOk ?? true, commits: [] as string[] }
const D = '/cache/wiki'
mock.store(on)
mock.env(on, { HOME: '/home/u', GITEA_TOKEN: 'secret-token' })
const clock = mock.clock(on, { now: 1_000_000 })
on('command.register', (_$: any, e: any) => {
opts.registered?.push(e.name)
return { value: { name: e.name } } as never
})
on('session.start', () => ({ cwd: '/proj' }) as never)
on('command.run', () => ({ text: '' }))
on('fs.exists', (_$: any, e: any) => ({ value: files.has(e.path.replace(`${D}/`, '')) || e.path === `${D}/.git` && files.has('.git') }) as never)
on('fs.read', (_$: any, e: any) => {
const k = e.path.replace(`${D}/`, '')
if (!files.has(k)) return { deny: 'ENOENT' } as never
return { value: files.get(k)! } as never
})
on('fs.write', (_$: any, e: any) => {
files.set(e.path.replace(`${D}/`, ''), e.text)
return { value: undefined } as never
})
on('process.run', (_$: any, e: any) => {
const argv = [...e.argv]
calls.push(argv)
const sub = argv.find(a => ['clone', 'pull', 'add', 'commit', 'push', 'reset', 'get-url'].includes(a))
const out = (exitCode = 0, stdout = '') => ({ value: { exitCode, stdout, stderr: '' } }) as never
if (sub === 'get-url') return out(0, 'https://gitea.example/o/r.git\n')
if (sub === 'clone') {
if (!state.online) return out(128)
files.set('.git', '1')
return out()
}
if (sub === 'pull') return state.online ? out() : out(128)
if (sub === 'commit') {
state.commits.push(argv[argv.indexOf('-m') + 1]!)
return out()
}
if (sub === 'push') return state.pushOk ? out() : out(1)
return out()
})
return { files, calls, state, clock }
}
const start = async ($: any) => {
await $.session.start({ source: 'startup', cwd: '/proj' } as never)
}
const run = ($: any, command: string, args = '') => $.command.run({ command, args } as never)
test('未啟動角色時拒絕寫入與讀取', OPTS, async ($, on) => {
const w = setup(on, { seed: { '.git': '1', 'Home.md': '## tools\n\n- [git](tools-git)\n', 'tools-git.md': '內容' } })
await start($)
// 工具指令尚未註冊,也未啟動:直接當作一般指令放行(由 harness 底層回空字串)。
const res = await run($, 'anima-wiki', 'tools git\n內容')
expect(res.text).toBe('')
expect(w.state.commits.length).toBe(0)
})
test('啟動後才註冊工具,並依 wiki 目錄動態註冊', OPTS, async ($, on) => {
const registered: string[] = []
setup(on, { registered, seed: { '.git': '1', 'Home.md': '## tools\n\n- [git](tools-git)\n' } })
await start($)
expect(registered).toEqual([...registered.filter(n => n === 'anima-off' || n.startsWith('sao-'))])
await run($, 'sao-asuna')
expect(registered).toContain('anima-wiki')
expect(registered).toContain('tools-git')
})
test('寫入:一個中文 commit、更新 Home 與 _Sidebar、推送,並註冊新指令', OPTS, async ($, on) => {
const w = setup(on, { seed: { '.git': '1' } })
await start($)
await run($, 'sao-asuna')
const res = await run($, 'anima-wiki', 'tools git https://git-scm.com\n用 rebase 整理歷史。')
expect(res.text).toContain('已新增')
expect(w.state.commits).toEqual(['feat: 新增知識「tools/git」'])
expect(w.files.get('Home.md')).toContain('## tools')
expect(w.files.get('Home.md')).toContain('- [git](tools-git)')
expect(w.files.get('_Sidebar.md')).toContain('[git](tools-git)')
expect(w.files.get('tools-git.md')).toContain('出處:https://git-scm.com')
expect(w.calls.some(c => c.includes('push'))).toBe(true)
// 讀回
const read = await run($, 'tools-git')
expect(read.text).toContain('用 rebase 整理歷史。')
})
test('覆蓋既有主題用「更新」訊息;來源預設為使用者提供', OPTS, async ($, on) => {
const w = setup(on, { seed: { '.git': '1' } })
await start($)
await run($, 'sao-asuna')
await run($, 'anima-wiki', 'tools git\n第一版')
await run($, 'anima-wiki', 'tools git\n第二版')
expect(w.state.commits).toEqual(['feat: 新增知識「tools/git」', 'feat: 更新知識「tools/git」'])
expect(w.files.get('tools-git.md')).toContain('出處:使用者提供')
expect(w.files.get('Home.md')!.match(/\[git\]/g)!.length).toBe(1)
})
test('10 分鐘內讀取用快取,逾時才重新 pull;離線沿用快取', OPTS, async ($, on) => {
const w = setup(on, { seed: { '.git': '1', 'Home.md': '## tools\n\n- [git](tools-git)\n', 'tools-git.md': '舊內容' } })
await start($)
await run($, 'sao-asuna')
const pulls = () => w.calls.filter(c => c.includes('pull')).length
await run($, 'tools-git')
const base = pulls()
await w.clock.advance(5 * 60 * 1000)
await run($, 'tools-git')
expect(pulls()).toBe(base)
await w.clock.advance(6 * 60 * 1000)
w.state.online = false
const res = await run($, 'tools-git')
expect(pulls()).toBe(base + 1)
expect(res.text).toContain('舊內容')
})
test('推送失敗時還原並回報,不註冊指令', OPTS, async ($, on) => {
const w = setup(on, { seed: { '.git': '1' }, pushOk: false })
await start($)
await run($, 'sao-asuna')
const res = await run($, 'anima-wiki', 'tools git\n內容')
expect(res.text).toContain('失敗')
expect(w.calls.some(c => c.includes('reset'))).toBe(true)
})
test('token 只放在指令參數,不寫入任何檔案', OPTS, async ($, on) => {
const w = setup(on, { seed: { '.git': '1' } })
await start($)
await run($, 'sao-asuna')
await run($, 'anima-wiki', 'tools git\n內容')
const pushCall = w.calls.find(c => c.includes('push'))!
expect(pushCall.join(' ')).toContain('Authorization: token secret-token')
expect([...w.files.values()].some(v => v.includes('secret-token'))).toBe(false)
})
test('離開角色後工具被拒絕並提示先啟動', OPTS, async ($, on) => {
const w = setup(on, { seed: { '.git': '1' } })
await start($)
await run($, 'sao-asuna')
await run($, 'anima-off')
const res = await run($, 'anima-wiki', 'tools git\n內容')
expect(res.text).toContain('請先啟動角色')
expect(w.state.commits.length).toBe(0)
})
+92
View File
@@ -0,0 +1,92 @@
// wiki 知識庫的純邏輯:目錄解析與渲染、頁名、驗證。讀寫與 git 在 register.tsx。
export type Entry = { domain: string; topic: string }
export const CACHE_MS = 10 * 60 * 1000
// 指令名稱只能用英數與底線,領域與主題也沿用。
const NAME_RE = /^[A-Za-z0-9_]+$/
export const validName = (s: string): boolean => NAME_RE.test(s)
export const commandName = (e: Entry): string => `${e.domain}-${e.topic}`
export const pageName = (e: Entry): string => `${e.domain}-${e.topic}`
// Home 的格式:「## 領域」章、「- [主題](頁名)」節。
export const parseCatalog = (home: string): Entry[] => {
const out: Entry[] = []
let domain = ''
for (const line of home.split('\n')) {
const h = /^##\s+(.+?)\s*$/.exec(line)
if (h) {
domain = h[1]!
continue
}
const m = /^\s*[-*]\s+\[(.+?)\]\((.+?)\)\s*$/.exec(line)
if (m && domain) out.push({ domain, topic: m[1]! })
}
return out
}
const group = (entries: Entry[]): Map<string, string[]> => {
const g = new Map<string, string[]>()
for (const e of entries) g.set(e.domain, [...(g.get(e.domain) ?? []), e.topic])
return g
}
export const addEntry = (entries: Entry[], e: Entry): Entry[] =>
entries.some(x => x.domain === e.domain && x.topic === e.topic) ? entries : [...entries, e]
export const renderHome = (entries: Entry[]): string => {
const lines = ['# 知識庫', '']
for (const [domain, topics] of group(entries)) {
lines.push(`## ${domain}`, '')
for (const t of topics) lines.push(`- [${t}](${pageName({ domain, topic: t })})`)
lines.push('')
}
return lines.join('\n')
}
export const renderSidebar = (entries: Entry[]): string => {
const lines = ['**[知識庫](Home)**', '']
for (const [domain, topics] of group(entries)) {
lines.push(`**${domain}**`, '')
for (const t of topics) lines.push(`- [${t}](${pageName({ domain, topic: t })})`)
lines.push('')
}
return lines.join('\n')
}
export const renderPage = (e: Entry, body: string, source: string): string =>
`# ${e.topic}\n\n> 領域:${e.domain}\n\n${body.trim()}\n\n---\n\n出處:${source}\n`
export const commitMessage = (e: Entry, existed: boolean): string =>
`feat: ${existed ? '更新' : '新增'}知識「${e.domain}/${e.topic}」`
// tea 設定檔(YAML)中,找出網址主機相符的登入的 token。
export const teaToken = (yaml: string, host: string): string | undefined => {
for (const block of yaml.split(/^\s*-\s+name:/m).slice(1)) {
const url = /^\s*url:\s*(\S+)/m.exec(block)?.[1]
const token = /^\s*token:\s*(\S+)/m.exec(block)?.[1]
if (!url || !token) continue
try {
if (new URL(url.replace(/^['"]|['"]$/g, '')).host === host) return token.replace(/^['"]|['"]$/g, '')
} catch {}
}
return undefined
}
export const wikiUrlFromRemote = (remote: string): string | undefined => {
const base = remote.trim().replace(/\.git$/, '')
return base ? `${base}.wiki.git` : undefined
}
// 指令參數:第一行「領域 主題 [出處]」,其後為內容。
export const parseWriteArgs = (args: string): { entry: Entry; source: string; body: string } | undefined => {
const [first = '', ...rest] = args.trim().split('\n')
const [domain, topic, ...src] = first.trim().split(/\s+/)
const body = rest.join('\n').trim()
if (!domain || !topic || !body || !validName(domain) || !validName(topic)) return undefined
return { entry: { domain, topic }, source: src.join(' ') || '使用者提供', body }
}