types.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387
  1. import type { AgentMessage } from "@/lib/agent/types"
  2. import type { DataSourceCategory } from "@/lib/novel/classification"
  3. import type { ContextPack } from "@/lib/novel/context-engine"
  4. import {
  5. copyLlmRequestCacheTrace,
  6. isLlmRequestCacheTrace,
  7. type LlmRequestCacheTrace,
  8. } from "@/lib/llm-request-trace"
  9. export const CONTEXT_CACHE_SCHEMA_VERSION = 2
  10. export type ContextSurface = "ai-chat" | "ai-outline"
  11. export type ContextIntent = "generate" | "question" | "review" | "lint"
  12. export type ContextSourceKind =
  13. | "chapter"
  14. | "outline"
  15. | "memory"
  16. | "setting"
  17. | "entity"
  18. | "snapshot"
  19. | "deduction"
  20. | "soul"
  21. | "book-analysis"
  22. | "retrieval"
  23. | "other"
  24. | "ignored"
  25. export interface DependencyStamp {
  26. fingerprint: string
  27. sourceCount: number
  28. kinds: ContextSourceKind[]
  29. }
  30. export type ContextCacheScope = "static" | "chapter" | "task"
  31. export interface SourceVersion {
  32. path: string
  33. kind: ContextSourceKind
  34. mtimeMs?: number
  35. size?: number
  36. hash?: string
  37. revision: number
  38. }
  39. export interface CachedArtifact<T = unknown> {
  40. schemaVersion: number
  41. key: string
  42. sourceName: string
  43. scope: ContextCacheScope
  44. value: T
  45. dependencyStamp: DependencyStamp
  46. createdAt: number
  47. }
  48. export interface StableBundle {
  49. schemaVersion: number
  50. surface: ContextSurface
  51. text: string
  52. dependencyStamp: DependencyStamp
  53. updatedAt: number
  54. }
  55. export interface ContextCacheArtifactEntry {
  56. path: string
  57. sourceName: string
  58. scope: ContextCacheScope
  59. dependencyStamp: DependencyStamp
  60. createdAt: number
  61. byteSize: number
  62. }
  63. export interface ContextCacheManifest {
  64. schemaVersion: number
  65. sources: Record<string, SourceVersion>
  66. artifacts: Record<string, ContextCacheArtifactEntry>
  67. }
  68. export interface SessionContextSummary {
  69. text: string
  70. dependencyFingerprint?: string
  71. updatedAt: number
  72. }
  73. export type StablePrefixStatus = "unchanged" | "updated" | "persist_failed"
  74. export type ContextFragmentDisposition =
  75. | "kept"
  76. | "truncated"
  77. | "budget_dropped"
  78. | "policy_excluded"
  79. export interface ContextFragmentTrace {
  80. title: string
  81. layer: "stable" | "summary" | "dynamic"
  82. disposition: ContextFragmentDisposition
  83. candidateEstimatedTokens: number
  84. injectedEstimatedTokens: number
  85. }
  86. export interface LlmRequestDiagnostics {
  87. requestCount: number
  88. /** False when the provider only exposes aggregate usage without an internal request count. */
  89. requestCountAvailable?: boolean
  90. /** Distinguishes a normal workflow aggregate from a provider-managed thread total. */
  91. usageScope?: "workflow" | "provider_thread"
  92. providerUsageAvailable: boolean
  93. inputTokens?: number
  94. outputTokens?: number
  95. cacheReadTokens?: number
  96. cacheWriteTokens?: number
  97. requests?: LlmRequestCacheTrace[]
  98. omittedRequestCount?: number
  99. }
  100. /** Generation-details stats: source traces + independent stablePrefixStatus; token fields are estimates. */
  101. export interface ContextHubStats {
  102. cacheHits: number
  103. reloaded: number
  104. empty: number
  105. fallbackUsed: number
  106. readFailed: number
  107. writeFailed: number
  108. /** 缓存命中项内容的估算 token 之和(用户可见的「节省 token」)。旧快照缺失时按 0 处理。 */
  109. cacheHitTokens?: number
  110. /** 本轮加载的任务级(查询依赖)数据源数量;不计入可缓存命中率。旧快照缺失时按 0 处理。 */
  111. taskScopedLoaded?: number
  112. /** 本轮任务级数据源的缓存命中数;从可缓存命中率分子中扣除。旧快照缺失时按 0 处理。 */
  113. taskScopedHits?: number
  114. stablePrefixStatus?: StablePrefixStatus
  115. /** Estimated tokens (local heuristic). */
  116. stableTokens: number
  117. summaryTokens: number
  118. dynamicTokens: number
  119. candidateTokens: number
  120. estimatedSavedTokens: number
  121. estimatedSavedPercent: number
  122. expanded: boolean
  123. providerCacheEnabled: boolean
  124. providerUsageReported?: boolean
  125. providerInputTokens?: number
  126. providerCachedTokens?: number
  127. providerCacheWriteTokens?: number
  128. /** Hub injection budget (estimated domain). */
  129. budgetTokens?: number
  130. /** Hub injection total (estimated); not full-window occupancy. */
  131. composedTokens?: number
  132. utilizationPercent?: number
  133. memoryCandidateCount?: number
  134. memorySelectedCount?: number
  135. memoryFilteredCount?: number
  136. memoryInjectedChars?: number
  137. memoryEstimatedTokens?: number
  138. fragmentTraces?: ContextFragmentTrace[]
  139. requestDiagnostics?: LlmRequestDiagnostics
  140. }
  141. export type ContextCacheItemStatus =
  142. | "cache_hit"
  143. | "reloaded"
  144. | "empty"
  145. | "fallback_used"
  146. | "read_failed"
  147. | "write_failed"
  148. export type ContextSourceTraceStatus = ContextCacheItemStatus
  149. export interface ContextCacheItemTrace {
  150. key: string
  151. sourceName: string
  152. status: ContextCacheItemStatus
  153. dependencyStamp: DependencyStamp
  154. dependencyPaths: string[]
  155. dependencyPathsTruncated: boolean
  156. }
  157. export interface ContextHubSnapshotRef {
  158. id: string
  159. surface: ContextSurface
  160. createdAt: number
  161. stats: ContextHubStats
  162. }
  163. export interface ContextHubSnapshot extends ContextHubSnapshotRef {
  164. schemaVersion: number
  165. items: ContextCacheItemTrace[]
  166. stableCore: string
  167. sessionSummary: string
  168. dynamicContext: string
  169. warnings?: string[]
  170. }
  171. export interface ContextHubRequest {
  172. projectPath: string
  173. surface: ContextSurface
  174. sessionId: string
  175. task: string
  176. intent: ContextIntent
  177. chapterNumber?: number
  178. categories?: DataSourceCategory[]
  179. references?: string[]
  180. messages?: AgentMessage[]
  181. existingSummary?: SessionContextSummary
  182. /** Explicit token budget; 0 / undefined = window-derived safe cap. */
  183. tokenBudget?: number
  184. /** Model context window in tokens (wiki-store `maxContextSize`). */
  185. maxContextSize?: number
  186. forceRefresh?: boolean
  187. }
  188. export interface ContextHubResult {
  189. surface: ContextSurface
  190. stableCore: string
  191. sessionSummary: string
  192. dynamicContext: string
  193. contextPack: ContextPack
  194. dependencyStamp: DependencyStamp
  195. stats: ContextHubStats
  196. cacheItems: ContextCacheItemTrace[]
  197. warnings: string[]
  198. readFile: (path: string) => Promise<string>
  199. }
  200. export interface ContextHub {
  201. prepare(request: ContextHubRequest): Promise<ContextHubResult | null>
  202. readFile(path: string): Promise<string>
  203. saveSnapshot(id: string, result: ContextHubResult): Promise<ContextHubSnapshotRef>
  204. readSnapshot(reference: ContextHubSnapshotRef): Promise<ContextHubSnapshot | null>
  205. pruneSnapshots(surface: ContextSurface, referencedIds: string[]): Promise<void>
  206. markDirty(path: string): void
  207. dispose(): void
  208. }
  209. const CURRENT_ITEM_STATUSES = new Set<ContextCacheItemStatus>([
  210. "cache_hit",
  211. "reloaded",
  212. "empty",
  213. "fallback_used",
  214. "read_failed",
  215. "write_failed",
  216. ])
  217. function isFiniteNumber(value: unknown): value is number {
  218. return typeof value === "number" && Number.isFinite(value)
  219. }
  220. function normalizeRequestDiagnostics(value: unknown): LlmRequestDiagnostics | undefined {
  221. if (!value || typeof value !== "object") return undefined
  222. const source = value as Record<string, unknown>
  223. if (!isFiniteNumber(source.requestCount) || typeof source.providerUsageAvailable !== "boolean") {
  224. return undefined
  225. }
  226. const optionalNumber = (key: string) => source[key] === undefined || isFiniteNumber(source[key])
  227. if (
  228. !optionalNumber("inputTokens")
  229. || !optionalNumber("outputTokens")
  230. || !optionalNumber("cacheReadTokens")
  231. || !optionalNumber("cacheWriteTokens")
  232. ) return undefined
  233. const requests = Array.isArray(source.requests)
  234. ? source.requests.filter(isLlmRequestCacheTrace).map(copyLlmRequestCacheTrace)
  235. : undefined
  236. const requestCountAvailable = typeof source.requestCountAvailable === "boolean"
  237. ? source.requestCountAvailable
  238. : undefined
  239. const usageScope = source.usageScope === "workflow" || source.usageScope === "provider_thread"
  240. ? source.usageScope
  241. : undefined
  242. return {
  243. requestCount: Math.max(0, Math.floor(source.requestCount)),
  244. ...(requestCountAvailable !== undefined ? { requestCountAvailable } : {}),
  245. ...(usageScope ? { usageScope } : {}),
  246. providerUsageAvailable: source.providerUsageAvailable,
  247. ...(isFiniteNumber(source.inputTokens) ? { inputTokens: source.inputTokens } : {}),
  248. ...(isFiniteNumber(source.outputTokens) ? { outputTokens: source.outputTokens } : {}),
  249. ...(isFiniteNumber(source.cacheReadTokens) ? { cacheReadTokens: source.cacheReadTokens } : {}),
  250. ...(isFiniteNumber(source.cacheWriteTokens) ? { cacheWriteTokens: source.cacheWriteTokens } : {}),
  251. ...(requests ? { requests } : {}),
  252. ...(isFiniteNumber(source.omittedRequestCount)
  253. ? { omittedRequestCount: Math.max(0, Math.floor(source.omittedRequestCount)) }
  254. : {}),
  255. }
  256. }
  257. function normalizeContextHubStats(source: ContextHubStats): ContextHubStats {
  258. const { requestDiagnostics: _requestDiagnostics, ...rest } = source
  259. const requestDiagnostics = normalizeRequestDiagnostics(source.requestDiagnostics)
  260. return {
  261. ...rest,
  262. ...(requestDiagnostics ? { requestDiagnostics } : {}),
  263. }
  264. }
  265. /** True only for the current stats shape. Legacy hits/refreshed/failures payloads are rejected. */
  266. export function isCurrentContextHubStats(raw: unknown): raw is ContextHubStats {
  267. if (!raw || typeof raw !== "object") return false
  268. const source = raw as Record<string, unknown>
  269. return isFiniteNumber(source.cacheHits)
  270. && isFiniteNumber(source.reloaded)
  271. && isFiniteNumber(source.empty)
  272. && isFiniteNumber(source.fallbackUsed)
  273. && isFiniteNumber(source.readFailed)
  274. && isFiniteNumber(source.writeFailed)
  275. && isFiniteNumber(source.stableTokens)
  276. && isFiniteNumber(source.summaryTokens)
  277. && isFiniteNumber(source.dynamicTokens)
  278. && isFiniteNumber(source.candidateTokens)
  279. && isFiniteNumber(source.estimatedSavedTokens)
  280. && isFiniteNumber(source.estimatedSavedPercent)
  281. && typeof source.expanded === "boolean"
  282. && typeof source.providerCacheEnabled === "boolean"
  283. }
  284. function isCurrentCacheItemStatus(status: unknown): status is ContextCacheItemStatus {
  285. return typeof status === "string" && CURRENT_ITEM_STATUSES.has(status as ContextCacheItemStatus)
  286. }
  287. /** Disk/UI parse: reject legacy or corrupt snapshots instead of remapping fields. */
  288. export function parseContextHubSnapshot(raw: unknown): ContextHubSnapshot | null {
  289. if (!raw || typeof raw !== "object") return null
  290. const source = raw as Record<string, unknown>
  291. if (
  292. source.schemaVersion !== CONTEXT_CACHE_SCHEMA_VERSION
  293. || typeof source.id !== "string"
  294. || (source.surface !== "ai-chat" && source.surface !== "ai-outline")
  295. || typeof source.createdAt !== "number"
  296. || typeof source.stableCore !== "string"
  297. || typeof source.sessionSummary !== "string"
  298. || typeof source.dynamicContext !== "string"
  299. || !Array.isArray(source.items)
  300. || !isCurrentContextHubStats(source.stats)
  301. ) return null
  302. if (source.warnings !== undefined && !Array.isArray(source.warnings)) return null
  303. const items: ContextCacheItemTrace[] = []
  304. for (const item of source.items) {
  305. if (!item || typeof item !== "object") return null
  306. const entry = item as Record<string, unknown>
  307. if (
  308. typeof entry.key !== "string"
  309. || typeof entry.sourceName !== "string"
  310. || !isCurrentCacheItemStatus(entry.status)
  311. || !entry.dependencyStamp
  312. || typeof entry.dependencyStamp !== "object"
  313. || !Array.isArray(entry.dependencyPaths)
  314. ) return null
  315. items.push({
  316. key: entry.key,
  317. sourceName: entry.sourceName,
  318. status: entry.status,
  319. dependencyStamp: entry.dependencyStamp as DependencyStamp,
  320. dependencyPaths: entry.dependencyPaths.filter((path): path is string => typeof path === "string"),
  321. dependencyPathsTruncated: Boolean(entry.dependencyPathsTruncated),
  322. })
  323. }
  324. return {
  325. schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
  326. id: source.id,
  327. surface: source.surface,
  328. createdAt: source.createdAt,
  329. stats: normalizeContextHubStats(source.stats as ContextHubStats),
  330. items,
  331. stableCore: source.stableCore,
  332. sessionSummary: source.sessionSummary,
  333. dynamicContext: source.dynamicContext,
  334. ...(Array.isArray(source.warnings)
  335. ? { warnings: source.warnings.filter((warning): warning is string => typeof warning === "string") }
  336. : {}),
  337. }
  338. }
  339. export function parseContextHubSnapshotRef(raw: unknown): ContextHubSnapshotRef | null {
  340. if (!raw || typeof raw !== "object") return null
  341. const source = raw as Record<string, unknown>
  342. if (
  343. typeof source.id !== "string"
  344. || (source.surface !== "ai-chat" && source.surface !== "ai-outline")
  345. || typeof source.createdAt !== "number"
  346. || !isCurrentContextHubStats(source.stats)
  347. ) return null
  348. return {
  349. id: source.id,
  350. surface: source.surface,
  351. createdAt: source.createdAt,
  352. stats: normalizeContextHubStats(source.stats as ContextHubStats),
  353. }
  354. }