build_pack.py 產生對白索引(放在使用者資料目錄);render_skill.py 把人格卡渲染成 角色 skill 並檢查精選對白逐字來自索引;插件改成執行期從資料目錄讀索引。 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
352ce7dcad
commit
697ed6a0b9
@@ -1,8 +0,0 @@
|
||||
import type { CharacterCode } from '../characters'
|
||||
import type { IndexEntry } from '../retrieval'
|
||||
|
||||
// 各角色的對白索引。W5、W6 由建置流水線產生並填入;目前為空,空索引時 hook 不呼叫模型。
|
||||
export const INDEXES: Readonly<Record<CharacterCode, readonly IndexEntry[]>> = {
|
||||
'sao-asuna': [],
|
||||
'sao-suguha': [],
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, test } from 'claude-code/testing'
|
||||
|
||||
import { indexPath, loadIndex, parseIndex } from './index-loader'
|
||||
|
||||
const line = (o: Record<string, unknown>) => JSON.stringify({ id: 'a', function: 'comfort', emotion: 'worry', mode: null, text: '沒事的喔', ...o })
|
||||
|
||||
describe('indexPath', () => {
|
||||
test('dataDir 留空用家目錄下的預設位置', () => {
|
||||
expect(indexPath('', '/home/u', 'sao-asuna')).toBe('/home/u/.claude/anime-companion/sao-asuna/index.jsonl')
|
||||
})
|
||||
|
||||
test('dataDir 優先,去掉結尾斜線', () => {
|
||||
expect(indexPath('/data/x/', '/home/u', 'sao-suguha')).toBe('/data/x/sao-suguha/index.jsonl')
|
||||
})
|
||||
|
||||
test('沒有家目錄也沒有 dataDir 回傳 null', () => {
|
||||
expect(indexPath(' ', undefined, 'sao-asuna')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseIndex', () => {
|
||||
test('讀取合法的行', () => {
|
||||
expect(parseIndex([line({}), line({ id: 'b', mode: 'leafa' })].join('\n'))).toEqual([
|
||||
{ id: 'a', function: 'comfort', emotion: 'worry', mode: null, text: '沒事的喔' },
|
||||
{ id: 'b', function: 'comfort', emotion: 'worry', mode: 'leafa', text: '沒事的喔' },
|
||||
])
|
||||
})
|
||||
|
||||
test('略過壞行、空行、白名單外的功能、過長或空的句子', () => {
|
||||
const text = ['not json', '', line({ function: 'flirt' }), line({ text: '' }), line({ text: '字'.repeat(81) }), line({ id: 'ok' })].join('\n')
|
||||
expect(parseIndex(text).map(e => e.id)).toEqual(['ok'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadIndex', () => {
|
||||
test('讀取失敗或沒有路徑回傳空陣列', async () => {
|
||||
expect(await loadIndex(async () => { throw new Error('ENOENT') }, '/x')).toEqual([])
|
||||
expect(await loadIndex(async () => line({}), null)).toEqual([])
|
||||
})
|
||||
|
||||
test('成功讀取', async () => {
|
||||
expect(await loadIndex(async () => line({}), '/x')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { MAX_LINE } from './examples'
|
||||
import type { IndexEntry } from './retrieval'
|
||||
import { isFunctionTag } from './tags'
|
||||
|
||||
const DEFAULT_DIR = '.claude/anime-companion'
|
||||
|
||||
// 對白索引由建置流水線產生,放在使用者資料目錄(不在插件裡、不入版本控制)。
|
||||
// dataDir 留空時用 ~/.claude/anime-companion;找不到家目錄就回傳 null。
|
||||
export function indexPath(dataDir: string, home: string | undefined, code: string): string | null {
|
||||
const base = dataDir.trim() !== '' ? dataDir.trim() : home ? `${home}/${DEFAULT_DIR}` : null
|
||||
return base === null ? null : `${base.replace(/\/+$/, '')}/${code}/index.jsonl`
|
||||
}
|
||||
|
||||
// 一行一筆;格式不對的行略過,不讓壞掉的資料擋住載入。
|
||||
export function parseIndex(text: string): IndexEntry[] {
|
||||
const out: IndexEntry[] = []
|
||||
for (const line of text.split('\n')) {
|
||||
if (line.trim() === '') continue
|
||||
try {
|
||||
const e = JSON.parse(line) as Record<string, unknown>
|
||||
if (
|
||||
typeof e.id === 'string' &&
|
||||
isFunctionTag(e.function) &&
|
||||
typeof e.emotion === 'string' &&
|
||||
(e.mode === null || typeof e.mode === 'string') &&
|
||||
typeof e.text === 'string' &&
|
||||
e.text.length > 0 &&
|
||||
e.text.length <= MAX_LINE
|
||||
) {
|
||||
out.push({ id: e.id, function: e.function, emotion: e.emotion, mode: e.mode, text: e.text })
|
||||
}
|
||||
} catch {
|
||||
// 略過壞行
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export async function loadIndex(read: (path: string) => Promise<string>, path: string | null): Promise<IndexEntry[]> {
|
||||
if (path === null) return []
|
||||
try {
|
||||
return parseIndex(await read(path))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,14 @@ import { buildInjection } from './inject'
|
||||
import type { IndexEntry } from './retrieval'
|
||||
|
||||
const entry = (id: string, fn: IndexEntry['function']): IndexEntry => ({ id, function: fn, emotion: 'neutral', mode: null, text: `句${id}` })
|
||||
const indexes = {
|
||||
const indexes: Record<string, IndexEntry[]> = {
|
||||
'sao-asuna': [entry('a', 'comfort'), entry('b', 'comfort')],
|
||||
'sao-suguha': [] as IndexEntry[],
|
||||
'sao-suguha': [],
|
||||
}
|
||||
const answer = (text: string): Complete => async () => ({ isAnswered: true, text })
|
||||
const deps = (opts: Record<string, string>, complete: Complete, calls = { n: 0 }) => ({
|
||||
config: resolveConfig(opts),
|
||||
indexes,
|
||||
loadIndex: async (c: string) => indexes[c] ?? [],
|
||||
complete: async (r: Parameters<Complete>[0]) => (calls.n++, complete(r)),
|
||||
now: () => 0,
|
||||
turn: 0,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { retrieve, type IndexEntry } from './retrieval'
|
||||
|
||||
export type InjectDeps = {
|
||||
config: Config
|
||||
indexes: Readonly<Record<CharacterCode, readonly IndexEntry[]>>
|
||||
loadIndex: (character: CharacterCode) => Promise<readonly IndexEntry[]>
|
||||
complete: Complete
|
||||
append: (text: string) => Promise<unknown>
|
||||
now: () => number
|
||||
@@ -16,12 +16,12 @@ export type InjectDeps = {
|
||||
// 回傳 null 表示這一輪不注入(沒選角色、強度 0、索引為空、分類失敗或沒有同標籤的對白)。
|
||||
// 索引為空時不呼叫模型,省下一次無用的分類。
|
||||
export async function buildInjection(
|
||||
{ config, indexes, complete, now, turn }: Omit<InjectDeps, 'append'>,
|
||||
{ config, loadIndex, complete, now, turn }: Omit<InjectDeps, 'append'>,
|
||||
userText: string,
|
||||
): Promise<{ text: string | null; fn: string | null; ms: number } | null> {
|
||||
const character = config.character
|
||||
if (character === null || config.intensity === 0) return null
|
||||
const index = indexes[character]
|
||||
const index = await loadIndex(character)
|
||||
if (index.length === 0) return null
|
||||
const started = now()
|
||||
const fn = await classify(complete, userText)
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import type { Register } from 'claude-code'
|
||||
|
||||
import type { CharacterCode } from './characters'
|
||||
import { resolveConfig } from './config'
|
||||
import { INDEXES } from './data/index'
|
||||
import { indexPath, loadIndex } from './index-loader'
|
||||
import { buildInjection } from './inject'
|
||||
import type { IndexEntry } from './retrieval'
|
||||
import { buildRules, SECTION_ID } from './rules'
|
||||
|
||||
// 規則段落(W8)加到系統提示最後;每則使用者訊息再依功能標籤檢索角色的對白當語氣參考(W7)。
|
||||
// 對白索引在使用者資料目錄(dataDir,預設 ~/.claude/anime-companion/<角色>/index.jsonl)。
|
||||
export const register: Register = (on, options) => {
|
||||
const config = resolveConfig(options)
|
||||
const showTiming = options.showTiming === true
|
||||
const dataDir = typeof options.dataDir === 'string' ? options.dataDir : ''
|
||||
let turn = 0
|
||||
// 只快取讀到內容的索引;沒讀到(還沒建置)下一輪會再試,建置完不必重開 session。
|
||||
const cache = new Map<CharacterCode, readonly IndexEntry[]>()
|
||||
|
||||
on('prompt.compose', async ($, e, next) => {
|
||||
const result = await next(e)
|
||||
@@ -20,8 +26,16 @@ export const register: Register = (on, options) => {
|
||||
|
||||
on('prompt.submit', async ($, e, next) => {
|
||||
if (e.command || e.isMeta) return next(e)
|
||||
const load = async (code: CharacterCode) => {
|
||||
const hit = cache.get(code)
|
||||
if (hit) return hit
|
||||
const path = indexPath(dataDir, await $.env.get('HOME'), code)
|
||||
const index = await loadIndex(p => $.fs.read(p), path)
|
||||
if (index.length > 0) cache.set(code, index)
|
||||
return index
|
||||
}
|
||||
const r = await buildInjection(
|
||||
{ config, indexes: INDEXES, complete: req => $.model.complete(req), now: Date.now, turn: turn++ },
|
||||
{ config, loadIndex: load, complete: req => $.model.complete(req), now: Date.now, turn: turn++ },
|
||||
e.text,
|
||||
)
|
||||
if (r === null) return next(e)
|
||||
|
||||
Reference in New Issue
Block a user