From 0b658ef87827902f1ec645d50d3ddef8337fe2a0 Mon Sep 17 00:00:00 2001 From: jiantw83 Date: Mon, 12 Oct 2026 13:28:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20M3=20=E5=B0=8F=E8=AA=AA=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E6=A0=B8=E5=BF=83=EF=BC=88=E5=A5=97=E7=94=A8=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E7=B5=90=E6=9E=9C=E3=80=81=E5=90=88=E4=BD=B5=E8=A9=A2?= =?UTF-8?q?=E5=95=8F=E3=80=81=E5=80=8B=E6=80=A7=E6=8E=A8=E5=B0=8E=E3=80=81?= =?UTF-8?q?=E5=8F=A3=E7=99=96=EF=BC=89=E8=88=87=20ingest=20skill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- docs/fixtures/sample-chapter.md | 9 ++ docs/ingest-walkthrough.md | 12 ++ docs/research-verification.md | 10 ++ plugins/cockpit/hooks/companion/core/apply.ts | 149 ++++++++++++++++++ .../cockpit/hooks/companion/core/core.test.ts | 147 +++++++++++++++++ .../hooks/companion/core/personality.ts | 63 ++++++++ .../cockpit/hooks/companion/core/speech.ts | 20 +++ plugins/cockpit/hooks/companion/core/store.ts | 19 +++ plugins/cockpit/hooks/companion/core/types.ts | 58 +++++++ plugins/cockpit/skills/ingest/SKILL.md | 27 ++++ 10 files changed, 514 insertions(+) create mode 100644 docs/fixtures/sample-chapter.md create mode 100644 docs/ingest-walkthrough.md create mode 100644 docs/research-verification.md create mode 100644 plugins/cockpit/hooks/companion/core/apply.ts create mode 100644 plugins/cockpit/hooks/companion/core/core.test.ts create mode 100644 plugins/cockpit/hooks/companion/core/personality.ts create mode 100644 plugins/cockpit/hooks/companion/core/speech.ts create mode 100644 plugins/cockpit/hooks/companion/core/store.ts create mode 100644 plugins/cockpit/hooks/companion/core/types.ts create mode 100644 plugins/cockpit/skills/ingest/SKILL.md diff --git a/docs/fixtures/sample-chapter.md b/docs/fixtures/sample-chapter.md new file mode 100644 index 0000000..8a6a95e --- /dev/null +++ b/docs/fixtures/sample-chapter.md @@ -0,0 +1,9 @@ +# 第 1 章 放學後的圖書室(原創範例) + +春天的星見學院,櫻花正落。放學後的圖書室只剩夕陽。 + +「才、才不是為了你呢!」小葵把書塞進我懷裡,別開了臉。她的短髮在逆光下閃著栗色,血型是 A 型,這是她自己說的。 + +(真是個不坦率的傢伙。)我在心裡想。 + +「先輩,明天也要來喔!」學妹小綠笑著揮手。「先輩真的很可靠喔!」 diff --git a/docs/ingest-walkthrough.md b/docs/ingest-walkthrough.md new file mode 100644 index 0000000..f44f7ad --- /dev/null +++ b/docs/ingest-walkthrough.md @@ -0,0 +1,12 @@ +# 範例章節完整流程(人工跑一次) + +範例章節:`docs/fixtures/sample-chapter.md`(原創內容)。於 2026-10-12 以記憶體檔案系統跑完以下步驟,結果如下。 + +1. 抽取:地點「星見學院圖書室」、環境「春天、傍晚、櫻花」;人物小葵(main,tsundere,A 型、栗色短髮、身高 unknown)、小綠(minor)。 +2. `planMerge`:首次無疑似同一人,主要角色小葵排在路人小綠之前。 +3. 決定:兩者皆「新建」,`applyParseResult` 寫入 `world/places.json`、`world/environment.json`、`characters//core.json`、`canon/episodic.json`、`canon/lines.json`,無衝突;身高 `unknown` 不寫入。 +4. 重跑同一章(小葵選合併):回報 `alreadyApplied: ["小葵"]`,檔案內容不變(冪等)。 +5. 口癖:語尾「喔」、稱呼「先輩」(重複出現 ≥2 次才採用)。 +6. 個性:tsundere 先驗 A=35;模型推論 A=90 附對白證據,被限制為 55(先驗 +20)。 + +結論:流程可走通;口頭禪歸納目前只能抓到語尾與稱呼,「才、才不是…」這類口頭禪仍須由 skill 中的模型判斷後補入。 diff --git a/docs/research-verification.md b/docs/research-verification.md new file mode 100644 index 0000000..2f82f64 --- /dev/null +++ b/docs/research-verification.md @@ -0,0 +1,10 @@ +# 研究待查證項目記錄(M3) + +查證日期:2026-10-12。僅記錄實際查到的內容,查不到的標為未查證。 + +| 項目 | 狀態 | 說明 | +|---|---|---| +| 個性轉 PAD 的係數 | 未查證 | 出處為 Mehrabian 1996〈Analysis of the Big-five Personality Factors in Terms of the PAD Temperament Model〉(Australian Journal of Psychology 48, 86–92),為標準化變數的線性迴歸。搜尋結果未取得係數,實作 PAD 基線(M1/M2)前須讀原文 PDF(https://www.cs.uky.edu/~sgware/reading/papers/mehrabian1996analysis.pdf)。M3 不使用此係數。 | +| 檢索權重的原始出處 | 部分查證 | Generative Agents(Park 2023, arXiv 2304.03442)以時效、重要度、相關性三項各自 min-max 正規化後加總;時效為指數衰減(衰減係數 0.995/遊戲小時);重要度由 LLM 給 1–10。次級來源另列 (0.5, 3, 2) 的手調權重,屬各實作自行調整。本專案 (1, 1, 2, 0.5) 為自訂設計值,並非出自原論文;原論文權重是否為等權重尚未對照官方程式碼確認。 | +| MemGPT、Reflexion 細節 | 未查證 | 本里程碑不依賴,留待 M4(記憶鞏固與反思)前查證。 | +| LLM 對白推個性可信度偏低 | 沿用規格 | 規格引用的相關最高約 0.27 未重新查證,故個性只當先驗並保留人工覆寫。 | diff --git a/plugins/cockpit/hooks/companion/core/apply.ts b/plugins/cockpit/hooks/companion/core/apply.ts new file mode 100644 index 0000000..1194a91 --- /dev/null +++ b/plugins/cockpit/hooks/companion/core/apply.ts @@ -0,0 +1,149 @@ +import { readJson, writeJson } from './store' +import { INFO_FIELDS, LAYERS, SOURCE_RANK } from './types' +import type { ApplyReport, Candidate, Conflict, Decision, Fs, Info, InfoField, InfoValue, Line, Memory, ParseResult, ParsedCharacter, PlanItem } from './types' + +export interface CharacterCore { + id: string + name: string + aliases: string[] + traits: string[] + info: Info + chapters: string[] +} + +const MAX_LINES = 30 +const MAX_LINE_LEN = 80 + +const charDir = (root: string, id: string) => `${root}/characters/${id}` +const slug = (name: string) => name.trim().toLowerCase().replace(/[\s/\\]+/g, '-') + +export function listCharacters(fs: Fs, root: string): CharacterCore[] { + const ids = readJson(fs, `${root}/characters/index.json`, []) + return ids.flatMap(id => { + const core = readJson(fs, `${charDir(root, id)}/core.json`, null) + return core ? [core] : [] + }) +} + +function reasonsFor(c: ParsedCharacter, existing: CharacterCore): string[] { + const reasons: string[] = [] + const names = [c.name, ...(c.aliases ?? [])] + const theirs = [existing.name, ...existing.aliases] + if (c.name === existing.name) reasons.push(`名字相同:${c.name}`) + for (const a of names) { + if (a !== existing.name && theirs.includes(a)) reasons.push(`別名吻合:${a}`) + } + if (!reasons.length && names.some(n => theirs.some(t => n.length >= 2 && t.length >= 2 && (n.includes(t) || t.includes(n))))) { + reasons.push('名字互相包含') + } + const shared = (c.traits ?? []).filter(t => existing.traits.includes(t)) + if (shared.length >= 2) reasons.push(`特徵相同:${shared.join('、')}`) + return reasons +} + +/** 產生逐一詢問的清單:主要角色在前、路人在後(可批次詢問),附疑似同一人的理由 */ +export function planMerge(fs: Fs, root: string, result: ParseResult): PlanItem[] { + const existing = listCharacters(fs, root) + const items = result.characters.map(c => { + const candidates: Candidate[] = existing + .map(e => ({ id: e.id, reasons: reasonsFor(c, e) })) + .filter(x => x.reasons.length > 0) + return { name: c.name, role: c.role, candidates } + }) + return [...items.filter(i => i.role === 'main'), ...items.filter(i => i.role !== 'main')] +} + +const uniq = (xs: T[]) => [...new Set(xs)] + +function mergeInfo(character: string, core: CharacterCore, incoming: Info, resolutions: Record, conflicts: Conflict[]) { + for (const field of INFO_FIELDS) { + const next = incoming[field] + if (!next || next.value === 'unknown') continue + const cur = core.info[field] + if (!cur || cur.value === 'unknown') { + core.info[field] = next + } else if (cur.value === next.value) { + if (rank(next) > rank(cur)) core.info[field] = next + } else { + const choice = resolutions[`${character}.${field}`] + if (choice === 'replace') core.info[field] = next + else if (choice !== 'keep') conflicts.push({ character, field, existing: cur, incoming: next }) + } + } +} + +const rank = (v: InfoValue) => (v.source ? SOURCE_RANK[v.source] : 0) + +function appendById(list: T[], add: T[]): T[] { + const seen = new Set(list.map(x => x.id)) + return [...list, ...add.filter(x => !seen.has(x.id))] +} + +/** + * 套用解析結果。冪等以章節編號為鍵:同一章對同一角色只會寫入一次; + * 合併只追加、不覆蓋;基本資訊衝突回報給呼叫端逐欄位詢問(resolutions 帶回答案)。 + */ +export function applyParseResult( + fs: Fs, + root: string, + result: ParseResult, + decisions: Record, + resolutions: Record = {}, +): ApplyReport { + const report: ApplyReport = { applied: [], skipped: [], alreadyApplied: [], conflicts: [] } + const chapter = result.chapter + + for (const kind of ['timeline', 'places', 'terms', 'environment'] as const) { + const entries = result.world?.[kind] ?? [] + if (!entries.length) continue + const path = `${root}/world/${kind}.json` + const add = entries.map(e => ({ id: `${chapter}:${kind}:${e.order}`, chapter, order: e.order, text: e.text })) + writeJson(fs, path, appendById(readJson(fs, path, []), add)) + } + + for (const c of result.characters) { + const decision = decisions[c.name] + if (!decision) throw new Error(`缺少人物決定:${c.name}`) + if (decision.action === 'skip') { report.skipped.push(c.name); continue } + + const index = readJson(fs, `${root}/characters/index.json`, []) + let id: string + if (decision.action === 'merge') { + if (!index.includes(decision.target)) throw new Error(`合併目標不存在:${decision.target}`) + id = decision.target + } else { + id = slug(c.name) + for (let n = 2; index.includes(id); n++) id = `${slug(c.name)}-${n}` + } + + const dir = charDir(root, id) + const core = readJson(fs, `${dir}/core.json`, { id, name: c.name, aliases: [], traits: [], info: {}, chapters: [] }) + if (core.chapters.includes(chapter)) { report.alreadyApplied.push(c.name); continue } + + const mine: Conflict[] = [] + mergeInfo(c.name, core, c.info ?? {}, resolutions, mine) + report.conflicts.push(...mine) + core.aliases = uniq([...core.aliases, ...(c.name !== core.name ? [c.name] : []), ...(c.aliases ?? [])]).filter(a => a !== core.name) + core.traits = uniq([...core.traits, ...(c.traits ?? [])]) + + for (const layer of LAYERS) { + const add: Memory[] = (c.memories?.[layer] ?? []).map(m => ({ ...m, id: `${chapter}:${layer}:${m.order}`, chapter, layer })) + if (!add.length) continue + const path = `${dir}/canon/${layer}.json` + writeJson(fs, path, appendById(readJson(fs, path, []), add)) + } + + if (c.lines?.length) { + const path = `${dir}/canon/lines.json` + const add = c.lines.map((l: Line) => ({ ...l, text: l.text.slice(0, MAX_LINE_LEN), id: `${chapter}:line:${l.order}`, chapter })) + writeJson(fs, path, appendById(readJson<(typeof add)[number][]>(fs, path, []), add).slice(-MAX_LINES)) + } + + // 有未解決衝突時不標記章節完成,讓回答後可重跑(記憶以 id 去重,不會重複) + if (!mine.length) core.chapters.push(chapter) + writeJson(fs, `${dir}/core.json`, core) + if (!index.includes(id)) writeJson(fs, `${root}/characters/index.json`, [...index, id]) + report.applied.push(id) + } + return report +} diff --git a/plugins/cockpit/hooks/companion/core/core.test.ts b/plugins/cockpit/hooks/companion/core/core.test.ts new file mode 100644 index 0000000..5baa730 --- /dev/null +++ b/plugins/cockpit/hooks/companion/core/core.test.ts @@ -0,0 +1,147 @@ +import { describe, test } from 'node:test' +import assert from 'node:assert/strict' + +import { applyParseResult, listCharacters, planMerge } from './apply' +import { applyInference, initPersonality, overrideDim } from './personality' +import { summarizeTics } from './speech' +import { memoryFs } from './store' +import type { ParseResult } from './types' + +const ROOT = '/c' +const ch1: ParseResult = { + chapter: '1', + world: { places: [{ text: '星見學院', order: 1 }], terms: [{ text: '星詠', order: 1 }] }, + characters: [ + { + name: '小葵', role: 'main', aliases: ['葵'], traits: ['tsundere', '短髮'], + info: { bloodType: { value: 'A', source: 'novel', confidence: 0.9 }, height: { value: '158cm', source: 'wiki', confidence: 0.5 } }, + memories: { episodic: [{ text: '在校門口遇見主角', importance: 6, order: 1 }] }, + lines: [{ text: '才、才不是為了你呢!', kind: 'speech', order: 1 }], + }, + { name: '路人甲', role: 'minor' }, + ], +} +const decide = { 小葵: { action: 'new' }, 路人甲: { action: 'skip' } } as const + +describe('applyParseResult', () => { + test('重複套用同一章是冪等的', () => { + const fs = memoryFs() + applyParseResult(fs, ROOT, ch1, decide) + const snap = new Map(fs.files) + const r = applyParseResult(fs, ROOT, ch1, { ...decide, 小葵: { action: 'merge', target: '小葵' } }) + assert.deepEqual(r.alreadyApplied, ['小葵']) + assert.deepEqual(fs.files, snap) + }) + + test('略過的人物不寫入', () => { + const fs = memoryFs() + const r = applyParseResult(fs, ROOT, ch1, decide) + assert.deepEqual(r.skipped, ['路人甲']) + assert.equal(listCharacters(fs, ROOT).length, 1) + }) + + test('合併新章節只追加記憶,並保留舊內容', () => { + const fs = memoryFs() + applyParseResult(fs, ROOT, ch1, decide) + applyParseResult(fs, ROOT, { + chapter: '2', + characters: [{ name: '葵', role: 'main', memories: { episodic: [{ text: '一起去圖書館', importance: 4, order: 1 }] } }], + }, { 葵: { action: 'merge', target: '小葵' } }) + const mem = JSON.parse(fs.files.get('/c/characters/小葵/canon/episodic.json')!) + assert.deepEqual(mem.map((m: { id: string }) => m.id), ['1:episodic:1', '2:episodic:1']) + assert.equal(listCharacters(fs, ROOT)[0]!.chapters.length, 2) + }) + + test('基本資訊衝突不會被無聲覆寫,回答後才更新', () => { + const fs = memoryFs() + applyParseResult(fs, ROOT, ch1, decide) + const ch2: ParseResult = { + chapter: '2', + characters: [{ name: '小葵', role: 'main', info: { bloodType: { value: 'B', source: 'wiki', confidence: 0.4 }, hair: { value: '黑色短髮', source: 'novel', confidence: 0.9 } } }], + } + const d = { 小葵: { action: 'merge', target: '小葵' } } as const + const r = applyParseResult(fs, ROOT, ch2, d) + assert.equal(r.conflicts.length, 1) + assert.equal(r.conflicts[0]!.field, 'bloodType') + let core = listCharacters(fs, ROOT)[0]! + assert.equal(core.info.bloodType!.value, 'A') + assert.equal(core.info.hair!.value, '黑色短髮') // 無衝突的欄位照常補上 + + const r2 = applyParseResult(fs, ROOT, ch2, d, { '小葵.bloodType': 'replace' }) + assert.equal(r2.conflicts.length, 0) + core = listCharacters(fs, ROOT)[0]! + assert.equal(core.info.bloodType!.value, 'B') + const mem = JSON.parse(fs.files.get('/c/characters/小葵/canon/episodic.json')!) + assert.equal(mem.length, 1) // 重跑沒有重複寫入 + }) + + test('unknown 不會覆蓋既有值,高優先來源可升級同值', () => { + const fs = memoryFs() + applyParseResult(fs, ROOT, ch1, decide) + const r = applyParseResult(fs, ROOT, { + chapter: '3', + characters: [{ name: '小葵', role: 'main', info: { bloodType: { value: 'unknown', source: null, confidence: 0 }, height: { value: '158cm', source: 'novel', confidence: 0.9 } } }], + }, { 小葵: { action: 'merge', target: '小葵' } }) + assert.equal(r.conflicts.length, 0) + const core = listCharacters(fs, ROOT)[0]! + assert.equal(core.info.bloodType!.value, 'A') + assert.equal(core.info.height!.source, 'novel') + }) + + test('世界觀庫以章節編號去重', () => { + const fs = memoryFs() + applyParseResult(fs, ROOT, ch1, decide) + applyParseResult(fs, ROOT, { ...ch1, characters: [] }, {}) + assert.equal(JSON.parse(fs.files.get('/c/world/places.json')!).length, 1) + }) + + test('缺少人物決定會拒絕', () => { + assert.throws(() => applyParseResult(memoryFs(), ROOT, ch1, {}), /缺少人物決定/) + }) +}) + +describe('planMerge', () => { + test('主要角色在前,並附疑似同一人的理由', () => { + const fs = memoryFs() + applyParseResult(fs, ROOT, ch1, decide) + const plan = planMerge(fs, ROOT, { + chapter: '2', + characters: [{ name: '路人乙', role: 'minor' }, { name: '葵', role: 'main', traits: ['tsundere', '短髮'] }], + }) + assert.equal(plan[0]!.name, '葵') + assert.equal(plan[0]!.candidates[0]!.id, '小葵') + assert.ok(plan[0]!.candidates[0]!.reasons.some(r => r.includes('別名'))) + assert.deepEqual(plan[1]!.candidates, []) + }) +}) + +describe('personality', () => { + test('推論限制在先驗 ±20 並要求證據', () => { + const p = initPersonality(['tsundere']) + const out = applyInference(p, { + A: { value: 100, evidence: [{ chapter: '1', quote: '才不是為了你' }] }, + N: { value: 0, evidence: [] }, + }) + assert.equal(out.dims.A.value, 55) + assert.equal(out.dims.N.value, p.dims.N.value) + assert.equal(out.dims.A.evidence.length, 1) + }) + + test('人工覆寫後鎖定,自動推導不再更新', () => { + let p = overrideDim(initPersonality(['genki']), 'E', 70) + p = applyInference(p, { E: { value: 95, evidence: [{ chapter: '1', quote: 'x' }] } }) + assert.equal(p.dims.E.value, 70) + }) +}) + +describe('summarizeTics', () => { + test('只採用重複出現的語尾與稱呼', () => { + const t = summarizeTics([ + { text: '先輩、今天也辛苦了にゃ', kind: 'speech', order: 1 }, + { text: '先輩はすごいにゃ', kind: 'speech', order: 2 }, + { text: '偶爾說一次', kind: 'monologue', order: 3 }, + ]) + assert.ok(t.endings.includes('にゃ')) + assert.ok(t.address.includes('先輩')) + }) +}) diff --git a/plugins/cockpit/hooks/companion/core/personality.ts b/plugins/cockpit/hooks/companion/core/personality.ts new file mode 100644 index 0000000..db63537 --- /dev/null +++ b/plugins/cockpit/hooks/companion/core/personality.ts @@ -0,0 +1,63 @@ +export const BIG5 = ['O', 'C', 'E', 'A', 'N'] as const +export type Dim = (typeof BIG5)[number] +export type Vec = Record + +export const MAX_DEVIATION = 20 + +/** 動漫屬性的 Big Five 先驗(設計啟發式,非學術標準) */ +export const ARCHETYPE_PRIORS: Record = { + tsundere: { O: 50, C: 55, E: 55, A: 35, N: 65 }, + genki: { O: 70, C: 40, E: 90, A: 70, N: 35 }, + kuudere: { O: 45, C: 65, E: 20, A: 45, N: 30 }, + dandere: { O: 50, C: 55, E: 15, A: 65, N: 55 }, + yandere: { O: 40, C: 50, E: 45, A: 25, N: 85 }, + onee: { O: 55, C: 65, E: 60, A: 80, N: 25 }, +} + +export interface Evidence { chapter: string; quote: string } +export interface DimState { value: number; prior: number; evidence: Evidence[]; locked: boolean } +export interface Personality { + archetypes: string[] + dims: Record + expressiveness: number + gap: number +} + +const clamp = (x: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, x)) + +export function priorFor(archetypes: string[]): Vec { + const known = archetypes.filter(a => ARCHETYPE_PRIORS[a]) + const out = {} as Vec + for (const d of BIG5) { + out[d] = known.length ? known.reduce((s, a) => s + ARCHETYPE_PRIORS[a]![d], 0) / known.length : 50 + } + return out +} + +export function initPersonality(archetypes: string[], expressiveness = 50, gap = 0): Personality { + const prior = priorFor(archetypes) + const dims = {} as Personality['dims'] + for (const d of BIG5) dims[d] = { value: prior[d], prior: prior[d], evidence: [], locked: false } + return { archetypes, dims, expressiveness, gap } +} + +/** 套用模型推論:限制在先驗 ±20、附證據;人工鎖定的維度不更新;缺證據不採用 */ +export function applyInference(p: Personality, inferred: Partial>): Personality { + const dims = { ...p.dims } + for (const d of BIG5) { + const inf = inferred[d] + const cur = dims[d] + if (!inf || cur.locked || !inf.evidence.length) continue + dims[d] = { + ...cur, + value: clamp(inf.value, cur.prior - MAX_DEVIATION, cur.prior + MAX_DEVIATION), + evidence: [...cur.evidence, ...inf.evidence], + } + } + return { ...p, dims } +} + +/** 人工覆寫並鎖定,之後自動推導不會改動 */ +export function overrideDim(p: Personality, dim: Dim, value: number): Personality { + return { ...p, dims: { ...p.dims, [dim]: { ...p.dims[dim], value: clamp(value, 0, 100), locked: true } } } +} diff --git a/plugins/cockpit/hooks/companion/core/speech.ts b/plugins/cockpit/hooks/companion/core/speech.ts new file mode 100644 index 0000000..e4ae053 --- /dev/null +++ b/plugins/cockpit/hooks/companion/core/speech.ts @@ -0,0 +1,20 @@ +import type { Line } from './types' + +export interface SpeechTics { endings: string[]; catchphrases: string[]; address: string[] } + +const ENDING = /([ぁ-んァ-ヶー]{1,2}|[啦喔啊呢吧嘛囉喲哦呀])[!?!?。…~~]*$/ +const CALL = /([^\s,。!?、「」]{0,6}(?:さん|くん|ちゃん|様|先輩|同學|大人|先生|小姐))/g + +/** 從對白與獨白歸納口癖:語尾、重複出現的口頭禪、稱呼;出現至少 minCount 次才採用 */ +export function summarizeTics(lines: Line[], minCount = 2): SpeechTics { + const count = (items: string[]) => { + const m = new Map() + for (const i of items) m.set(i, (m.get(i) ?? 0) + 1) + return [...m].filter(([, n]) => n >= minCount).sort((a, b) => b[1] - a[1]).map(([k]) => k) + } + const texts = lines.map(l => l.text.trim()).filter(Boolean) + const endings = count(texts.flatMap(t => { const m = ENDING.exec(t); return m ? [m[1]!] : [] })) + const address = count(texts.flatMap(t => [...t.matchAll(CALL)].map(m => m[1]!))) + const catchphrases = count(texts.flatMap(t => t.split(/[!?!?。…、,,\s]+/).filter(s => s.length >= 2 && s.length <= 8))) + return { endings, catchphrases, address } +} diff --git a/plugins/cockpit/hooks/companion/core/store.ts b/plugins/cockpit/hooks/companion/core/store.ts new file mode 100644 index 0000000..a9a68ec --- /dev/null +++ b/plugins/cockpit/hooks/companion/core/store.ts @@ -0,0 +1,19 @@ +import type { Fs } from './types' + +export function readJson(fs: Fs, path: string, fallback: T): T { + const raw = fs.read(path) + return raw === null ? fallback : (JSON.parse(raw) as T) +} + +export function writeJson(fs: Fs, path: string, data: unknown): void { + fs.write(path, JSON.stringify(data, null, 2)) +} + +export function memoryFs(): Fs & { files: Map } { + const files = new Map() + return { + files, + read: p => files.get(p) ?? null, + write: (p, d) => void files.set(p, d), + } +} diff --git a/plugins/cockpit/hooks/companion/core/types.ts b/plugins/cockpit/hooks/companion/core/types.ts new file mode 100644 index 0000000..1eb69b8 --- /dev/null +++ b/plugins/cockpit/hooks/companion/core/types.ts @@ -0,0 +1,58 @@ +export interface Fs { + read(path: string): string | null + /** 原子寫入:先寫暫存檔再改名 */ + write(path: string, data: string): void +} + +export type Source = 'novel' | 'official' | 'wiki' +export const SOURCE_RANK: Record = { novel: 3, official: 2, wiki: 1 } + +export const INFO_FIELDS = ['birthday', 'zodiac', 'bloodType', 'hair', 'face', 'height', 'weight', 'measurements'] as const +export type InfoField = (typeof INFO_FIELDS)[number] + +export interface InfoValue { + value: string + source: Source | null + confidence: number + chapter?: string +} +export type Info = Partial> + +export type Layer = 'episodic' | 'semantic' | 'emotional' | 'procedural' +export const LAYERS: Layer[] = ['episodic', 'semantic', 'emotional', 'procedural'] + +export interface MemoryInput { text: string; importance: number; order: number } +export interface Memory extends MemoryInput { id: string; chapter: string; layer: Layer } + +export interface Line { text: string; kind: 'speech' | 'monologue'; order: number } + +export interface ParsedCharacter { + name: string + aliases?: string[] + role: 'main' | 'minor' + traits?: string[] + info?: Info + memories?: Partial> + lines?: Line[] +} + +export interface WorldEntry { text: string; order: number } +export interface ParseResult { + chapter: string + world?: { timeline?: WorldEntry[]; places?: WorldEntry[]; terms?: WorldEntry[]; environment?: WorldEntry[] } + characters: ParsedCharacter[] +} + +export type Decision = { action: 'merge'; target: string } | { action: 'new' } | { action: 'skip' } + +export interface Candidate { id: string; reasons: string[] } +export interface PlanItem { name: string; role: 'main' | 'minor'; candidates: Candidate[] } + +export interface Conflict { character: string; field: InfoField; existing: InfoValue; incoming: InfoValue } + +export interface ApplyReport { + applied: string[] + skipped: string[] + alreadyApplied: string[] + conflicts: Conflict[] +} diff --git a/plugins/cockpit/skills/ingest/SKILL.md b/plugins/cockpit/skills/ingest/SKILL.md new file mode 100644 index 0000000..7492b76 --- /dev/null +++ b/plugins/cockpit/skills/ingest/SKILL.md @@ -0,0 +1,27 @@ +--- +name: ingest +description: 解析一章輕小說(txt 或 md),抽出世界觀與人物,逐一詢問合併、新建或略過,並寫入 cockpit 角色資料。使用者提供小說章節檔或要求建立/更新角色設定時使用。 +--- + +# 小說章節解析 + +輸入:單章 txt 或 md 與章節編號。不保存整章原文,只存結構化摘要與簡短對白範例(每句 ≤80 字)。 + +## 流程 + +1. **讀取與分段**:超過約 6000 字的章節依段落/場景切成多段,逐段抽取後合併為一份結果,出現順序(`order`)沿用全章連續編號。 +2. **抽取**:時間、地點、環境、用語(寫入共用世界觀庫);人物、對白、獨白(寫入角色)。人物標 `main`(有戲份的主要角色)或 `minor`(路人)。 +3. **整理成 `ParseResult`**(型別見 `hooks/companion/core/types.ts`): + - 記憶分四層:`episodic` 情節、`semantic` 語意、`emotional` 情感、`procedural` 程序;每筆含 `importance`(1–10)與 `order`。 + - 基本資訊欄位:birthday、zodiac、bloodType、hair、face、height、weight、measurements。每欄附 `source`(`novel` > `official` > `wiki`)與 `confidence`(0–1);原文沒寫的先搜尋,查不到填 `value: "unknown"`,不可猜測。 +4. **逐一詢問人物**:呼叫 `planMerge` 取得清單(主要角色在前,路人最後批次問)。每位問「合併 / 新建 / 略過」,並顯示疑似同一人的理由(名字、別名、特徵)。 +5. **套用**:呼叫 `applyParseResult(fs, root, result, decisions, resolutions)`。 + - 同一章重跑不會重複寫入(以章節編號冪等);合併只追加不覆蓋。 + - 回傳的 `conflicts` 是基本資訊衝突,**逐欄位**問使用者保留或取代,答案放進 `resolutions["人物.欄位"]`(`keep` / `replace`)後再套用一次。更新既有值前一律先問。 +6. **口癖與個性**:用 `summarizeTics` 由對白與獨白歸納語尾、口頭禪、稱呼;個性先用屬性標籤取先驗(`initPersonality`),再把模型推論交給 `applyInference`(自動限制在先驗 ±20,每個維度必須附章節與對白證據)。使用者手動調整的維度用 `overrideDim` 鎖定,之後不再自動更新。 + +## 注意 + +- 模型從對白推得的個性可信度偏低,只當先驗。 +- 不要在 repo 內保存受版權保護的長篇設定或台詞;資料寫在使用者家目錄的 cockpit 資料夾。 +- 範例完整流程見 `docs/ingest-walkthrough.md`。 -- 2.53.0