From 0c525d07b2796f48cbeea426909826647bb7e0a7 Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 15 Oct 2026 06:15:03 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=9A=8E=E6=AE=B5=205=20wiki=20?= =?UTF-8?q?=E7=9F=A5=E8=AD=98=E5=BA=AB=EF=BC=88=E8=AE=80=E5=AF=AB=E3=80=81?= =?UTF-8?q?10=20=E5=88=86=E9=90=98=E5=BF=AB=E5=8F=96=E3=80=81=E5=8B=95?= =?UTF-8?q?=E6=85=8B=E8=A8=BB=E5=86=8A=E6=8C=87=E4=BB=A4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .claude-plugin/plugin.json | 16 +++- README.md | 9 +++ hooks/register.tsx | 117 ++++++++++++++++++++++++++++- hooks/wiki.test.ts | 148 +++++++++++++++++++++++++++++++++++++ hooks/wiki.ts | 92 +++++++++++++++++++++++ 5 files changed, 379 insertions(+), 3 deletions(-) create mode 100644 hooks/wiki.test.ts create mode 100644 hooks/wiki.ts diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 4b9d6c0..32b1fa4 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -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" } -} \ No newline at end of file +} diff --git a/README.md b/README.md index 1303c54..a623562 100644 --- a/README.md +++ b/README.md @@ -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 不寫入任何檔案。 + ## 安裝 ``` diff --git a/hooks/register.tsx b/hooks/register.tsx index 2ac24c6..8fafd07 100644 --- a/hooks/register.tsx +++ b/hooks/register.tsx @@ -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 = { 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 { + 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 { + return parseCatalog(await $.fs.read(`${w.dir}/Home.md`).catch(() => '')) +} + +async function registerEntry($: Engine, known: Set, 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) { + 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, args: string): Promise { + 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 { + 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 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() + const wikiCfg = (): WikiCfg => ({ url: str('wikiUrl', ''), cacheDir: str('wikiCacheDir', '') }) const overrides = (): Partial => { const out: Partial = {} @@ -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} 離開。` } }) diff --git a/hooks/wiki.test.ts b/hooks/wiki.test.ts new file mode 100644 index 0000000..533ff36 --- /dev/null +++ b/hooks/wiki.test.ts @@ -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; pushOk?: boolean } = {}) => { + const files = new Map(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) +}) diff --git a/hooks/wiki.ts b/hooks/wiki.ts new file mode 100644 index 0000000..579e982 --- /dev/null +++ b/hooks/wiki.ts @@ -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 => { + const g = new Map() + 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 } +} -- 2.53.0