/** * 代码问答的存法:每个工作区文件夹一份 `/.spx/code-qa.json`,读写的规矩同文件注释(见 pathMap)。 * 形状是 `{ "相对路径": [{ anchor, entries: [{ id, question, answer, createdAt, sessionId }] }] }`: * 一份文件里挂着问答的每一块一项(anchor 见 BlockAnchors),块下面问过的按时间排。 */ import type { PathMap, PathMapFile } from '../pathMap' /** 问过的一次:问题、回答(Markdown)、什么时候问的(ISO 时间)、Claude Code 的会话 id。 */ export interface QaEntry { id: string question: string answer: string createdAt: string sessionId: string /** 用哪个 profile 答的(profile 名);选「默认」时、以前存的没有 */ profile?: string } /** 挂在一块上的问答。 */ export interface QaThread { anchor: string entries: QaEntry[] } export type QaMap = PathMap function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } function text(value: unknown): string | undefined { return typeof value === 'string' && value.trim() !== '' ? value : undefined } function parseEntry(value: unknown): QaEntry | undefined { if (!isRecord(value)) return undefined const [id, question, answer, createdAt, profile] = [text(value.id), text(value.question), text(value.answer), text(value.createdAt), text(value.profile)] if (!id || !question || !answer || !createdAt || Number.isNaN(Date.parse(createdAt))) return undefined return { id, question, answer, createdAt, sessionId: typeof value.sessionId === 'string' ? value.sessionId : '', ...(profile && { profile }) } } /** 同一块的并成一项;块按记号排,问答按时间排:写出来的顺序稳定,git diff 干净。 */ function tidy(threads: QaThread[]): QaThread[] { const byAnchor = new Map() for (const thread of threads) byAnchor.set(thread.anchor, [...byAnchor.get(thread.anchor) ?? [], ...thread.entries]) return [...byAnchor] .filter(([, entries]) => entries.length > 0) .sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0) .map(([anchor, entries]) => ({ anchor, entries: [...entries].sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)), })) } /** 一份文件的问答:不是数组、一项都认不出的当没有;认不出的块、问答跳过。 */ function parseThreads(value: unknown): QaThread[] | undefined { if (!Array.isArray(value)) return undefined const threads = value.flatMap((item): QaThread[] => { const anchor = isRecord(item) ? text(item.anchor) : undefined if (!anchor || !isRecord(item) || !Array.isArray(item.entries)) return [] return [{ anchor, entries: item.entries.flatMap(entry => parseEntry(entry) ?? []) }] }) const tidied = tidy(threads) return tidied.length > 0 ? tidied : undefined } export const QA_FILE: PathMapFile = { path: '.spx/code-qa.json', label: '代码问答', parse: parseThreads, } /** 在 key 这份文件的 anchor 那块下面记一次问答。 */ export function withEntry(map: QaMap, key: string, anchor: string, entry: QaEntry): QaMap { return { ...map, [key]: tidy([...map[key] ?? [], { anchor, entries: [entry] }]) } } /** 删掉一次问答;块下面删光了这块就不挂了,文件里删光了这份文件就不记了。 */ export function withoutEntry(map: QaMap, key: string, anchor: string, entryId: string): QaMap { const threads = tidy((map[key] ?? []).map(thread => thread.anchor === anchor ? { anchor, entries: thread.entries.filter(entry => entry.id !== entryId) } : thread)) const next = { ...map } delete next[key] return threads.length > 0 ? { ...next, [key]: threads } : next } /** 再问 anchor 那块时接着的会话:这块最近一次问答的会话 id;没问过、或那次没记会话 id 时没有,重新开会话。 */ export function latestSession(threads: readonly QaThread[] | undefined, anchor: string): string | undefined { return threads?.find(thread => thread.anchor === anchor)?.entries.at(-1)?.sessionId || undefined }