dsh-data-domain-knowledge 0.1.1-alpha.2 → 0.1.1-alpha.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (252) hide show
  1. package/README.md +33 -16
  2. package/lib/client.js +1042 -2097
  3. package/lib/contract-refs.d.ts +0 -1
  4. package/lib/doc-page.js +1 -1
  5. package/lib/pages.js +2489 -1060
  6. package/lib/types/agent-behavior-rules.d.ts.map +1 -1
  7. package/lib/types/agent-behavior-rules.js +3 -2
  8. package/lib/types/agent-behavior-rules.js.map +1 -1
  9. package/lib/types/axis-contract.js +1 -1
  10. package/lib/types/axis-contract.js.map +1 -1
  11. package/lib/types/card-organize.d.ts +2 -1
  12. package/lib/types/card-organize.d.ts.map +1 -1
  13. package/lib/types/card-organize.js +5 -3
  14. package/lib/types/card-organize.js.map +1 -1
  15. package/lib/types/config-schema.d.ts +1 -1
  16. package/lib/types/config-schema.js +1 -1
  17. package/lib/types/convert/host.d.ts +2 -3
  18. package/lib/types/convert/host.d.ts.map +1 -1
  19. package/lib/types/convert/host.js +11 -2
  20. package/lib/types/convert/host.js.map +1 -1
  21. package/lib/types/convert/ingest.d.ts +14 -20
  22. package/lib/types/convert/ingest.d.ts.map +1 -1
  23. package/lib/types/convert/ingest.js +8 -5
  24. package/lib/types/convert/ingest.js.map +1 -1
  25. package/lib/types/diagnosis/store.d.ts +2 -2
  26. package/lib/types/diagnosis/store.d.ts.map +1 -1
  27. package/lib/types/diagnosis/store.js +10 -2
  28. package/lib/types/diagnosis/store.js.map +1 -1
  29. package/lib/types/diagnosis/types.d.ts +1 -1
  30. package/lib/types/diagnosis/types.js +1 -1
  31. package/lib/types/extract-model.d.ts +3 -0
  32. package/lib/types/extract-model.d.ts.map +1 -1
  33. package/lib/types/extract-model.js.map +1 -1
  34. package/lib/types/images.js +1 -1
  35. package/lib/types/index.d.ts +0 -4
  36. package/lib/types/index.d.ts.map +1 -1
  37. package/lib/types/index.js +99 -91
  38. package/lib/types/index.js.map +1 -1
  39. package/lib/types/knowledge-bases.d.ts +3 -0
  40. package/lib/types/knowledge-bases.d.ts.map +1 -1
  41. package/lib/types/knowledge-bases.js +22 -2
  42. package/lib/types/knowledge-bases.js.map +1 -1
  43. package/lib/types/knowledge-contract.d.ts +6 -0
  44. package/lib/types/knowledge-contract.d.ts.map +1 -1
  45. package/lib/types/knowledge-contract.js +6 -2
  46. package/lib/types/knowledge-contract.js.map +1 -1
  47. package/lib/types/layout.d.ts +9 -26
  48. package/lib/types/layout.d.ts.map +1 -1
  49. package/lib/types/layout.js +10 -28
  50. package/lib/types/layout.js.map +1 -1
  51. package/lib/types/materialize-snapshot.d.ts +5 -0
  52. package/lib/types/materialize-snapshot.d.ts.map +1 -1
  53. package/lib/types/migrate.d.ts +3 -1
  54. package/lib/types/migrate.d.ts.map +1 -1
  55. package/lib/types/migrate.js +3 -1
  56. package/lib/types/migrate.js.map +1 -1
  57. package/lib/types/model-text.d.ts +7 -0
  58. package/lib/types/model-text.d.ts.map +1 -1
  59. package/lib/types/model-text.js +31 -9
  60. package/lib/types/model-text.js.map +1 -1
  61. package/lib/types/organize-contract.d.ts.map +1 -1
  62. package/lib/types/organize-contract.js +2 -1
  63. package/lib/types/organize-contract.js.map +1 -1
  64. package/lib/types/pipeline/engine.d.ts +28 -4
  65. package/lib/types/pipeline/engine.d.ts.map +1 -1
  66. package/lib/types/pipeline/engine.js +68 -15
  67. package/lib/types/pipeline/engine.js.map +1 -1
  68. package/lib/types/pipeline/progress.d.ts +12 -0
  69. package/lib/types/pipeline/progress.d.ts.map +1 -0
  70. package/lib/types/pipeline/progress.js +37 -0
  71. package/lib/types/pipeline/progress.js.map +1 -0
  72. package/lib/types/pipeline/resumable-extract.d.ts +45 -0
  73. package/lib/types/pipeline/resumable-extract.d.ts.map +1 -0
  74. package/lib/types/pipeline/resumable-extract.js +262 -0
  75. package/lib/types/pipeline/resumable-extract.js.map +1 -0
  76. package/lib/types/pipeline/store.d.ts +8 -2
  77. package/lib/types/pipeline/store.d.ts.map +1 -1
  78. package/lib/types/pipeline/store.js +27 -5
  79. package/lib/types/pipeline/store.js.map +1 -1
  80. package/lib/types/pipeline/types.d.ts +56 -0
  81. package/lib/types/pipeline/types.d.ts.map +1 -1
  82. package/lib/types/pipeline/types.js +18 -1
  83. package/lib/types/pipeline/types.js.map +1 -1
  84. package/lib/types/present.d.ts +1 -1
  85. package/lib/types/present.js +1 -1
  86. package/lib/types/preset-ids.d.ts +0 -1
  87. package/lib/types/preset-ids.d.ts.map +1 -1
  88. package/lib/types/preset-ids.js +0 -3
  89. package/lib/types/preset-ids.js.map +1 -1
  90. package/lib/types/query-analysis.d.ts +0 -5
  91. package/lib/types/query-analysis.d.ts.map +1 -1
  92. package/lib/types/query-analysis.js +0 -16
  93. package/lib/types/query-analysis.js.map +1 -1
  94. package/lib/types/recall-delivery.d.ts +36 -0
  95. package/lib/types/recall-delivery.d.ts.map +1 -0
  96. package/lib/types/recall-delivery.js +16 -0
  97. package/lib/types/recall-delivery.js.map +1 -0
  98. package/lib/types/retrieval/scope.d.ts +17 -0
  99. package/lib/types/retrieval/scope.d.ts.map +1 -0
  100. package/lib/types/retrieval/scope.js +11 -0
  101. package/lib/types/retrieval/scope.js.map +1 -0
  102. package/lib/types/runtime.d.ts +75 -14
  103. package/lib/types/runtime.d.ts.map +1 -1
  104. package/lib/types/runtime.js +454 -198
  105. package/lib/types/runtime.js.map +1 -1
  106. package/lib/types/settings-volatile.d.ts +0 -7
  107. package/lib/types/settings-volatile.d.ts.map +1 -1
  108. package/lib/types/settings-volatile.js +0 -7
  109. package/lib/types/settings-volatile.js.map +1 -1
  110. package/lib/types/source-identity.d.ts +34 -0
  111. package/lib/types/source-identity.d.ts.map +1 -0
  112. package/lib/types/source-identity.js +85 -0
  113. package/lib/types/source-identity.js.map +1 -0
  114. package/lib/types/source-read.d.ts +18 -0
  115. package/lib/types/source-read.d.ts.map +1 -0
  116. package/lib/types/source-read.js +49 -0
  117. package/lib/types/source-read.js.map +1 -0
  118. package/lib/types/store/axis-references.d.ts.map +1 -1
  119. package/lib/types/store/axis-references.js +5 -3
  120. package/lib/types/store/axis-references.js.map +1 -1
  121. package/lib/types/store/knowledge-db.d.ts +5 -29
  122. package/lib/types/store/knowledge-db.d.ts.map +1 -1
  123. package/lib/types/store/knowledge-db.js +57 -2
  124. package/lib/types/store/knowledge-db.js.map +1 -1
  125. package/lib/types/store/source-identity.d.ts +8 -0
  126. package/lib/types/store/source-identity.d.ts.map +1 -0
  127. package/lib/types/store/source-identity.js +56 -0
  128. package/lib/types/store/source-identity.js.map +1 -0
  129. package/lib/types/types.d.ts +46 -5
  130. package/lib/types/types.d.ts.map +1 -1
  131. package/lib/types/types.js +6 -3
  132. package/lib/types/types.js.map +1 -1
  133. package/lib/types/wiki-availability.d.ts +11 -0
  134. package/lib/types/wiki-availability.d.ts.map +1 -0
  135. package/lib/types/wiki-availability.js +39 -0
  136. package/lib/types/wiki-availability.js.map +1 -0
  137. package/lib/types/wiki-finalize.d.ts +4 -2
  138. package/lib/types/wiki-finalize.d.ts.map +1 -1
  139. package/lib/types/wiki-finalize.js +9 -27
  140. package/lib/types/wiki-finalize.js.map +1 -1
  141. package/lib/types/wiki-links.d.ts +35 -0
  142. package/lib/types/wiki-links.d.ts.map +1 -0
  143. package/lib/types/wiki-links.js +129 -0
  144. package/lib/types/wiki-links.js.map +1 -0
  145. package/lib/types/wiki-trial-types.d.ts +3 -0
  146. package/lib/types/wiki-trial-types.d.ts.map +1 -1
  147. package/lib/types/wiki-trial.d.ts +3 -3
  148. package/lib/types/wiki-trial.d.ts.map +1 -1
  149. package/lib/types/wiki-trial.js +14 -3
  150. package/lib/types/wiki-trial.js.map +1 -1
  151. package/package.json +16 -16
  152. package/scripts/doctor.mjs +0 -7
  153. package/scripts/setup-embedding.sh +0 -0
  154. package/src/agent-behavior-rules.ts +3 -2
  155. package/src/axis-contract.ts +1 -1
  156. package/src/card-organize.ts +5 -3
  157. package/src/client/AttributionEventGuide.tsx +3 -3
  158. package/src/client/AxisMatrix.tsx +86 -19
  159. package/src/client/AxisRegistrySection.tsx +1 -1
  160. package/src/client/CompileSettingsView.tsx +473 -0
  161. package/src/client/DirectoryPicker.tsx +0 -2
  162. package/src/client/ExtractionTasksPage.tsx +557 -423
  163. package/src/client/GuideArchitectureCanvas.tsx +5 -12
  164. package/src/client/ImportFilesDialog.tsx +1 -0
  165. package/src/client/InfoHint.tsx +1 -1
  166. package/src/client/KnowledgeBaseTab.tsx +141 -148
  167. package/src/client/KnowledgePanel.tsx +16 -9
  168. package/src/client/OperationLogTab.tsx +1 -1
  169. package/src/client/PipelineRunView.tsx +173 -0
  170. package/src/client/RecallPanel.tsx +5 -16
  171. package/src/client/TabGuide.tsx +3 -7
  172. package/src/client/WikiFlowPanel.tsx +10 -11
  173. package/src/client/arch/NodeLogicPanel.tsx +3 -3
  174. package/src/client/arch/model.ts +5 -113
  175. package/src/client/architecture.css +1 -1
  176. package/src/client/architecture.tsx +13 -27
  177. package/src/client/attribution/AttributionTab.tsx +141 -10
  178. package/src/client/pages.css +305 -180
  179. package/src/client/pages.tsx +7 -12
  180. package/src/client/styles.css +15 -96
  181. package/src/config-schema.ts +1 -1
  182. package/src/convert/host.ts +13 -5
  183. package/src/convert/ingest.ts +20 -7
  184. package/src/diagnosis/store.ts +7 -2
  185. package/src/diagnosis/types.ts +1 -1
  186. package/src/extract-model.ts +3 -0
  187. package/src/images.ts +1 -1
  188. package/src/index.ts +92 -97
  189. package/src/knowledge-bases.ts +23 -3
  190. package/src/knowledge-contract.ts +7 -2
  191. package/src/layout.ts +10 -32
  192. package/src/materialize-snapshot.ts +1 -0
  193. package/src/migrate.ts +3 -1
  194. package/src/model-text.ts +33 -11
  195. package/src/organize-contract.ts +2 -1
  196. package/src/pipeline/engine.ts +85 -22
  197. package/src/pipeline/progress.ts +40 -0
  198. package/src/pipeline/resumable-extract.ts +233 -0
  199. package/src/pipeline/store.ts +28 -6
  200. package/src/pipeline/types.ts +47 -2
  201. package/src/present.ts +1 -1
  202. package/src/preset-ids.ts +0 -4
  203. package/src/query-analysis.ts +0 -20
  204. package/src/recall-delivery.ts +19 -0
  205. package/src/retrieval/scope.ts +10 -0
  206. package/src/runtime.ts +412 -206
  207. package/src/settings-volatile.ts +0 -7
  208. package/src/source-identity.ts +85 -0
  209. package/src/source-read.ts +47 -0
  210. package/src/store/axis-references.ts +5 -3
  211. package/src/store/knowledge-db.ts +54 -2
  212. package/src/store/source-identity.ts +42 -0
  213. package/src/types.ts +18 -5
  214. package/src/wiki-availability.ts +37 -0
  215. package/src/wiki-finalize.ts +9 -25
  216. package/src/wiki-links.ts +120 -0
  217. package/src/wiki-trial-types.ts +3 -2
  218. package/src/wiki-trial.ts +17 -6
  219. package/lib/types/intent/engine.d.ts +0 -122
  220. package/lib/types/intent/engine.d.ts.map +0 -1
  221. package/lib/types/intent/engine.js +0 -488
  222. package/lib/types/intent/engine.js.map +0 -1
  223. package/lib/types/intent/host.d.ts +0 -68
  224. package/lib/types/intent/host.d.ts.map +0 -1
  225. package/lib/types/intent/host.js +0 -249
  226. package/lib/types/intent/host.js.map +0 -1
  227. package/lib/types/intent/scope.d.ts +0 -25
  228. package/lib/types/intent/scope.d.ts.map +0 -1
  229. package/lib/types/intent/scope.js +0 -116
  230. package/lib/types/intent/scope.js.map +0 -1
  231. package/lib/types/intent/store.d.ts +0 -33
  232. package/lib/types/intent/store.d.ts.map +0 -1
  233. package/lib/types/intent/store.js +0 -239
  234. package/lib/types/intent/store.js.map +0 -1
  235. package/lib/types/intent/tools.d.ts +0 -62
  236. package/lib/types/intent/tools.d.ts.map +0 -1
  237. package/lib/types/intent/tools.js +0 -130
  238. package/lib/types/intent/tools.js.map +0 -1
  239. package/lib/types/intent/types.d.ts +0 -170
  240. package/lib/types/intent/types.d.ts.map +0 -1
  241. package/lib/types/intent/types.js +0 -56
  242. package/lib/types/intent/types.js.map +0 -1
  243. package/src/client/IntentLogic.tsx +0 -192
  244. package/src/client/IntentTab.tsx +0 -343
  245. package/src/client/TypeDefinitionDrawer.tsx +0 -195
  246. package/src/intent/README.md +0 -42
  247. package/src/intent/engine.ts +0 -515
  248. package/src/intent/host.ts +0 -315
  249. package/src/intent/scope.ts +0 -125
  250. package/src/intent/store.ts +0 -218
  251. package/src/intent/tools.ts +0 -154
  252. package/src/intent/types.ts +0 -229
package/src/runtime.ts CHANGED
@@ -1,4 +1,12 @@
1
- import { wikiProjection, procedureText, type ProcedureContent } from './knowledge-contract.js'
1
+ import { chooseSourceId } from './store/source-identity.js'
2
+ import { readSourceExact, SourceReadError, type SourceReadInput } from './source-read.js'
3
+ import { matchesScope, scopeError, type KnowledgeScope } from './retrieval/scope.js'
4
+ import { runResumableExtraction, EXTRACTION_PLAN_VERSION } from './pipeline/resumable-extract.js'
5
+ import { compileWikiNavigation, scanWikiLinks, stripWikiNavigation, wikiIndexContent, wikiLinkCandidates, wikiReference, type WikiReference } from './wiki-links.js'
6
+ import { wikiProjection, wikiGroupKey, procedureText, type ProcedureContent, type WikiKind } from './knowledge-contract.js'
7
+ import { wikiUnavailableReason } from './wiki-availability.js'
8
+ import { SourceIdentityError, resolveSourceIdentities, normalizeSourceUrl, type SourceIdentityInput, type SourceIdentity, isSourceId } from './source-identity.js'
9
+ import { agentContextItem } from './recall-delivery.js'
2
10
  import { buildDeriveSystem, DERIVE_CONTRACT_VERSION } from './derive-contract.js'
3
11
  import { createHash, randomUUID } from 'node:crypto'
4
12
  import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
@@ -34,13 +42,13 @@ import { buildAliasIndex, candidateEntityId, extractCandidates, normalizeCandida
34
42
  import { skeletonBody, type ModelCall } from './extract-model.js'
35
43
  import { applyTrialAxisResolution, cardNameFor, normalizeKnowledge, runWikiTrial, trialSummary, locateQuote, type AxisPromptBlock, type TrialAxisSet, type TrialAxisSetResolution } from './wiki-trial.js'
36
44
  import { EXTRACTION_CONTRACT_VERSION } from './extract-table.js'
37
- import { KnowledgePipeline, type PipelineContext, type PipelineSource } from './pipeline/engine.js'
45
+ import { KnowledgePipeline, type PipelineContext, type PipelineDeriveProgress, type PipelineSource } from './pipeline/engine.js'
38
46
  import { PipelineStore, type ExtractionCheckpoint } from './pipeline/store.js'
39
- import type { MaterializationGroup, PipelineStatus } from './pipeline/types.js'
47
+ import { normalizeExtractionTimeoutMinutes, type ExtractionTimeoutMinutes, type MaterializationGroup, type PipelineItemState, type PipelineStatus } from './pipeline/types.js'
40
48
  import type { MaterializationSnapshot } from './materialize-snapshot.js'
41
49
  import { candidatesFromReport, coordinateIdsOf, coordinateIsEmpty, type MaterializedCard } from './wiki-materialize.js'
42
50
  import {
43
- sectionRecords,
51
+ sectionRecords, ORGANIZE_VERSION,
44
52
  buildOrganizePrompt, buildOrganizeSystem, checkOrganize, citationsFrom, layoutHash, layoutOf,
45
53
  parseOrganize, placementsOf, renderFootnoteDefinitions, renderOrganizeBody,
46
54
  type CardCitationRecord, type CardLayoutRecord, type OrganizeInput, type OrganizePlan,
@@ -597,7 +605,9 @@ function recallKnowledgeOf(row: Knowledge, section: string): RecallKnowledge {
597
605
  ...(first?.toVersion === undefined ? {} : { sourceVersion: first.toVersion }),
598
606
  ...(first?.currentVersion === undefined ? {} : { currentSourceVersion: first.currentVersion }),
599
607
  ...(first?.quote ? { quote: first.quote } : {}),
600
- ...(first?.documentId ? { sourceId: first.documentId } : {}),
608
+ ...(first?.documentId ? { sourceId: first.documentId, readRef: { source_id: first.documentId,
609
+ ...(first.toVersion === undefined ? {} : { version: first.toVersion }),
610
+ ...(first.locator ? { start: first.locator.start, end: first.locator.end } : {}), quote: first.quote } } : {}),
601
611
  ...(first && first.headingPath.length > 0 ? { headingPath: first.headingPath } : {}),
602
612
  }
603
613
  }
@@ -651,6 +661,11 @@ const NO_DELETION: DocumentDeletion = {
651
661
  }
652
662
 
653
663
  export interface ImportedSourceReport {
664
+ reusedSource?: boolean
665
+ proposedSourceId?: string
666
+ sourceIdentity?: SourceIdentity[]
667
+ version?: number
668
+ readRef?: { kb?: string; source_id: string; version?: number; content_hash: string }
654
669
  index: number
655
670
  sourceId: string
656
671
  title?: string
@@ -661,6 +676,7 @@ export interface ImportedSourceReport {
661
676
 
662
677
  /** 批量导入里失败的一条。**带着原因回来**,因为局部失败要求调用方能只重试那几条。 */
663
678
  export interface ImportFailure {
679
+ candidates?: string[]
664
680
  index: number
665
681
  title?: string
666
682
  code: string
@@ -1181,7 +1197,7 @@ function parseLineage(value: unknown): LineageContext | undefined {
1181
1197
  if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
1182
1198
  const record = value as Record<string, unknown>
1183
1199
  const lineage: LineageContext = {}
1184
- for (const key of ['sessionId', 'turnId', 'intentId', 'taskId', 'subtaskId', 'parentSubtaskId', 'runId', 'traceId', 'domain'] as const) {
1200
+ for (const key of ['sessionId', 'turnId', 'taskId', 'subtaskId', 'parentSubtaskId', 'runId', 'traceId', 'domain'] as const) {
1185
1201
  if (typeof record[key] === 'string' && record[key].trim()) lineage[key] = record[key].trim().slice(0, 160)
1186
1202
  }
1187
1203
  return Object.keys(lineage).length > 0 ? lineage : undefined
@@ -1311,7 +1327,7 @@ export function splitWikiNotes(card: Pick<KnowledgeCard, 'summary' | 'content'>)
1311
1327
  const sections: Array<{ heading: string; lines: string[] }> = []
1312
1328
  let current: { heading: string; lines: string[] } | undefined
1313
1329
  let fence: string | undefined
1314
- for (const line of card.content.trim().split('\n')) {
1330
+ for (const line of stripWikiNavigation(card.content).trim().split('\n')) {
1315
1331
  const code = line.match(/^\s*(?:(?:\d+[.)]|[-+*])\s+)?(`{3,}|~{3,})(.*)$/)
1316
1332
  if (code) {
1317
1333
  if (!fence) fence = code[1]!
@@ -1776,6 +1792,7 @@ export interface KnowledgeRuntimeOptions {
1776
1792
  * 省略时算没有备注 —— 独立运行时与单元测试不必先造一个注册表。
1777
1793
  */
1778
1794
  extractionNote?: () => string
1795
+ extractionTimeoutMinutes?: () => ExtractionTimeoutMinutes
1779
1796
  /**
1780
1797
  * 0029:家当目录(`<agentRoot>/resource`)。
1781
1798
  *
@@ -1831,6 +1848,7 @@ export interface ModelSeam {
1831
1848
  provider?: string
1832
1849
  model?: string
1833
1850
  reasoningEffort?: string
1851
+ maxTokens?: number
1834
1852
  }
1835
1853
 
1836
1854
  /**
@@ -1862,6 +1880,7 @@ export interface CompileStepView {
1862
1880
  /** 这一段的计数事实,一行一条。第一条通常是进度(`读取文档 3/40`)。 */
1863
1881
  facts: string[]
1864
1882
  }
1883
+ type CompileActivityView = Omit<CompileStepView, 'id'> & { id: CompileStepId | 'publish' }
1865
1884
 
1866
1885
  export type CompileTaskState = 'queued' | 'running' | 'pausing' | 'paused' | 'cancelling' | 'succeeded' | 'failed' | 'cancelled'
1867
1886
  export type CompileTaskAction = 'pause' | 'resume' | 'cancel'
@@ -1997,6 +2016,7 @@ export class KnowledgeRuntime {
1997
2016
  readonly rootDir: string
1998
2017
  /** 本库的坐标提取备注(0068)。见 {@link KnowledgeRuntimeOptions.extractionNote}。 */
1999
2018
  private readonly extractionNoteProvider?: () => string
2019
+ private readonly extractionTimeoutProvider?: () => ExtractionTimeoutMinutes
2000
2020
  /**
2001
2021
  * Id of the knowledge base this runtime serves. `KnowledgeBases` sets it when
2002
2022
  * it creates/caches a runtime; asset URLs are built from it. Standalone
@@ -2165,6 +2185,7 @@ export class KnowledgeRuntime {
2165
2185
  // 运行时实际在 B」这种两处说法。
2166
2186
  this.config = { ...DEFAULT_KNOWLEDGE_CONFIG, ...config }
2167
2187
  this.extractionNoteProvider = options.extractionNote
2188
+ this.extractionTimeoutProvider = options.extractionTimeoutMinutes
2168
2189
  this.memoryDir = options.memoryRoot ? resolve(options.memoryRoot) : join(this.rootDir, '04_memory')
2169
2190
  this.staticModelCall = options.modelCall
2170
2191
  this.environmentGuard = options.environmentGuard
@@ -3179,8 +3200,8 @@ export class KnowledgeRuntime {
3179
3200
  * 每条的原因仍然在 `sources[].message` 里。
3180
3201
  */
3181
3202
  async importSources(input: {
3182
- sources: Array<{ title?: string; content: string; externalId?: string; metadata?: Record<string, unknown>; allowDuplicateTitle?: boolean }>
3183
- /** 默认编译(`only: 'wiki'`)。关掉它就只做写入,留给调用方自己决定何时编译。 */
3203
+ sources: Array<{ sourceId?: string; expectedVersion?: number; sourceUrl?: string; sourceIdentity?: SourceIdentityInput; title?: string; content: string; externalId?: string; metadata?: Record<string, unknown>; allowDuplicateTitle?: boolean }>
3204
+ /** 默认只保存 Source Raw;显式 true 才运行完整处理流程。 */
3184
3205
  compile?: boolean
3185
3206
  lineage?: LineageContext
3186
3207
  }): Promise<Envelope<ImportSourcesResult>> {
@@ -3198,9 +3219,20 @@ export class KnowledgeRuntime {
3198
3219
  const written: ImportedSourceReport[] = []
3199
3220
  const failed: ImportFailure[] = []
3200
3221
  const warnings: string[] = []
3222
+ const seenSourceIds = new Set<string>()
3201
3223
  for (const [index, item] of items.entries()) {
3224
+ if (item?.sourceId && seenSourceIds.has(item.sourceId)) {
3225
+ failed.push({ index, title: item.title, code: 'SOURCE_ID_DUPLICATED_IN_BATCH', message: '同一批次中的独立输入必须使用不同 source_id。' })
3226
+ warnings.push(`第 ${index + 1} 份来源重复使用 source_id,未写入。`)
3227
+ continue
3228
+ }
3229
+ if (item?.sourceId) seenSourceIds.add(item.sourceId)
3202
3230
  const title = item?.title?.trim() || undefined
3203
3231
  const result = await this.addSource({
3232
+ ...(item?.sourceId ? { sourceId: item.sourceId } : {}),
3233
+ ...(item?.expectedVersion === undefined ? {} : { expectedVersion: item.expectedVersion }),
3234
+ ...(item?.sourceUrl === undefined ? {} : { sourceUrl: item.sourceUrl }),
3235
+ ...(item?.sourceIdentity === undefined ? {} : { sourceIdentity: item.sourceIdentity }),
3204
3236
  ...(title ? { title } : {}),
3205
3237
  content: item?.content,
3206
3238
  ...(item?.externalId ? { externalId: item.externalId } : {}),
@@ -3210,7 +3242,7 @@ export class KnowledgeRuntime {
3210
3242
  })
3211
3243
  if (result.ok === false) {
3212
3244
  const message = result.error?.message ?? '写入失败'
3213
- failed.push({ index, ...(title ? { title } : {}), code: result.error?.code ?? 'SOURCE_WRITE_FAILED', message })
3245
+ failed.push({ index, ...(title ? { title } : {}), code: result.error?.code ?? 'SOURCE_WRITE_FAILED', message, candidates: result.data.candidates })
3214
3246
  // 逐条失败也要出现在 warnings 里:`ImportSourcesResult.failed` 是结构化账本,
3215
3247
  // 而 warnings 是不管谁在读都必须看见的那一句。
3216
3248
  warnings.push(`第 ${index + 1} 份来源${title ? `「${title}」` : ''}没有写入:${message}`)
@@ -3218,7 +3250,7 @@ export class KnowledgeRuntime {
3218
3250
  }
3219
3251
  written.push({
3220
3252
  index,
3221
- sourceId: result.data.sourceId,
3253
+ sourceId: result.data.sourceId, version: result.data.version, readRef: result.data.readRef, reusedSource: result.data.reusedSource, proposedSourceId: result.data.proposedSourceId, sourceIdentity: result.data.sourceIdentity,
3222
3254
  ...(title ? { title } : {}),
3223
3255
  bytes: typeof item?.content === 'string' ? item.content.length : 0,
3224
3256
  })
@@ -3317,14 +3349,17 @@ export class KnowledgeRuntime {
3317
3349
  externalId?: string
3318
3350
  metadata?: Record<string, unknown>
3319
3351
  sourceId?: string
3352
+ expectedVersion?: number
3353
+ sourceUrl?: string
3354
+ sourceIdentity?: SourceIdentityInput
3320
3355
  lineage?: LineageContext
3321
3356
  provenance?: SourceProvenance
3322
3357
  /**
3323
3358
  * 允许与已有的来源同标题(0051)。默认 `false`:重名是错误,拒收并要求改标题。
3324
- * 传 `true` 时落成 `<标题>-<内容哈希前 6 位>.md`,并回一条 warning。
3359
+ * 传 `true` 时按各自 Source Raw ID 并存,并回一条 warning。
3325
3360
  */
3326
3361
  allowDuplicateTitle?: boolean
3327
- }): Promise<Envelope<{ sourceId: string; lineage?: LineageContext }>> {
3362
+ }): Promise<Envelope<{ sourceId: string; proposedSourceId?: string; reusedSource?: boolean; sourceIdentity?: SourceIdentity[]; candidates?: string[]; possibleDuplicateIds?: string[]; version?: number; readRef?: { kb?: string; source_id: string; version?: number; content_hash: string }; lineage?: LineageContext }>> {
3328
3363
  if (!this.config.allowSourceTruth) return failure({ sourceId: '' }, 'SOURCE_TRUTH_DISABLED', '当前配置禁止写入 SourceTruth', false)
3329
3364
  if (typeof input.content !== 'string' || input.content.trim().length === 0) return failure({ sourceId: '' }, 'CONTENT_REQUIRED', 'content 不能为空', true)
3330
3365
  /**
@@ -3344,86 +3379,50 @@ export class KnowledgeRuntime {
3344
3379
  if (db === null) {
3345
3380
  return failure({ sourceId: '' }, 'STORE_UNAVAILABLE', this.storeProblem ?? '知识库不可用', true, '检查 Node 版本(正文进库之后需要 22.5+ 的 node:sqlite)与知识库文件的属主标记。')
3346
3381
  }
3347
- const sourceId = input.sourceId?.trim() || hash(input.externalId ?? input.content)
3348
- if (!SAFE_ID.test(sourceId)) return failure({ sourceId: '' }, 'SOURCE_ID_INVALID', 'source_id 含有非法字符', false)
3349
- const title = input.title?.trim() || sourceId
3350
- /**
3351
- * 重名(0051):**默认拒收**。
3352
- *
3353
- * 判据从「目标路径被谁占着」换成「库里有没有人用这个标题」。文件名没有
3354
- * 之后看起来这条该消失,其实不该 —— 它要解决的问题不是「文件会互相覆盖」,而是
3355
- * 「库里有两份同名材料」这件事**必须让人知道**。所以三条出路原样保留:
3356
- *
3357
- * 1. 同一个 `sourceId`(同一份东西重复导入)→ 就是它自己,不冲突;
3358
- * 2. 另一个 `sourceId` 用了这个标题 → **拒收**,报错里带上「被谁占着」;
3359
- * 3. 调用方明确要留两份同名材料 → 传 `allowDuplicateTitle`,回一条 warning。
3360
- *
3361
- * 为什么不静默加后缀:那样人永远不知道库里有俩「复盘」。也不静默覆盖:那等于直接
3362
- * 丢一份知识。
3363
- */
3382
+ const proposedSourceId = input.sourceId
3383
+ if (proposedSourceId !== undefined && !isSourceId(proposedSourceId)) return failure({ sourceId: '' }, 'SOURCE_ID_INVALID', 'source_id 含有非法字符', false)
3384
+ let sourceId = proposedSourceId ?? '', reusedSource = false
3385
+ let record!: SourceRaw
3364
3386
  let duplicateNote: string | undefined
3365
- const owner = db.sourceIdByTitle(title)
3366
- if (owner !== undefined && owner !== sourceId) {
3367
- if (input.allowDuplicateTitle !== true) {
3368
- return failure(
3369
- { sourceId: '' },
3370
- 'SOURCE_TITLE_DUPLICATED',
3371
- `标题「${title}」已被来源 ${owner} 占用,改一个标题再来`,
3372
- true,
3373
- '换一个有区分度的标题(例如带上日期或版本),或显式传 allow_duplicate_title: true 让两份并存。',
3374
- )
3375
- }
3376
- duplicateNote = `标题「${title}」与来源 ${owner} 重复,已按 allow_duplicate_title 并存;建议改成有区分度的标题。`
3377
- }
3378
- /**
3379
- * 写入交给存储层:它在**一个事务**里做三件事 —— 正文进 `blob`、加一行 `revision`、
3380
- * 更新当前态。而「正文没变就不造版本」也在那里(幂等重入的常态,回滚可回滚的依据)。
3381
- *
3382
- * 0062 之前这三件事散在三处,其中「版本快照」是单独写一个文件的 —— 于是崩在中间
3383
- * 就是「文件变了、历史没记」,只能靠启动对账兜底。
3384
- */
3385
- const existing = db.source(sourceId)
3386
- const record: SourceRaw = {
3387
- sourceId,
3388
- title,
3389
- content: input.content,
3390
- ...(input.externalId ? { externalId: input.externalId } : {}),
3391
- metadata: input.metadata ?? {},
3392
- ...(input.lineage ? { lineage: input.lineage } : {}),
3393
- createdAt: existing?.record.createdAt ?? new Date().toISOString(),
3394
- updatedAt: new Date().toISOString(),
3395
- }
3396
- /**
3397
- * 溯源:调用方给了就用它;没给就**从正文自己声明的地址**补一条最小的;再没有才退回
3398
- * 外部标识(`upload:<文件名>:<mtime>` 这种,它带着文件的名字)。
3399
- *
3400
- * 补进去的都是**已经知道的事实**,不含猜测 —— 但少了这一步,「这份材料从哪来」的
3401
- * 答案就取决于它当初是走哪条路进来的:转换内核那条路写 `resource` 与 `sources[]`,
3402
- * 直接读上传这条什么都不写。
3403
- */
3404
- const declaredOrigin = declaredOriginOf(record.content)
3405
- const provenance: SourceProvenance = input.provenance ?? {
3406
- ...(declaredOrigin === undefined ? {} : { resource: declaredOrigin }),
3407
- sources: [{
3408
- id: 'origin',
3409
- resource: declaredOrigin ?? input.externalId?.trim() ?? record.title,
3410
- title: record.title,
3411
- }],
3412
- }
3413
- const frontMatter = sourceFrontMatter(record, provenance)
3414
- /**
3415
- * 底稿(0067):调用方这一趟带进来的优先,没有就**沿用库里已有的那一份**。
3416
- *
3417
- * 沿用是必须的:`addSource` 也是「只改记账字段」那条路(补溯源、改标题、修 front
3418
- * matter),而底稿是正文之外的资产 —— 在这条路上把它清掉,等于让人改一次标题就丢一次
3419
- * 重跑的依据。清空底稿只有一条路:连同这份来源一起删掉。
3420
- */
3421
- const original = sourceOriginalOf(input.metadata) ?? existing?.original
3387
+ let possibleDuplicateIds: string[] = []
3388
+ let identities: SourceIdentity[] = []
3422
3389
  try {
3423
- db.transact({ opId: operationId(), actor: 'panel', summary: `写入来源「${title}」` }, tx => {
3390
+ if (input.sourceUrl !== undefined && input.sourceIdentity?.url !== undefined && normalizeSourceUrl(input.sourceUrl).href !== normalizeSourceUrl(input.sourceIdentity.url).href) throw new SourceIdentityError('SOURCE_ORIGIN_CONFLICT', 'source_url 与 source_identity.url 不一致。')
3391
+ if (input.sourceIdentity !== undefined || input.sourceUrl !== undefined) identities = resolveSourceIdentities({ ...input.sourceIdentity, ...(input.sourceUrl === undefined ? {} : { url: input.sourceUrl }) })
3392
+ // transact writes the operation row first: identity lookup, source write and binding share one writer lock.
3393
+ db.transact({ opId: operationId(), actor: 'panel', summary: `写入来源「${input.title ?? proposedSourceId ?? '新来源'}」` }, tx => {
3394
+ const choice = chooseSourceId(db, proposedSourceId, identities)
3395
+ sourceId = choice.sourceId; reusedSource = choice.reused
3396
+ const existing = db.source(sourceId)
3397
+ if (!existing) possibleDuplicateIds = (db.raw("SELECT s.entity_id FROM source s JOIN revision r ON r.entity_kind='source' AND r.entity_id=s.entity_id AND r.version=s.head_version WHERE r.blob_hash=? AND s.entity_id<>? ORDER BY s.entity_id LIMIT 20")
3398
+ .all(createHash('sha256').update(input.content).digest('hex'), sourceId) as Array<{ entity_id: string }>).map(row => row.entity_id)
3399
+ if (input.expectedVersion !== undefined && (!Number.isInteger(input.expectedVersion) || input.expectedVersion < 0 || (existing?.version ?? 0) !== input.expectedVersion)) throw new SourceIdentityError('SOURCE_VERSION_CONFLICT', '来源版本已变化,请按 source_id 读取当前版本后再更新。', [sourceId])
3400
+ const title = input.title?.trim() || existing?.record.title || sourceId
3401
+ const owner = db.sourceIdByTitle(title)
3402
+ if (owner !== undefined && owner !== sourceId && existing?.record.title !== title) {
3403
+ if (input.allowDuplicateTitle !== true) throw new SourceIdentityError('SOURCE_TITLE_DUPLICATED', `标题「${title}」已被来源 ${owner} 占用;请改标题或显式允许同名并存。`, [owner])
3404
+ duplicateNote = `标题「${title}」与来源 ${owner} 重复,已按 allow_duplicate_title 并存。`
3405
+ }
3406
+ record = {
3407
+ sourceId, title, content: input.content,
3408
+ ...((input.externalId ?? existing?.record.externalId) ? { externalId: input.externalId ?? existing?.record.externalId } : {}),
3409
+ metadata: input.metadata ?? existing?.record.metadata ?? {},
3410
+ ...(input.lineage ? { lineage: input.lineage } : {}),
3411
+ createdAt: existing?.record.createdAt ?? new Date().toISOString(), updatedAt: new Date().toISOString(),
3412
+ }
3413
+ // Body-declared URLs remain provenance only. They never claim or redirect source identity.
3414
+ const origin = input.sourceUrl ?? input.sourceIdentity?.url ?? declaredOriginOf(record.content)
3415
+ ?? (typeof existing?.frontmatter.resource === 'string' ? existing.frontmatter.resource : undefined)
3416
+ const provenance: SourceProvenance = input.provenance ?? {
3417
+ ...(origin ? { resource: origin } : {}), sources: [{ id: 'origin', resource: origin ?? input.externalId?.trim() ?? title, title }],
3418
+ }
3419
+ const frontMatter = { ...(existing?.frontmatter ?? {}), ...sourceFrontMatter(record, provenance) }
3420
+ const original = sourceOriginalOf(input.metadata) ?? existing?.original
3424
3421
  tx.putSource(record, frontMatter, { actor: 'user', kind: 'named', summary: `写入来源「${title}」` }, original)
3422
+ for (const identity of identities) tx.bindSourceIdentity(identity, sourceId)
3425
3423
  })
3426
3424
  } catch (cause) {
3425
+ if (cause instanceof SourceIdentityError) return failure({ sourceId, candidates: cause.candidates }, cause.code, cause.message, false)
3427
3426
  return failure({ sourceId: '' }, 'STORE_WRITE_FAILED', `写进知识库失败:${cause instanceof Error ? cause.message : String(cause)}`, true)
3428
3427
  }
3429
3428
  /**
@@ -3447,7 +3446,7 @@ export class KnowledgeRuntime {
3447
3446
  this.sourceIndex = undefined
3448
3447
  return success(
3449
3448
  {
3450
- sourceId,
3449
+ sourceId, proposedSourceId, reusedSource, possibleDuplicateIds, sourceIdentity: db.sourceIdentities(sourceId), version: db.source(sourceId)?.version, readRef: { kb: this.baseId || 'default', source_id: sourceId, version: db.source(sourceId)?.version, content_hash: createHash('sha256').update(record.content).digest('hex') },
3451
3450
  ...(record.lineage ? { lineage: record.lineage } : {}),
3452
3451
  assets: { refs: imageRefs.length, missing: missingImages.length },
3453
3452
  },
@@ -4386,7 +4385,8 @@ export class KnowledgeRuntime {
4386
4385
 
4387
4386
  const listed = input.knowledge ? { knowledge: input.knowledge, total: input.knowledge.length } : await store.list({
4388
4387
  objectId: input.coordinate.objectId,
4389
- ...(input.existing?.metadata.wiki_kind ? { kind: input.existing.metadata.wiki_kind === 'way' ? 'procedure' as const : 'fact' as const, ...(input.coordinate.actionId ? { actionId: input.coordinate.actionId } : {}) } : { actionId: input.coordinate.actionId, sceneId: input.coordinate.sceneId }),
4388
+ actionId: input.coordinate.actionId, sceneId: input.coordinate.sceneId,
4389
+ ...(input.existing?.metadata.wiki_kind ? { kind: input.existing.metadata.wiki_kind === 'way' ? 'procedure' as const : 'fact' as const } : {}),
4390
4390
  excludeWithdrawn: true,
4391
4391
  limit: MAX_ORGANIZE_KNOWLEDGE,
4392
4392
  })
@@ -4438,7 +4438,7 @@ export class KnowledgeRuntime {
4438
4438
  hash,
4439
4439
  layout,
4440
4440
  reuse,
4441
- system: buildOrganizeSystem(input.coordinate.actionId ? 'way' : 'fact'),
4441
+ system: buildOrganizeSystem(input.existing?.metadata.wiki_kind === 'way' || listed.knowledge[0]?.kind === 'procedure' ? 'way' : 'fact'),
4442
4442
  prompt: buildOrganizePrompt(organizeInput),
4443
4443
  }
4444
4444
  }
@@ -4637,7 +4637,7 @@ export class KnowledgeRuntime {
4637
4637
  const plan = planRelated(cards, relations, await this.projectedRelated(cards))
4638
4638
  const entries = plan.get(cardKey(card))
4639
4639
  if (entries === undefined || entries.length === 0) return {}
4640
- return { related: entries.map(entry => ({ title: entry.title, entityType: entry.entityType, reason: entry.reason })) }
4640
+ return { related: entries.map(entry => ({ title: entry.title, entityType: entry.entityType, entityId: entry.entityId, reason: entry.reason })) }
4641
4641
  } catch {
4642
4642
  // 相关投影是**增强**:算不出来时不给清单,于是正文里一个 `[[…]]` 都不许出现 —— 而不是把卡片卡住。
4643
4643
  return {}
@@ -4868,7 +4868,9 @@ export class KnowledgeRuntime {
4868
4868
  const createdAt = typeof existing?.frontmatter.created_at === 'string' && existing.frontmatter.created_at ? existing.frontmatter.created_at : updatedAt
4869
4869
  const provenance = [...(input.provenance ?? []), ...(input.lineage ? [{ ...input.lineage, role: 'created' as const, refType: 'knowledge_card', refId: input.entityId }] : [])]
4870
4870
  // 旧版本曾把 schema_issues 写入 metadata。重写卡片时主动摘掉它,避免旧的待补结果继续出现。
4871
- const { schema_issues: _staleIssues, ...metadata } = input.metadata ?? {}
4871
+ const { schema_issues: _staleIssues, ...metadata } = input.metadata ?? previous?.metadata ?? {}
4872
+ if (status === 'deprecated' && (input.origin === 'review' || input.origin === 'manual' || (input.origin === undefined && previous?.status !== 'deprecated'))) metadata.wiki_deprecation_origin = 'manual'
4873
+ if (status !== 'deprecated') delete metadata.wiki_deprecation_origin
4872
4874
  void _staleIssues
4873
4875
  // 备注与 review **默认继承库里已有的**:不传不等于清空(见入参注释)。
4874
4876
  const annotations = input.annotations ?? previousAnnotations ?? []
@@ -4949,7 +4951,106 @@ export class KnowledgeRuntime {
4949
4951
  return success({ entityId: input.entityId, ...(input.lineage ? { lineage: input.lineage } : {}) })
4950
4952
  }
4951
4953
 
4952
- /** Resolve one card by id, optionally constrained to a type (ids are unique per type). */
4954
+ /** Shared by direct reads, index publication and recall; always reads live contributions. */
4955
+ private async wikiUnavailability(cards: readonly KnowledgeCard[], suppliedPlacements?: Map<string, CardKnowledgeRow[]>): Promise<Map<string, string>> {
4956
+ const truth = await this.storeHandle()
4957
+ const knowledge = await this.knowledgeStore()
4958
+ if (!truth || !knowledge) return new Map(cards.map(card => [cardKey(card), '知识真源不可用,无法核验 Wiki。']))
4959
+ const placements = suppliedPlacements ?? truth.placementsOf(cards.map(card => card.entityId))
4960
+ const ids = [...new Set(cards.flatMap(card => [...(placements.get(card.entityId) ?? []).map(row => row.knowledgeId), ...Object.keys(card.metadata.knowledge_versions as object ?? {})]))]
4961
+ const rows = new Map<string, Knowledge>()
4962
+ for (let offset = 0; offset < ids.length; offset += 500) {
4963
+ const page = await knowledge.list({ ids: ids.slice(offset, offset + 500), includeMerged: true, limit: 500 })
4964
+ for (const row of page.knowledge) rows.set(row.id, row)
4965
+ }
4966
+ const sources = new Map((truth.raw('SELECT entity_id,head_version FROM source').all() as Array<{ entity_id: string; head_version: number }>).map(row => [row.entity_id, row.head_version]))
4967
+ const cases = new Map<string, number | undefined>()
4968
+ const result = new Map<string, string>()
4969
+ for (const card of cards) {
4970
+ const reason = wikiUnavailableReason(card, placements.get(card.entityId) ?? [], { knowledge: rows, sources, caseVersion: id => {
4971
+ if (!cases.has(id)) cases.set(id, truth.event(id)?.version)
4972
+ return cases.get(id)
4973
+ } })
4974
+ if (reason) result.set(cardKey(card), reason)
4975
+ }
4976
+ return result
4977
+ }
4978
+
4979
+ /** Read a stable Wiki link or browse an Object's coordinates without flattening its files. */
4980
+ async readWiki(input: KnowledgeScope & { target?: string; object?: string; includeContent?: boolean; limit?: number; offset?: number; maxChars?: number; contentOffset?: number; contentMaxChars?: number; expectedVersion?: number; linkLimit?: number; linkOffset?: number } = {}): Promise<Envelope<Record<string, unknown>>> {
4981
+ const empty = { wikis: [], total: 0, offset: input.offset ?? 0, hasMore: false, outgoing: [], backlinks: [] }
4982
+ const limit = input.limit ?? 20, offset = input.offset ?? 0, maxChars = input.maxChars ?? (this.config.contextMaxChars || 20000)
4983
+ const linkLimit = input.linkLimit ?? 20, linkOffset = input.linkOffset ?? 0
4984
+ const pageChars = input.contentMaxChars ?? Math.min(20000, maxChars)
4985
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100 || !Number.isInteger(offset) || offset < 0) return failure(empty, 'WIKI_PAGE_INVALID', 'limit 必须为 1–100,offset 必须为非负整数。', false)
4986
+ if (!Number.isInteger(maxChars) || maxChars < 1 || maxChars > 200000 || !Number.isInteger(pageChars) || pageChars < 1 || pageChars > 40000 || !Number.isInteger(linkLimit) || linkLimit < 1 || linkLimit > 100 || !Number.isInteger(linkOffset) || linkOffset < 0) return failure(empty, 'WIKI_BUDGET_INVALID', 'max_chars 为 1–200000,content_max_chars 为 1–40000,link_limit 为 1–100,link_offset 为非负整数。', false)
4987
+ if (scopeError(input)) return failure(empty, 'SCOPE_INVALID', scopeError(input)!, false)
4988
+ if (input.contentOffset !== undefined && (!input.target || !Number.isInteger(input.contentOffset) || input.contentOffset < 0)) return failure(empty, 'WIKI_CONTENT_OFFSET_INVALID', '正文分页需要唯一 target 和非负整数 content_offset。', false)
4989
+ if (input.expectedVersion !== undefined && (!input.target || !Number.isInteger(input.expectedVersion) || input.expectedVersion < 1)) return failure(empty, 'WIKI_VERSION_INVALID', 'expected_version 需要唯一 target 和正整数版本号。', false)
4990
+ if (!input.target?.trim() && !input.objectId?.trim() && !input.object?.trim()) return failure(empty, 'WIKI_SCOPE_REQUIRED', '请提供双链 target、object_id 或明确的 Object 名称。', false)
4991
+ const cards = await this.loadCards()
4992
+ let objectId = input.objectId
4993
+ if (input.object !== undefined) {
4994
+ const name = input.object.trim().toLocaleLowerCase()
4995
+ const objects = (await this.listAxisEntities('object') ?? []).filter(object => object.status !== 'deprecated' && object.status !== 'merged'
4996
+ && [object.id, object.name, ...object.aliases].some(value => value.toLocaleLowerCase() === name))
4997
+ if (objects.length !== 1) return failure({ ...empty, candidates: objects.map(object => ({ id: object.id, name: object.name })) }, objects.length ? 'OBJECT_AMBIGUOUS' : 'OBJECT_NOT_FOUND', 'Object 名称未唯一解析,请使用明确的 object_id。', false)
4998
+ if (objectId !== undefined && objectId !== objects[0]!.id) return failure(empty, 'OBJECT_SCOPE_CONFLICT', 'object 与 object_id 指向不同对象。', false)
4999
+ objectId = objects[0]!.id
5000
+ }
5001
+ let selected = cards.filter(card => matchesScope(card, { ...input, objectId }))
5002
+ if (input.target?.trim()) {
5003
+ selected = wikiLinkCandidates(selected, input.target)
5004
+ if (selected.length !== 1) return failure({ ...empty, candidates: selected.map(card => ({ ...wikiReference(card), coordinate: card.coordinate, wikiKind: card.metadata.wiki_kind })) }, selected.length ? 'WIKI_LINK_AMBIGUOUS' : 'WIKI_NOT_FOUND', '双链未唯一指向 Wiki,请使用稳定 target。', false)
5005
+ if (selected[0]!.status === 'deprecated') return failure(empty, 'WIKI_UNAVAILABLE', '目标 Wiki 已停用,请按 Object 查找当前文件。', false)
5006
+ }
5007
+ selected = selected.filter(card => card.status !== 'deprecated').sort((a, b) => wikiReference(a).target.localeCompare(wikiReference(b).target))
5008
+ const page = selected.slice(offset, offset + limit), includeContent = input.includeContent ?? Boolean(input.target)
5009
+ const truth = await this.storeHandle()
5010
+ const unavailableByKey = await this.wikiUnavailability(page, truth?.placementsOf(page.map(card => card.entityId)))
5011
+ const wikis: Array<Record<string, unknown>> = []
5012
+ let usedChars = 0
5013
+ for (const card of page) {
5014
+ const unavailable = unavailableByKey.get(cardKey(card)), version = truth?.card(card.entityId)?.version
5015
+ if (input.expectedVersion !== undefined && input.expectedVersion !== version) return failure(empty, 'WIKI_VERSION_CHANGED', 'Wiki 已更新,请重新读取,避免拼接不同版本的正文。', false)
5016
+ const ref = wikiReference(card)
5017
+ const entry: Record<string, unknown> = { ...ref, coordinate: card.coordinate, wikiKind: card.metadata.wiki_kind, status: card.status, version, available: !unavailable,
5018
+ ...(unavailable ? { reason: unavailable } : {}), ...(card.coordinate ? { coordinateLabels: await this.coordinateNames(card.coordinate) } : {}), contentDelivered: false }
5019
+ if (includeContent && !unavailable) {
5020
+ if (input.contentOffset !== undefined) {
5021
+ if (input.contentOffset > card.content.length) return failure(empty, 'WIKI_CONTENT_OFFSET_INVALID', 'content_offset 超过正文长度。', false)
5022
+ const end = Math.min(card.content.length, input.contentOffset + Math.min(pageChars, maxChars - usedChars))
5023
+ const content = card.content.slice(input.contentOffset, end), truncated = end < card.content.length
5024
+ entry.contentPage = { content, offset: input.contentOffset, totalChars: card.content.length, returnedChars: content.length, truncated, nextOffset: truncated ? end : null }
5025
+ entry.contentDelivered = true; entry.contentComplete = input.contentOffset === 0 && !truncated
5026
+ usedChars += content.length
5027
+ if (truncated) entry.nextRead = { tool: 'read_knowledge_wiki', args: { kb: this.baseId || 'default', target: ref.target, expected_version: version, content_offset: end, content_max_chars: pageChars, max_chars: maxChars } }
5028
+ } else {
5029
+ const cost = JSON.stringify(card).length
5030
+ if (usedChars + cost <= maxChars) { entry.wiki = card; entry.contentDelivered = true; entry.contentComplete = true; usedChars += cost }
5031
+ else { entry.requiredChars = cost; entry.contentComplete = false; entry.nextRead = { tool: 'read_knowledge_wiki', args: { kb: this.baseId || 'default', target: ref.target, expected_version: version, content_offset: 0, content_max_chars: pageChars, max_chars: maxChars } } }
5032
+ }
5033
+ }
5034
+ wikis.push(entry)
5035
+ }
5036
+ const first = input.target ? page[0] : undefined, live = cards.filter(card => card.status !== 'deprecated')
5037
+ const outgoing = first ? [...new Map(scanWikiLinks(first.content).flatMap(link => {
5038
+ const targets = wikiLinkCandidates(live, link.target)
5039
+ return targets.length === 1 && targets[0]!.entityId !== first.entityId ? [[targets[0]!.entityId, wikiReference(targets[0]!)] as const] : []
5040
+ })).values()].sort((a, b) => a.target.localeCompare(b.target)) : []
5041
+ const backlinks = first ? live.filter(card => card.entityId !== first.entityId && scanWikiLinks(card.content).some(link => {
5042
+ const targets = wikiLinkCandidates(live, link.target)
5043
+ return targets.length === 1 && targets[0]!.entityId === first.entityId
5044
+ })).map(wikiReference).sort((a, b) => a.target.localeCompare(b.target)) : []
5045
+ const data = { wikis, total: selected.length, offset, hasMore: offset + page.length < selected.length, nextOffset: offset + page.length < selected.length ? offset + page.length : null,
5046
+ outgoing: outgoing.slice(linkOffset, linkOffset + linkLimit), backlinks: backlinks.slice(linkOffset, linkOffset + linkLimit),
5047
+ links: { offset: linkOffset, limit: linkLimit, outgoingTotal: outgoing.length, backlinksTotal: backlinks.length, nextOffset: linkOffset + linkLimit < Math.max(outgoing.length, backlinks.length) ? linkOffset + linkLimit : null },
5048
+ budget: { maxChars, usedChars, omittedCards: wikis.filter(item => includeContent && item.available && !item.contentDelivered).length } }
5049
+ await this.audit('wiki.read', { target: input.target, objectId, includeContent, returned: page.length, usedChars })
5050
+ return success(data)
5051
+ }
5052
+
5053
+ /** Resolve one card by its stable ID, optionally constrained to a type. */
4953
5054
  async readCard(entityId: string, entityType?: SchemaType): Promise<KnowledgeCard | undefined> {
4954
5055
  const db = await this.storeHandle()
4955
5056
  if (db === null) return undefined
@@ -5023,7 +5124,43 @@ export class KnowledgeRuntime {
5023
5124
  async readCase(id: string, version?: number): Promise<EventEntity | undefined> {
5024
5125
  const db = await this.storeHandle()
5025
5126
  if (version !== undefined && (!Number.isInteger(version) || version < 1)) return undefined
5026
- return version === undefined ? db?.event(id) : db?.caseVersion(id, version)
5127
+ if (!db?.event(id)) return undefined
5128
+ return version === undefined ? db.event(id) : db.caseVersion(id, version)
5129
+ }
5130
+
5131
+ async queryCases(input: KnowledgeScope & { ids?: string[]; version?: number; query?: string; limit?: number; offset?: number; includeContent?: boolean; maxChars?: number; contentOffset?: number }): Promise<Envelope<Record<string, unknown>>> {
5132
+ const empty = { cases: [], missing_ids: [], total: 0, hasMore: false }
5133
+ const limit = input.limit ?? 20, offset = input.offset ?? 0, maxChars = input.maxChars ?? 20000
5134
+ if (scopeError(input)) return failure(empty, 'SCOPE_INVALID', scopeError(input)!, false)
5135
+ if (!Number.isInteger(limit) || limit < 1 || limit > 50 || !Number.isInteger(offset) || offset < 0 || !Number.isInteger(maxChars) || maxChars < 1 || maxChars > 100000) return failure(empty, 'CASE_PAGE_INVALID', 'limit 为 1–50,offset 为非负整数,max_chars 为 1–100000。', false)
5136
+ if (input.ids !== undefined && (!Array.isArray(input.ids) || !input.ids.length || input.ids.length > 50 || input.ids.some(id => typeof id !== 'string' || !id.trim()))) return failure(empty, 'CASE_IDS_INVALID', 'case_ids 必须含 1–50 个有效 ID。', false)
5137
+ const ids = input.ids ? [...new Set(input.ids)] : undefined
5138
+ if (input.version !== undefined && (!ids || ids.length !== 1 || !Number.isInteger(input.version) || input.version < 1)) return failure(empty, 'CASE_VERSION_INVALID', '历史版本读取要求一个 Case ID 和正整数 version。', false)
5139
+ if (input.contentOffset !== undefined && (!ids || ids.length !== 1 || !Number.isInteger(input.contentOffset) || input.contentOffset < 0)) return failure(empty, 'CASE_CONTENT_OFFSET_INVALID', '正文分页需要一个 Case ID 和非负整数 content_offset。', false)
5140
+ if (input.query !== undefined && (typeof input.query !== 'string' || !input.query.trim() || ids)) return failure(empty, 'CASE_QUERY_INVALID', 'query 必须非空,且不与 case_ids 同传。', false)
5141
+ if (!ids && !input.query?.trim() && ![input.objectId, input.actionId, input.sceneId].some(value => value !== undefined)) return failure(empty, 'CASE_QUERY_REQUIRED', '请提供 case_ids、当前子问题 query 或坐标范围。', false)
5142
+ const read = ids ? await Promise.all(ids.map(id => this.readCase(id, input.version))) : await this.loadEvents()
5143
+ const terms = input.query ? [...new Set(segment(input.query))] : []
5144
+ const matched = read.filter((item): item is EventEntity => !!item)
5145
+ .filter(item => ![input.objectId, input.actionId, input.sceneId].some(value => value !== undefined) || item.records.some(record => matchesScope({ coordinate: record.coordinateIds }, input)))
5146
+ .map(item => ({ item, score: terms.length ? terms.filter(term => JSON.stringify(item).toLowerCase().includes(term.toLowerCase())).length / terms.length : 1 }))
5147
+ .filter(row => row.score > 0).sort((a, b) => b.score - a.score || a.item.id.localeCompare(b.item.id))
5148
+ const cases: Array<Record<string, unknown>> = [], includeContent = input.includeContent ?? Boolean(ids)
5149
+ let usedChars = 0
5150
+ for (const { item, score } of matched.slice(offset, offset + limit)) {
5151
+ const raw = JSON.stringify(item), version = item.version ?? 1
5152
+ const sourceReads = item.sources.map(source => ({ kb: this.baseId || 'default', source_id: source.documentId, version: source.sourceVersion, ...(source.locator ? { start: source.locator.start, end: source.locator.end } : {}), quote: source.quote, historicalVersionKnown: source.sourceVersion !== undefined }))
5153
+ const ref = { id: item.id, title: item.title, version, sourceReads, coordinate: item.coordinateIds, occurredAt: item.occurredAt, historicalEvidence: true, score }
5154
+ if (includeContent && input.contentOffset !== undefined) {
5155
+ if (input.contentOffset > raw.length) return failure(empty, 'CASE_CONTENT_OFFSET_INVALID', 'content_offset 超过 Case 正文长度。', false)
5156
+ const end = Math.min(raw.length, input.contentOffset + maxChars), content = raw.slice(input.contentOffset, end)
5157
+ cases.push({ ...ref, contentPage: { format: 'json', content, offset: input.contentOffset, totalChars: raw.length, truncated: end < raw.length },
5158
+ ...(end < raw.length ? { nextRead: { tool: 'get_knowledge_cases', args: { kb: this.baseId || 'default', case_ids: [item.id], version, content_offset: end, max_chars: maxChars } } } : {}) }); usedChars += content.length
5159
+ } else if (includeContent && usedChars + raw.length <= maxChars) { cases.push({ ...item, sourceReads, historicalEvidence: true, contentDelivered: true }); usedChars += raw.length }
5160
+ else cases.push({ ...ref, contentDelivered: false, requiredChars: raw.length, nextRead: { tool: 'get_knowledge_cases', args: { kb: this.baseId || 'default', case_ids: [item.id], version, ...(raw.length > maxChars ? { content_offset: 0 } : {}), max_chars: maxChars } } })
5161
+ }
5162
+ await this.audit('case.read', { ids, query: input.query, returned: cases.length, usedChars })
5163
+ return success({ cases, missing_ids: ids?.filter((_, index) => read[index] === undefined) ?? [], total: matched.length, offset, hasMore: offset + limit < matched.length, nextOffset: offset + limit < matched.length ? offset + limit : null, retrieval: ids ? 'id' : 'keyword', budget: { maxChars, usedChars } })
5027
5164
  }
5028
5165
 
5029
5166
  async loadEvents(): Promise<EventEntity[]> {
@@ -5040,7 +5177,7 @@ export class KnowledgeRuntime {
5040
5177
  if (card.status === 'problem' && status !== 'unreviewed') return failure({ entityId, status }, 'STATUS_TRANSITION_INVALID', 'problem 不能直接恢复为其他状态,请先改为 unreviewed', false)
5041
5178
  const eventLineage = withRun(lineage, 'knowledge-status')
5042
5179
  const provenance = [...(card.provenance ?? []), ...(eventLineage ? [{ ...eventLineage, role: 'executed' as const, refType: 'knowledge_status', refId: entityId }] : [])]
5043
- const written = await this.writeCard({ ...card, status, sourceRefs: card.sourceRefs, provenance, metadata: { ...card.metadata, ...(problemNote?.trim() ? { problem_note: problemNote.trim() } : {}) } })
5180
+ const written = await this.writeCard({ ...card, status, sourceRefs: card.sourceRefs, provenance, origin: 'review', metadata: { ...card.metadata, ...(problemNote?.trim() ? { problem_note: problemNote.trim() } : {}) } })
5044
5181
  if (written.ok === false) return failure({ entityId, status }, written.error.code, written.error.message, written.error.retryable, written.error.suggestion)
5045
5182
  return success({ entityId, status, ...(eventLineage ? { lineage: eventLineage } : {}) })
5046
5183
  }
@@ -5087,7 +5224,7 @@ export class KnowledgeRuntime {
5087
5224
  * `metadata.related_cards`;这里继续剥离旧版本正文里的标记,保证存量卡片迁移期间
5088
5225
  * 也不会把导航送进召回。
5089
5226
  */
5090
- const recallBody = stripEventReferenceSection(stripRelatedSection(card.content))
5227
+ const recallBody = wikiIndexContent(stripEventReferenceSection(stripRelatedSection(card.content)))
5091
5228
  for (const [index, chunk] of splitWikiNotes({ ...card, content: recallBody }).entries()) {
5092
5229
  const baseId = `${card.entityType}:${card.entityId}`
5093
5230
  const stableSection = layoutOf(card.metadata)?.sections?.find(section => section.title === chunk.heading)
@@ -5649,7 +5786,7 @@ export class KnowledgeRuntime {
5649
5786
  existing: card,
5650
5787
  ...(input.imagePlan.byCard.get(key) === undefined ? {} : { images: input.imagePlan.byCard.get(key) }),
5651
5788
  ...(relatedPlan.get(key) === undefined ? {} : {
5652
- related: (relatedPlan.get(key) ?? []).map(entry => ({ title: entry.title, entityType: entry.entityType, reason: entry.reason })),
5789
+ related: (relatedPlan.get(key) ?? []).map(entry => ({ title: entry.title, entityType: entry.entityType, entityId: entry.entityId, reason: entry.reason })),
5653
5790
  }),
5654
5791
  call: input.call,
5655
5792
  })
@@ -6211,12 +6348,11 @@ export class KnowledgeRuntime {
6211
6348
  // ------------------------------------------- Finalize · 索引 / 断链 / 相关(0049)
6212
6349
 
6213
6350
  /**
6214
- * 收尾段的**结构化数据层**:把相关卡片写进 `metadata.related_cards`,清理旧版本写进
6215
- * 正文的相关块,并把断链统计交给编译报告与索引页。
6351
+ * 编译收尾:生成类型化 Wiki 的稳定双链与导航,更新 `metadata.related_cards`,
6352
+ * 清理旧相关块,并把断链统计交给编译报告与索引页。
6216
6353
  *
6217
- * 相关卡片是派生导航数据,不是知识正文。它放在 metadata 里有三个好处:正文不会被
6218
- * 机器段落污染,召回不需要再剥离导航块,面板也能直接按结构展示。旧卡片上的
6219
- * `<!-- compiled:related -->` 只在这里做一次兼容清理。
6354
+ * 导航只放链接,不复制目标正文;面板使用结构化元数据,检索排除生成的导航块。
6355
+ * 旧卡片上的 `<!-- compiled:related -->` 在这里兼容清理。
6220
6356
  *
6221
6357
  * 三段都是确定性的,所以这个函数可以重复跑:正文和结构化相关数据都没变化时不写盘。
6222
6358
  */
@@ -6230,11 +6366,24 @@ export class KnowledgeRuntime {
6230
6366
  * 它是**算出来的**,不是谁声明的:这张卡里的知识引用了哪个轴实体,而那个轴自己
6231
6367
  * 有一张卡,就有了链接。模型抽出来的关系(`relations`)是第二层。
6232
6368
  */
6233
- const projected = await this.projectedRelated(cards)
6234
- const related = planRelated(cards, relations, projected)
6235
- /** 统计清理旧导航块之后的正文断链;相关卡片本身不再伪装成正文链接。 */
6236
- const nextCards = cards.map(card => ({ ...card, content: stripEventReferenceSection(stripRelatedSection(card.content)) }))
6237
- const deadLinks = deadLinksOf(nextCards)
6369
+ const axes = (await this.listAxisEntities() ?? []).filter(axis => axis.status !== 'deprecated' && axis.status !== 'merged')
6370
+ const objects = axes.filter(axis => axis.kind === 'object')
6371
+ const names = new Map(axes.map(axis => [axis.id, axis.name]))
6372
+ const labeled = cards.map(card => card.metadata.wiki_kind && card.coordinate ? { ...card, metadata: { ...card.metadata,
6373
+ wiki_coordinate_labels: { object: names.get(card.coordinate.objectId) ?? card.coordinate.objectId, action: names.get(card.coordinate.actionId) ?? card.coordinate.actionId, scene: names.get(card.coordinate.sceneId) ?? card.coordinate.sceneId },
6374
+ } } : card)
6375
+ const live = labeled.filter(card => card.status !== 'deprecated')
6376
+ const nextCards = labeled.map(card => {
6377
+ const base = { ...card, content: stripEventReferenceSection(stripRelatedSection(card.content)) }
6378
+ if (!card.metadata.wiki_kind || card.status === 'deprecated') return base
6379
+ const navigation = compileWikiNavigation(base, live, objects)
6380
+ return { ...base, content: navigation.content, metadata: { ...base.metadata,
6381
+ wiki_links: navigation.outgoing, wiki_inline_targets: navigation.inlineTargets, wiki_object_refs: navigation.objectIds,
6382
+ } }
6383
+ })
6384
+ const projected = await this.projectedRelated(nextCards)
6385
+ const related = planRelated(live, relations, projected)
6386
+ const deadLinks = deadLinksOf(nextCards.filter(card => card.status !== 'deprecated'))
6238
6387
  let rewritten = 0
6239
6388
  for (const [index, card] of cards.entries()) {
6240
6389
  const key = cardKey(card)
@@ -6242,7 +6391,7 @@ export class KnowledgeRuntime {
6242
6391
  const entries = related.get(key) ?? []
6243
6392
  const previousEntries = relatedEntriesOf(card.metadata)
6244
6393
  const contentChanged = nextCard.content !== card.content
6245
- const relatedChanged = JSON.stringify(previousEntries) !== JSON.stringify(entries)
6394
+ const relatedChanged = JSON.stringify(previousEntries) !== JSON.stringify(entries) || JSON.stringify(card.metadata.wiki_coordinate_labels) !== JSON.stringify(nextCard.metadata.wiki_coordinate_labels) || JSON.stringify(card.metadata.wiki_links) !== JSON.stringify(nextCard.metadata.wiki_links) || JSON.stringify(card.metadata.wiki_inline_targets) !== JSON.stringify(nextCard.metadata.wiki_inline_targets) || JSON.stringify(card.metadata.wiki_object_refs) !== JSON.stringify(nextCard.metadata.wiki_object_refs)
6246
6395
  if (!contentChanged && !relatedChanged) continue
6247
6396
  /**
6248
6397
  * 没有来源的卡片写不进去(`writeCard` 要求 `source_refs` 非空),而这是**唯一**
@@ -6263,7 +6412,7 @@ export class KnowledgeRuntime {
6263
6412
  status: card.status,
6264
6413
  ...(card.owner ? { owner: card.owner } : {}),
6265
6414
  ...(card.provenance ? { provenance: card.provenance } : {}),
6266
- metadata: { ...card.metadata, [RELATED_METADATA_KEY]: entries },
6415
+ metadata: { ...nextCard.metadata, [RELATED_METADATA_KEY]: entries },
6267
6416
  origin: 'compile',
6268
6417
  })
6269
6418
  if (written.ok === false) {
@@ -6387,13 +6536,9 @@ export class KnowledgeRuntime {
6387
6536
  }
6388
6537
  }
6389
6538
 
6390
- /** 一条上下文条目「值多少字符」:命中条目算它那些 note 的正文,扩展条目算标题 + 摘要(+ 正文)。 */
6391
- private contextCost(item: ContextItem, full: boolean): number {
6392
- const card = item.wiki
6393
- if (card === null) return 0
6394
- const shallow = card.title.length + (card.summary?.length ?? 0)
6395
- const knowledge = (item.knowledge ?? []).reduce((sum, row) => sum + row.text.length + row.state.length + 8, 0)
6396
- return knowledge + shallow + card.content.length
6539
+ /** 与 Agent 实际收到的上下文条目使用同一序列化形状,包含完整 Wiki、依据和引用。 */
6540
+ private contextCost(item: ContextItem): number {
6541
+ return JSON.stringify(agentContextItem(item)).length
6397
6542
  }
6398
6543
 
6399
6544
  /** 溢出目录:**系统临时目录**,不随知识库走(0050 的取舍,见 issue)。 */
@@ -6421,17 +6566,14 @@ export class KnowledgeRuntime {
6421
6566
  const { items, maxChars, recallId } = input
6422
6567
  const hitCards = items.filter(item => item.reason === 'hit').length
6423
6568
  const expandedCards = items.length - hitCards
6424
- const totalChars = items.reduce((sum, item) => sum + this.contextCost(item, true), 0)
6569
+ const totalChars = items.reduce((sum, item) => sum + this.contextCost(item), 0)
6425
6570
  if (!Number.isFinite(maxChars) || maxChars <= 0) {
6426
6571
  return { items, report: { maxChars: 0, usedChars: totalChars, omittedCards: 0, omittedChars: 0, hitCards, expandedCards } }
6427
6572
  }
6428
6573
  const keptItems: ContextItem[] = []
6429
6574
  let used = 0
6430
6575
  for (const item of items) {
6431
- const full = this.contextCost(item, true)
6432
- const shallow = this.contextCost(item, false)
6433
- // 命中条目按整条算(它的正文就是证据);扩展条目可以先按「浅」那一档进。
6434
- const cost = item.reason === 'hit' ? full : shallow
6576
+ const cost = this.contextCost(item)
6435
6577
  if (used + cost > maxChars) continue
6436
6578
  used += cost
6437
6579
  keptItems.push(item)
@@ -6442,17 +6584,18 @@ export class KnowledgeRuntime {
6442
6584
  maxChars,
6443
6585
  usedChars: used,
6444
6586
  omittedCards: omitted.length,
6445
- omittedChars: omitted.reduce((sum, item) => sum + this.contextCost(item, true), 0),
6587
+ omittedChars: omitted.reduce((sum, item) => sum + this.contextCost(item), 0),
6446
6588
  hitCards: keptItems.filter(item => item.reason === 'hit').length,
6447
6589
  expandedCards: keptItems.filter(item => item.reason === 'expansion').length,
6448
6590
  }
6449
6591
  if (omitted.length === 0) return { items: keptItems, report }
6450
6592
  const overflowPath = await this.writeRecallOverflow({ items, recallId, usedChars: used, maxChars })
6451
6593
  report.overflowPath = overflowPath
6594
+ report.omitted = omitted.flatMap(item => item.wiki ? [{ target: `${item.wiki.entityType}/${item.wiki.entityId}`, title: item.wiki.title, requiredChars: this.contextCost(item), read: { tool: 'read_knowledge_wiki', args: { kb: this.baseId || 'default', target: `${item.wiki.entityType}/${item.wiki.entityId}` } } }] : [])
6452
6595
  return {
6453
6596
  items: keptItems,
6454
6597
  report,
6455
- notice: `还有 ${report.omittedCards} 张卡片共 ${report.omittedChars} 字符没有放进上下文,完整内容在 ${overflowPath};需要更多信息时直接读这个文件(它是本次召回的临时产物,不保证长期存在)。`,
6598
+ notice: `还有 ${report.omittedCards} 张卡片共 ${report.omittedChars} 字符没有放进上下文,完整内容在 ${overflowPath};可按 budget.omitted 中的稳定 target 调用 read_knowledge_wiki 补读;此文件仅供本机诊断,不保证长期存在。`,
6456
6599
  }
6457
6600
  }
6458
6601
 
@@ -6554,13 +6697,17 @@ export class KnowledgeRuntime {
6554
6697
  * 而不是报错:这一层是**增强**,它坏了不该让编译失败。
6555
6698
  */
6556
6699
  private async projectedRelated(cards: KnowledgeCard[]): Promise<Array<{ fromKey: string; toKey: string }>> {
6700
+ const links = cards.flatMap(card => scanWikiLinks(card.content).flatMap(link => {
6701
+ const candidates = wikiLinkCandidates(cards.filter(target => target.status !== 'deprecated'), link.target)
6702
+ return candidates.length === 1 && candidates[0]!.entityId !== card.entityId ? [{ fromKey: cardKey(card), toKey: cardKey(candidates[0]!) }] : []
6703
+ }))
6557
6704
  const knowledge = await this.knowledgeStore()
6558
- if (knowledge === null) return []
6705
+ if (knowledge === null) return links
6559
6706
  const db = await this.storeHandle()
6560
- if (db === null) return []
6707
+ if (db === null) return links
6561
6708
  let placements: Map<string, CardKnowledgeRow[]>
6562
- try { placements = db.placements() } catch { return [] }
6563
- if (placements.size === 0) return []
6709
+ try { placements = db.placements() } catch { return links }
6710
+ if (placements.size === 0) return links
6564
6711
  /**
6565
6712
  * 单轴卡:坐标里恰好只有一个分量非空。
6566
6713
  *
@@ -6585,11 +6732,11 @@ export class KnowledgeRuntime {
6585
6732
  bucket.push(key)
6586
6733
  byAxis.set(axis, bucket)
6587
6734
  }
6588
- if (byAxis.size === 0) return []
6735
+ if (byAxis.size === 0) return links
6589
6736
  const ids = [...new Set([...placements.values()].flat().map(row => row.knowledgeId))]
6590
- if (ids.length === 0) return []
6737
+ if (ids.length === 0) return links
6591
6738
  let axes: Map<string, { objectId: string; actionId: string; sceneId: string }>
6592
- try { axes = await knowledge.axesOf(ids) } catch { return [] }
6739
+ try { axes = await knowledge.axesOf(ids) } catch { return links }
6593
6740
  const pairs = new Map<string, { fromKey: string; toKey: string }>()
6594
6741
  for (const [cardId, rows] of placements) {
6595
6742
  const fromKeys = keyById.get(cardId) ?? []
@@ -6609,7 +6756,7 @@ export class KnowledgeRuntime {
6609
6756
  }
6610
6757
  }
6611
6758
  }
6612
- return [...pairs.values()]
6759
+ return [...pairs.values(), ...links]
6613
6760
  }
6614
6761
 
6615
6762
  /**
@@ -8127,7 +8274,7 @@ export class KnowledgeRuntime {
8127
8274
  : card.metadata.wiki_kind
8128
8275
  ? events.filter(event => {
8129
8276
  const c = event.coordinateIds
8130
- return c.objectId === card.coordinate!.objectId && (card.metadata.wiki_kind === 'fact' || c.actionId === card.coordinate!.actionId)
8277
+ return coordinateKeyOf(c) === coordinateKeyOf(card.coordinate!)
8131
8278
  }).map(event => event.id).sort()
8132
8279
  : (eventsByCoordinate.get(coordinateKeyOf(card.coordinate)) ?? []).sort()
8133
8280
  const oldRaw = card.metadata.event_refs
@@ -8229,7 +8376,7 @@ export class KnowledgeRuntime {
8229
8376
 
8230
8377
  for (const event of input.events) {
8231
8378
  const seenEvidence = new Set<string>()
8232
- const evidence = [...event.sources, ...event.timeline.flatMap(step => step.sources)].filter(source => {
8379
+ const evidence = [...event.sources, ...event.timeline.flatMap(step => step.sources)].map(source => ({ ...source, sourceVersion: db.source(source.documentId)?.version })).filter(source => {
8233
8380
  if (!source.found) return false
8234
8381
  const key = source.documentId + '|' + source.quote
8235
8382
  if (seenEvidence.has(key)) return false
@@ -8273,7 +8420,7 @@ export class KnowledgeRuntime {
8273
8420
  symptom: event.symptom,
8274
8421
  coordinate: { object: event.object, action: event.action, scene: event.scene },
8275
8422
  coordinateIds,
8276
- timeline: event.timeline,
8423
+ timeline: event.timeline.map(step => ({ ...step, sources: step.sources.map(source => ({ ...source, sourceVersion: db.source(source.documentId)?.version })) })),
8277
8424
  rootCause: event.rootCause,
8278
8425
  rootCauseStatus: event.rootCauseStatus,
8279
8426
  outcome: event.outcome,
@@ -8338,7 +8485,7 @@ export class KnowledgeRuntime {
8338
8485
  if (!db || !audit || !await this.knowledgeStore()) throw new Error('知识库或任务历史不可用,无法启动处理任务')
8339
8486
  const store = new PipelineStore(db.connection(), audit.connection())
8340
8487
  const engine = new KnowledgePipeline(store, {
8341
- capture: () => ({ model: this.modelSeamNow(), note: this.extractionNote(), profile: structuredClone(this.organizeProfile()), organize: this.config.cardOrganize, format: 'table' }),
8488
+ capture: () => ({ model: this.modelSeamNow(), note: this.extractionNote(), extractionTimeoutMinutes: normalizeExtractionTimeoutMinutes(this.extractionTimeoutProvider?.()), profile: structuredClone(this.organizeProfile()), organize: this.config.cardOrganize, format: 'table' }),
8342
8489
  serialize: work => {
8343
8490
  this.compilePending += 1
8344
8491
  const run = this.compileTail.then(work, work)
@@ -8359,25 +8506,33 @@ export class KnowledgeRuntime {
8359
8506
  * 指纹,否则归一自己学到的实体(它们本来就出现在这份文档里)会让它永远无法复用。
8360
8507
  */
8361
8508
  fingerprint: (source, context) => this.pipelineExtractionFingerprint(source, context),
8362
- extract: async (source, context, onDelta) => {
8509
+ extract: async (source, context, onDelta, execution) => {
8363
8510
  if (!context.model.call) throw new Error(`抽取模型不可用:${context.model.why ?? '请在编译设置中选择模型'}`)
8364
8511
  const axes = await this.axisPromptBlock(source.content)
8365
- return runWikiTrial({ title: source.title, text: source.content, docId: source.sourceId, axes, note: context.note, call: context.model.call, modelRequest: { provider: context.model.provider, model: context.model.model, reasoningEffort: context.model.reasoningEffort }, outputFormat: context.format, sessionId: `knowledge:${this.baseId || 'default'}`, onDelta })
8512
+ return runResumableExtraction({ title: source.title, text: source.content, sourceId: source.sourceId, store, ...execution, recoveryTimeoutMs: normalizeExtractionTimeoutMinutes(context.extractionTimeoutMinutes) * 60_000, fingerprint: `${execution.fingerprint}:v${source.version}`, axes, note: context.note, call: context.model.call, modelRequest: { provider: context.model.provider, model: context.model.model, reasoningEffort: context.model.reasoningEffort, maxTokens: context.model.maxTokens }, outputFormat: context.format, sessionId: `knowledge:${this.baseId || 'default'}`, onDelta })
8366
8513
  },
8367
8514
  normalize: (source, checkpoint, context, force, output) => this.normalizePipelineSource(store, source, checkpoint, context, force, output),
8368
8515
  /**
8369
8516
  * 引擎只把 `snapshot` 原样传回来,所以那边看到的是 `unknown`;这里把它取回成
8370
8517
  * 真实类型(见 `materialize-snapshot.ts` 里为什么不放在 `pipeline/types.ts`)。
8371
8518
  */
8372
- derive: (sourceIds, context, force, output, checkpoint) => this.deriveProcedures(sourceIds, context, force, output, checkpoint),
8519
+ derive: (sourceIds, context, force, output, checkpoint, progress) => this.deriveProcedures(sourceIds, context, force, output, checkpoint, progress),
8373
8520
  groups: async context => {
8374
8521
  const snapshot = await this.materializationSnapshot(store, context)
8375
- return { groups: snapshot.groups, blocked: snapshot.blocked, blockedAmbiguous: snapshot.blockedAmbiguous, blockedAwaitingNormalize: snapshot.blockedAwaitingNormalize, snapshot }
8522
+ return { groups: snapshot.groups, blocked: snapshot.blocked, blockedAmbiguous: snapshot.blockedAmbiguous, blockedAwaitingNormalize: snapshot.blockedAwaitingNormalize, blockedItems: snapshot.blockedItems, snapshot }
8376
8523
  },
8377
8524
  materialize: (group, context, output, snapshot) => this.materializePipelineGroup(store, group, context, output, snapshot as MaterializationSnapshot),
8378
- build: async (taskId, checkpoint) => {
8525
+ link: async () => {
8526
+ const warnings: string[] = []
8527
+ const finalized = await this.runFinalizeStage(await this.loadCards(), Object.values(await this.readExtractedRelations()).flat(), warnings)
8528
+ if (warnings.length) throw new Error(warnings.join(';'))
8529
+ return `已更新 Wiki 双链与导航:${finalized.rewritten} 份`
8530
+ },
8531
+ build: async (taskId, checkpoint, progress) => {
8379
8532
  let result: Envelope<RebuildReport>
8380
- try { result = await this.compileOnce(taskId, 'all', undefined, { extract: false, checkpoint }) }
8533
+ try { result = await this.compileOnce(taskId, 'all', undefined, { extract: false, checkpoint, onProgress: step => {
8534
+ if ((step.id === 'index' || step.id === 'build' || step.id === 'publish') && step.state === 'running') progress?.(step.facts.join(';'))
8535
+ } }) }
8381
8536
  catch (cause) { await this.settleProgressFailure(taskId, cause); throw cause }
8382
8537
  this.settleProgressReport(taskId, result)
8383
8538
  if (!result.ok) throw new Error(result.error.message)
@@ -8443,7 +8598,7 @@ export class KnowledgeRuntime {
8443
8598
  .filter(entry => [entry.name, ...entry.aliases].map(normalizeAxisName).some(value => value.length > 0 && text.includes(value)))
8444
8599
  .map(entry => ({ id: entry.id, name: entry.name, aliases: entry.aliases, description: entry.description, guidance: entry.guidance }))
8445
8600
  .sort((left, right) => left.id.localeCompare(right.id)))
8446
- return contentHashOf(JSON.stringify({ source: source.sourceHash, title: source.title, contract: EXTRACTION_CONTRACT_VERSION, note: context.note, format: context.format, axes: relevant, model: { provider: context.model.provider, model: context.model.model, reasoningEffort: context.model.reasoningEffort } }))
8601
+ return contentHashOf(JSON.stringify({ source: source.sourceHash, title: source.title, contract: EXTRACTION_CONTRACT_VERSION, plan: EXTRACTION_PLAN_VERSION, note: context.note, format: context.format, axes: relevant, model: { provider: context.model.provider, model: context.model.model, reasoningEffort: context.model.reasoningEffort } }))
8447
8602
  }
8448
8603
 
8449
8604
  private async normalizationFingerprint(checkpoint: ExtractionCheckpoint, context: PipelineContext): Promise<string> {
@@ -8568,14 +8723,14 @@ export class KnowledgeRuntime {
8568
8723
  const cards = await this.loadCards()
8569
8724
  const relations = Object.values(await this.readExtractedRelations()).flat()
8570
8725
  for (const [key, entries] of planRelated(cards, relations, await this.projectedRelated(cards))) {
8571
- if (entries.length > 0) plan.set(key, entries.map(entry => ({ title: entry.title, entityType: entry.entityType, reason: entry.reason })))
8726
+ if (entries.length > 0) plan.set(key, entries.map(entry => ({ title: entry.title, entityType: entry.entityType, entityId: entry.entityId, reason: entry.reason })))
8572
8727
  }
8573
8728
  } catch { /* 增强项:算不出来就不给清单 */ }
8574
8729
  return plan
8575
8730
  }
8576
8731
 
8577
8732
  /** Independent, checkpointed Case -> Procedure compilation. No execution is inferred. */
8578
- private async deriveProcedures(sourceIds: string[] | undefined, context: PipelineContext, force: boolean, output: (text: string) => void, checkpoint: () => Promise<void>): Promise<{ done: number; total: number; reused: number; summary: string }> {
8733
+ private async deriveProcedures(sourceIds: string[] | undefined, context: PipelineContext, force: boolean, output: (text: string) => void, checkpoint: () => Promise<void>, progress?: (value: PipelineDeriveProgress) => void): Promise<{ done: number; total: number; reused: number; summary: string }> {
8579
8734
  const db = await this.storeHandle()
8580
8735
  const knowledge = await this.knowledgeStore()
8581
8736
  if (!db || !knowledge) throw new Error('Knowledge 存储不可用')
@@ -8584,24 +8739,29 @@ export class KnowledgeRuntime {
8584
8739
  const selected = sourceIds ? new Set(sourceIds) : undefined
8585
8740
  const cases = (await this.loadEvents()).filter(item => !selected || item.records.some(record => selected.has(record.sourceId)))
8586
8741
  let done = 0, reused = 0
8742
+ progress?.({ done, total: cases.length, reused, phase: 'preparing', summary: `已读取 ${cases.length} 个 Case` })
8587
8743
  const strings = (value: unknown): string[] => Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string').map(v => v.trim()).filter(Boolean) : []
8588
8744
  for (const item of cases) {
8589
8745
  await checkpoint()
8746
+ const observe = (phase: PipelineDeriveProgress['phase'], state: PipelineItemState['state'], summary: string, failed = 0): void => progress?.({ done: done + failed, total: cases.length, reused, failed, phase, summary, item: { id: item.id, title: item.title, state } })
8747
+ observe('processing', 'processing', `正在检查 Case:${item.title}`)
8590
8748
  const caseVersion = item.version ?? 1
8591
8749
  const evidence = [...item.sources, ...item.timeline.flatMap(step => step.sources)]
8592
8750
  const sourceVersions = new Map(evidence.map(e => [e.documentId, db.source(e.documentId)?.version]))
8593
8751
  const fingerprint = contentHashOf(JSON.stringify({ contract: DERIVE_CONTRACT_VERSION, item: { ...item, updatedAt: undefined }, sourceVersions: [...sourceVersions], model: { provider: context.model.provider, model: context.model.model, reasoningEffort: context.model.reasoningEffort } }))
8594
- const receipt = db.raw('SELECT fingerprint, knowledge_ids FROM pipeline_derivations WHERE case_id=?').get(item.id) as { fingerprint: string; knowledge_ids: string } | undefined
8595
- if (!force && receipt?.fingerprint === fingerprint) { done++; reused++; continue }
8752
+ const receipt = db.raw('SELECT fingerprint, response, knowledge_ids FROM pipeline_derivations WHERE case_id=?').get(item.id) as { fingerprint: string; response: string; knowledge_ids: string } | undefined
8753
+ // Replay validated cached output through ingest too: normalization may have withdrawn its support.
8754
+ const cachedResponse = !force && receipt?.fingerprint === fingerprint ? receipt.response : undefined
8596
8755
  const auditId = randomUUID()
8597
8756
  const request = { system: buildDeriveSystem(), prompt: JSON.stringify({ case: { ...item, version: caseVersion }, sourceEvidence: evidence, sourceVersions: [...sourceVersions] }) }
8598
- let response = '{"procedures":[],"skipped_cases":[]}'
8757
+ let response = cachedResponse ?? '{"procedures":[],"skipped_cases":[]}'
8599
8758
  db.raw('INSERT INTO pipeline_derivation_runs VALUES (?,?,?,?,?,?,?,?,?)').run(auditId, item.id, caseVersion, fingerprint, JSON.stringify(request), '', 'running', null, new Date().toISOString())
8600
8759
  try {
8601
- if (item.timeline.some(step => step.type === 'check' || step.type === 'action')) {
8760
+ if (cachedResponse === undefined && item.timeline.some(step => step.type === 'check' || step.type === 'action')) {
8602
8761
  if (!context.model.call) throw new Error(`提炼模型不可用:${context.model.why ?? '请选择模型'}`)
8762
+ observe('waiting-model', 'processing', `正在提炼 Case:${item.title}`)
8603
8763
  response = await context.model.call({ ...request, sessionId: `knowledge:${this.baseId || 'default'}:derive`, onDelta: output })
8604
- } else output(`Case ${item.id} 没有已执行过程,不反推 Procedure。\n`)
8764
+ } else if (cachedResponse === undefined) output(`Case ${item.id} 没有已执行过程,不反推 Procedure。\n`)
8605
8765
  const parsed = JSON.parse(response.trim().replace(/^```(?:json)?\s*/, '').replace(/\s*```$/, '')) as { procedures?: Array<Record<string, unknown>> }
8606
8766
  if (!Array.isArray(parsed.procedures)) throw new Error('Case 提炼返回缺少 procedures 数组')
8607
8767
  if (this.closed || db.event(item.id)?.version !== caseVersion || [...sourceVersions].some(([id, version]) => db.source(id)?.version !== version)) throw new Error('Case 或来源在提炼期间变化,请重试')
@@ -8636,7 +8796,10 @@ export class KnowledgeRuntime {
8636
8796
  db.raw("UPDATE pipeline_derivation_runs SET response=?,state='succeeded' WHERE id=?").run(response, auditId)
8637
8797
  output(`Case ${item.id}:保存 ${ids.length} 条候选 Procedure。\n`)
8638
8798
  done++
8799
+ if (cachedResponse !== undefined) reused++
8800
+ observe(cachedResponse !== undefined ? 'reused' : 'completed', cachedResponse !== undefined ? 'reused' : 'succeeded', `${item.title}:已保存 ${ids.length} 条候选方法`)
8639
8801
  } catch (cause) {
8802
+ observe('failed', 'failed', `${item.title}:${cause instanceof Error ? cause.message : String(cause)}`, 1)
8640
8803
  db.raw("UPDATE pipeline_derivation_runs SET response=?,state='failed',error=? WHERE id=?").run(response, cause instanceof Error ? cause.message : String(cause), auditId)
8641
8804
  throw cause
8642
8805
  }
@@ -8651,7 +8814,7 @@ export class KnowledgeRuntime {
8651
8814
  * 两处必须用同一个表达式,否则「什么算变化」就有两套说法。
8652
8815
  */
8653
8816
  private pipelineGroupFingerprint(knowledge: Knowledge[], context: PipelineContext): string {
8654
- return contentHashOf(JSON.stringify({ knowledge: knowledge.map(row => ({ id: row.id, kind: row.kind ?? 'fact', data: row.data, text: row.text, status: row.status, sources: row.sources.filter(source => !source.withdrawn) })).sort((a, b) => a.id.localeCompare(b.id)), profile: context.profile, organize: context.organize, model: context.organize ? { provider: context.model.provider, model: context.model.model, reasoningEffort: context.model.reasoningEffort } : undefined }))
8817
+ return contentHashOf(JSON.stringify({ contract: 'coordinate-wiki-v2', organizeVersion: context.organize ? ORGANIZE_VERSION : undefined, knowledge: knowledge.map(row => ({ id: row.id, kind: row.kind ?? 'fact', data: row.data, text: row.text, status: row.status, sources: row.sources.filter(source => !source.withdrawn) })).sort((a, b) => a.id.localeCompare(b.id)), profile: context.profile, organize: context.organize, model: context.organize ? { provider: context.model.provider, model: context.model.model, reasoningEffort: context.model.reasoningEffort } : undefined }))
8655
8818
  }
8656
8819
 
8657
8820
  /**
@@ -8688,12 +8851,12 @@ export class KnowledgeRuntime {
8688
8851
  ].filter(Boolean).map(text => `- ${text}[^${row.id}]`).join('\n')
8689
8852
  }
8690
8853
 
8691
- private async freshGroupFingerprint(coordinate: CardCoordinate, context: PipelineContext, db: KnowledgeDb): Promise<string> {
8854
+ private async freshGroupFingerprint(coordinate: CardCoordinate, wikiKind: WikiKind, context: PipelineContext, db: KnowledgeDb): Promise<string> {
8692
8855
  const store = await this.knowledgeStore()
8693
8856
  if (!store) return ''
8694
8857
  const raw: Knowledge[] = []
8695
8858
  for (let offset = 0;; offset += 500) {
8696
- const page = await store.list({ objectId: coordinate.objectId, kind: coordinate.actionId ? 'procedure' : 'fact', ...(coordinate.actionId ? { actionId: coordinate.actionId } : {}), excludeWithdrawn: true, limit: 500, offset })
8859
+ const page = await store.list({ objectId: coordinate.objectId, kind: wikiKind === 'way' ? 'procedure' : 'fact', actionId: coordinate.actionId, sceneId: coordinate.sceneId, excludeWithdrawn: true, limit: 500, offset })
8697
8860
  raw.push(...page.knowledge)
8698
8861
  if (offset + page.knowledge.length >= page.total) break
8699
8862
  }
@@ -8705,7 +8868,7 @@ export class KnowledgeRuntime {
8705
8868
  if (source) bodies.set(id, { version: source.version, content: source.record.content })
8706
8869
  }
8707
8870
  const rows = raw
8708
- .filter(row => row.status !== 'deprecated' && (row.kind !== 'procedure' || (row.data?.procedure?.completeness === 'complete' && row.data.procedure.method.length > 0 && row.status !== 'disputed' && row.data.procedure.caseRefs.every(ref => db.event(ref.id)?.version === ref.version) && !row.sources.some(source => source.toVersion !== undefined && db.source(source.documentId)?.version !== source.toVersion))))
8871
+ .filter(row => row.status !== 'deprecated' && (row.kind !== 'procedure' || (row.data?.procedure?.completeness === 'complete' && row.data.procedure.method.length > 0 && row.status !== 'disputed' && row.data.procedure.caseRefs.every(ref => db.event(ref.id)?.version === ref.version) && !row.sources.some(source => !source.withdrawn && source.toVersion !== undefined && db.source(source.documentId)?.version !== source.toVersion))))
8709
8872
  .map(row => assessKnowledgeEvidence(row, db, bodies))
8710
8873
  .map(row => ({ ...row, sources: row.sources.filter(source => !source.withdrawn && source.evidenceStatus !== 'missing') }))
8711
8874
  .filter(row => row.sources.length > 0)
@@ -8726,12 +8889,13 @@ export class KnowledgeRuntime {
8726
8889
  */
8727
8890
  const plans = options.plans !== false
8728
8891
  const unresolved = store.unresolvedKnowledge()
8729
- const grouped = new Map<string, { coordinate: CardCoordinate; knowledge: Knowledge[] }>()
8892
+ const grouped = new Map<string, { coordinate: CardCoordinate; wikiKind: WikiKind; knowledge: Knowledge[] }>()
8730
8893
  let blockedAmbiguous = 0
8731
8894
  let blockedAwaitingNormalize = 0
8732
- const groupFor = (coordinate: CardCoordinate) => {
8733
- const key = coordinateKeyOf(coordinate)
8734
- if (!grouped.has(key)) grouped.set(key, { coordinate, knowledge: [] })
8895
+ const blockedItems: MaterializationSnapshot['blockedItems'] = []
8896
+ const groupFor = (coordinate: CardCoordinate, wikiKind: WikiKind) => {
8897
+ const key = wikiGroupKey(wikiKind, coordinate)
8898
+ if (!grouped.has(key)) grouped.set(key, { coordinate, wikiKind, knowledge: [] })
8735
8899
  return grouped.get(key)!
8736
8900
  }
8737
8901
  const caseStore = await this.storeHandle()
@@ -8739,24 +8903,29 @@ export class KnowledgeRuntime {
8739
8903
  if (row.kind === 'case') continue
8740
8904
  const coordinate = wikiProjection(row.kind ?? 'fact', row)
8741
8905
  const method = row.data?.procedure
8742
- if (!coordinate || (row.kind === 'procedure' && (!method || method.completeness !== 'complete' || !method.method.length || row.status === 'disputed' || method.caseRefs.some(ref => caseStore?.event(ref.id)?.version !== ref.version) || row.sources.some(source => source.toVersion !== undefined && caseStore?.source(source.documentId)?.version !== source.toVersion)))) { blockedAmbiguous += 1; continue }
8743
- groupFor(coordinate).knowledge.push(row)
8906
+ if (!coordinate || (row.kind === 'procedure' && (!method || method.completeness !== 'complete' || !method.method.length || row.status === 'disputed' || method.caseRefs.some(ref => caseStore?.event(ref.id)?.version !== ref.version) || row.sources.some(source => source.toVersion !== undefined && caseStore?.source(source.documentId)?.version !== source.toVersion)))) {
8907
+ blockedAmbiguous += 1
8908
+ blockedItems.push({ knowledgeId: row.id, sourceIds: [...new Set(row.sources.map(source => source.documentId).filter(Boolean))], ...(coordinate ? { groupKey: wikiGroupKey(row.kind === 'procedure' ? 'way' : 'fact', coordinate) } : {}) })
8909
+ continue
8910
+ }
8911
+ groupFor(coordinate, row.kind === 'procedure' ? 'way' : 'fact').knowledge.push(row)
8744
8912
  }
8745
8913
  const db = await this.storeHandle()
8746
8914
  const events = new Map<string, EventEntity[]>()
8747
8915
  for (const event of db?.events() ?? []) {
8748
8916
  const coordinate = event.coordinateIds
8749
8917
  if (!coordinate.objectId) continue
8750
- for (const projection of [wikiProjection('fact', coordinate), wikiProjection('procedure', coordinate)]) {
8751
- if (!projection) continue
8752
- const key = coordinateKeyOf(projection)
8918
+ for (const kind of ['fact', 'way'] as const) {
8919
+ const key = wikiGroupKey(kind, coordinate)
8753
8920
  const bucket = events.get(key) ?? []
8754
8921
  bucket.push(event)
8755
8922
  events.set(key, bucket)
8756
8923
  }
8757
8924
  }
8758
8925
  const cards = await this.loadCards()
8759
- const byCoordinate = new Map(cards.filter(card => card.coordinate).map(card => [coordinateKeyOf(card.coordinate!), card]))
8926
+ const groupKeyOf = (card: KnowledgeCard): string => card.coordinate && (card.metadata.wiki_kind === 'fact' || card.metadata.wiki_kind === 'way')
8927
+ ? wikiGroupKey(card.metadata.wiki_kind, card.coordinate) : card.coordinate ? coordinateKeyOf(card.coordinate) : ''
8928
+ const byCoordinate = new Map(cards.filter(card => card.coordinate).map(card => [groupKeyOf(card), card]))
8760
8929
  const groups: MaterializationGroup[] = []
8761
8930
  const knowledge = new Map<string, Knowledge[]>()
8762
8931
  for (const [key, value] of grouped) {
@@ -8770,11 +8939,13 @@ export class KnowledgeRuntime {
8770
8939
  const needsNormalization = this.needsNormalizationForGroup(store, value.knowledge, unresolved, db)
8771
8940
  if (needsNormalization) blockedAwaitingNormalize += value.knowledge.length
8772
8941
  knowledge.set(key, value.knowledge)
8773
- groups.push({ key, wikiKind: value.coordinate.actionId ? 'way' : 'fact', title: existing?.title ?? `${names.object}${names.action ? ` · ${names.action}` : ''} · ${value.coordinate.actionId ? 'Wiki Way' : 'Wiki Fact'}`, coordinate: value.coordinate, knowledgeCount: value.knowledge.length, eventCount: events.get(key)?.length ?? 0, sourceIds: contributingSources, ...(existing ? { cardId: existing.entityId } : {}), state: needsNormalization ? 'blocked' : current ? 'current' : existing ? 'changed' : 'new', ...(needsNormalization ? { reason: '分组中的来源已变化、新抽取待归一或坐标有歧义,请先完成归一' } : {}), fingerprint })
8942
+ groups.push({ key, wikiKind: value.wikiKind, title: existing?.title ?? `${names.object}${names.action ? ` · ${names.action}` : ''}${names.scene ? ` · ${names.scene}` : ''} · ${value.wikiKind === 'way' ? 'Wiki Way' : 'Wiki Fact'}`, coordinate: value.coordinate, knowledgeCount: value.knowledge.length, eventCount: events.get(key)?.length ?? 0, sourceIds: contributingSources, ...(existing ? { cardId: existing.entityId } : {}), state: needsNormalization ? 'blocked' : current ? 'current' : existing ? 'changed' : 'new', ...(needsNormalization ? { reason: '分组中的来源已变化、新抽取待归一或坐标有歧义,请先完成归一' } : {}), fingerprint })
8774
8943
  }
8775
8944
  for (const card of cards) {
8776
- if (!card.coordinate || grouped.has(coordinateKeyOf(card.coordinate)) || !store.materialization(coordinateKeyOf(card.coordinate)) || card.status === 'deprecated') continue
8777
- groups.push({ key: coordinateKeyOf(card.coordinate), title: card.title, coordinate: card.coordinate, knowledgeCount: 0, eventCount: 0, sourceIds: card.sourceRefs, cardId: card.entityId, state: card.sourceRefs.some(id => unresolved.sources.has(id)) ? 'blocked' : 'withdrawn', ...(card.sourceRefs.some(id => unresolved.sources.has(id)) ? { reason: '来源中的坐标仍有歧义,请先完成归一' } : {}), fingerprint: 'withdrawn' })
8945
+ const key = groupKeyOf(card)
8946
+ const legacyReceipt = card.coordinate ? store.materialization(coordinateKeyOf(card.coordinate)) : undefined
8947
+ if (!card.coordinate || grouped.has(key) || (!store.materialization(key) && !legacyReceipt && card.metadata.pipeline_materialization !== true) || card.status === 'deprecated') continue
8948
+ groups.push({ key, wikiKind: card.metadata.wiki_kind === 'way' ? 'way' : 'fact', title: card.title, coordinate: card.coordinate, knowledgeCount: 0, eventCount: 0, sourceIds: card.sourceRefs, cardId: card.entityId, state: card.sourceRefs.some(id => unresolved.sources.has(id)) ? 'blocked' : 'withdrawn', ...(card.sourceRefs.some(id => unresolved.sources.has(id)) ? { reason: '来源中的坐标仍有歧义,请先完成归一' } : {}), fingerprint: 'withdrawn' })
8778
8949
  }
8779
8950
  const sources = plans ? (await this.loadSources()).map(source => ({ sourceId: source.sourceId, content: source.content })) : []
8780
8951
  const imagePlan = plans
@@ -8805,7 +8976,7 @@ export class KnowledgeRuntime {
8805
8976
  return {
8806
8977
  groups: groups.sort((a, b) => a.title.localeCompare(b.title)),
8807
8978
  blocked: blockedAmbiguous + blockedAwaitingNormalize,
8808
- blockedAmbiguous, blockedAwaitingNormalize,
8979
+ blockedAmbiguous, blockedAwaitingNormalize, blockedItems,
8809
8980
  knowledge, events, sources, images: imagePlan.byCard, related: relatedByKey,
8810
8981
  }
8811
8982
  }
@@ -8836,7 +9007,7 @@ export class KnowledgeRuntime {
8836
9007
  const statusBeforeDeprecate = (await this.readCard(existing.entityId))?.status
8837
9008
  if (statusBeforeDeprecate === undefined) throw new Error('卡片在处理期间已被删除,本次物化未提交,请刷新后重试')
8838
9009
  if (statusBeforeDeprecate !== existing.status) throw new Error(`卡片在处理期间被人工改动(状态 ${existing.status} → ${statusBeforeDeprecate}),本次物化未提交,请重试`)
8839
- const written = await this.writeCard({ ...existing, status: 'deprecated', origin: 'compile' })
9010
+ const written = await this.writeCard({ ...existing, metadata: { ...existing.metadata, wiki_deprecation_origin: existing.status === 'deprecated' && existing.metadata.wiki_deprecation_origin !== 'withdrawal' ? 'manual' : 'withdrawal' }, status: 'deprecated', origin: 'compile' })
8840
9011
  if (!written.ok) throw new Error(written.error.message)
8841
9012
  store.materialized(group.key, group.fingerprint, existing.entityId)
8842
9013
  // 停用也是一次物化:卡片从可召回里退出,这一步发生过,就该留一条轨迹。
@@ -8855,7 +9026,7 @@ export class KnowledgeRuntime {
8855
9026
  let content = knowledge.map(row => `## ${row.data?.procedure?.question ?? row.text.split('\n')[0]!.slice(0, 60)}\n\n${this.organizeKnowledgeText(row)}[^${row.id}]${boundaries.get(row.id) ? `\n\n### 适用条件与边界\n\n${boundaries.get(row.id)}` : ''}`).join('\n\n')
8856
9027
  let summary = knowledge[0]?.text ?? ''
8857
9028
  let placements: CardKnowledgeRow[] = knowledge.map((row, index) => ({ knowledgeId: row.id, section: row.data?.procedure?.question ?? row.text.split('\n')[0]!.slice(0, 60), ordinal: index + 1, origin: 'compiler' }))
8858
- let metadata: Record<string, unknown> = { ...(existing?.metadata ?? {}), pipeline_materialization: true, wiki_kind: group.wikiKind ?? (group.coordinate.actionId ? 'way' : 'fact') }
9029
+ let metadata: Record<string, unknown> = { ...(existing?.metadata ?? {}), pipeline_materialization: true, wiki_kind: group.wikiKind ?? 'fact', wiki_coordinate_version: 2, knowledge_versions: Object.fromEntries(knowledge.map(row => [row.id, row.version])) }
8859
9030
  const entityId = existing?.entityId ?? `coord-${hash(group.key)}`
8860
9031
  const images = snapshot.images.get(group.key) ?? []
8861
9032
  const related = existing && snapshot.related.has(group.key) ? { related: snapshot.related.get(group.key)! } : {}
@@ -8880,7 +9051,7 @@ export class KnowledgeRuntime {
8880
9051
  * 「只有知识行变了」;这条按坐标重算整组指纹,能把那种变化也挡住。
8881
9052
  */
8882
9053
  if (this.needsNormalizationForGroup(store, knowledge, store.unresolvedKnowledge(), db)) throw new Error('卡片组织期间事实发生变化,结果未提交,请重试')
8883
- if (await this.freshGroupFingerprint(group.coordinate, context, db) !== group.fingerprint) throw new Error('卡片分组在处理期间事实发生了变化,结果未提交,请重试')
9054
+ if (await this.freshGroupFingerprint(group.coordinate, group.wikiKind ?? 'fact', context, db) !== group.fingerprint) throw new Error('卡片分组在处理期间事实发生了变化,结果未提交,请重试')
8884
9055
  if (this.closed) throw new Error('知识库已关闭,本次物化未提交')
8885
9056
  /**
8886
9057
  * 状态必须**在写之前**重读,并且和读到的旧值比对。
@@ -8896,7 +9067,8 @@ export class KnowledgeRuntime {
8896
9067
  if (statusAtWrite !== existing?.status) {
8897
9068
  throw new Error(`卡片在处理期间被人工改动(状态 ${existing?.status ?? '未知'} → ${statusAtWrite ?? '未知'}),本次物化未提交,请重试`)
8898
9069
  }
8899
- const written = await this.writeCard({ entityType, entityId, title, content, summary, sourceRefs: group.sourceIds, status: statusAtWrite === 'deprecated' ? 'unreviewed' : statusAtWrite ?? 'unreviewed', coordinate: group.coordinate, placements, metadata, origin: 'compile' })
9070
+ const nextStatus = statusAtWrite === 'deprecated' && existing?.metadata.wiki_deprecation_origin === 'withdrawal' ? 'unreviewed' : statusAtWrite ?? 'unreviewed'
9071
+ const written = await this.writeCard({ entityType, entityId, title, content, summary, sourceRefs: group.sourceIds, status: nextStatus, coordinate: group.coordinate, placements, metadata, origin: 'compile' })
8900
9072
  if (!written.ok) throw new Error(written.error.message)
8901
9073
  db.setCardEvents(entityId, eventIds)
8902
9074
  store.materialized(group.key, group.fingerprint, entityId)
@@ -8915,7 +9087,7 @@ export class KnowledgeRuntime {
8915
9087
  card: {
8916
9088
  id: entityId,
8917
9089
  title,
8918
- status: statusAtWrite === 'deprecated' ? 'unreviewed' : statusAtWrite ?? 'unreviewed',
9090
+ status: nextStatus,
8919
9091
  characters: content.length,
8920
9092
  events: eventIds.length,
8921
9093
  images: images.length,
@@ -8939,7 +9111,9 @@ export class KnowledgeRuntime {
8939
9111
  const db = await this.derivedHandle()
8940
9112
  const manifest = db?.artifact('build-manifest.json')
8941
9113
  const hashes = manifest ? (JSON.parse(manifest) as { cardHashes?: Record<string, string> }).cardHashes ?? {} : {}
8942
- const cards = await this.loadCards()
9114
+ const allCards = await this.loadCards()
9115
+ const unavailable = await this.wikiUnavailability(allCards)
9116
+ const cards = allCards.filter(card => !card.metadata.wiki_kind || !unavailable.has(cardKey(card)))
8943
9117
  const indexPending = cards.some(card => hashes[`${card.entityType}:${card.entityId}`] !== this.cardHashOf(card)) || Object.keys(hashes).length !== cards.length
8944
9118
  return { sources: rows, groups, blocked, pendingNew: groups.filter(group => group.state === 'new').length, pendingUpdated: groups.filter(group => group.state === 'changed').length, pendingWithdrawn: groups.filter(group => group.state === 'withdrawn').length, indexPending }
8945
9119
  }
@@ -9131,7 +9305,7 @@ export class KnowledgeRuntime {
9131
9305
  * 跑一趟编译的**实体**(0066)。它不做排队,也不对外承诺「你 await 它就等于你发起了
9132
9306
  * 它」—— 那两件事分别在 `enqueueCompile()` 与 `rebuild()` / `startCompile()` 里。
9133
9307
  */
9134
- private async compileOnce(runId: string, only: 'all' | 'schema' | 'wiki' | 'embeddings' = 'all', lineage?: LineageContext, options: { extract?: boolean; forceExtract?: boolean; checkpoint?: () => Promise<void> } = {}): Promise<Envelope<RebuildReport>> {
9308
+ private async compileOnce(runId: string, only: 'all' | 'schema' | 'wiki' | 'embeddings' = 'all', lineage?: LineageContext, options: { extract?: boolean; forceExtract?: boolean; checkpoint?: () => Promise<void>; onProgress?: (step: CompileActivityView) => void } = {}): Promise<Envelope<RebuildReport>> {
9135
9309
  if (only === 'all' || only === 'embeddings') await this.environmentGuard?.('embedding')
9136
9310
  this.publication = { artifacts: new Map() }
9137
9311
  const configAtStart = JSON.stringify({ config: this.config, fingerprint: this.embeddingFingerprint() })
@@ -9165,18 +9339,23 @@ export class KnowledgeRuntime {
9165
9339
  }
9166
9340
  this.publication.artifacts.set(COMPILE_SNAPSHOT, JSON.stringify(snapshot))
9167
9341
  await this.checkpointCompileTask(runId)
9342
+ const changedWiki = await this.wikiUnavailability(snapshot.cards, new Map(snapshot.placements))
9343
+ if (snapshot.cards.some(card => card.metadata.wiki_kind && changedWiki.has(cardKey(card)))) throw new Error('WIKI_CHANGED_DURING_BUILD: Wiki 的支撑知识在构建期间变化,请重新组织并构建索引')
9168
9344
  if (JSON.stringify({ config: this.config, fingerprint: this.embeddingFingerprint() }) !== configAtStart) throw new Error('COMPILE_CONFIG_CHANGED: 编译期间设置已变化,请重新编译')
9169
9345
  await options.checkpoint?.()
9346
+ options.onProgress?.({ id: 'publish', state: 'running', facts: ['正在统一发布检索索引版本'] })
9170
9347
  db.publish(this.publication)
9171
9348
  this.vectorIndex = null
9172
9349
  }
9173
9350
  return result
9174
9351
  } finally {
9175
9352
  this.publication = undefined
9353
+ // Compilation filters unavailable projections; the browsing cache still represents all saved Wiki.
9354
+ this.cards = await this.loadCards()
9176
9355
  }
9177
9356
  }
9178
9357
 
9179
- private async prepareCompile(runId: string, only: 'all' | 'schema' | 'wiki' | 'embeddings' = 'all', lineage?: LineageContext, options: { extract?: boolean; forceExtract?: boolean } = {}): Promise<Envelope<RebuildReport>> {
9358
+ private async prepareCompile(runId: string, only: 'all' | 'schema' | 'wiki' | 'embeddings' = 'all', lineage?: LineageContext, options: { extract?: boolean; forceExtract?: boolean; onProgress?: (step: CompileActivityView) => void } = {}): Promise<Envelope<RebuildReport>> {
9180
9359
  /**
9181
9360
  * 进度记录**先占位**:`runId` 一落下去,这一趟就有了名字 —— 之后无论从哪个出口结束
9182
9361
  * (三种 return,或者抛异常),收口都能认出这条记录属于谁。
@@ -9258,6 +9437,7 @@ export class KnowledgeRuntime {
9258
9437
  const setStep = (id: CompileStepId, state: CompileStepView['state'], facts: string[]): void => {
9259
9438
  const current = this.compileProgress ?? blankProgress(runId, only, startedAt, 'running', queuedAt)
9260
9439
  this.writeCompileProgress({ ...current, steps: current.steps.map(step => step.id === id ? { ...step, state, facts } : step) })
9440
+ options.onProgress?.({ id, state, facts })
9261
9441
  }
9262
9442
  this.cards = await this.loadCards()
9263
9443
  const sources = await this.loadSources()
@@ -9436,6 +9616,9 @@ export class KnowledgeRuntime {
9436
9616
  await this.checkpointCompileTask(runId)
9437
9617
  // 相关 metadata 或旧正文清理刚落盘:stage 2 必须看到最新卡片。
9438
9618
  if (finalize.rewritten > 0) this.cards = await this.loadCards()
9619
+ const unavailableWiki = await this.wikiUnavailability(this.cards)
9620
+ for (const card of this.cards) if (card.metadata.wiki_kind && unavailableWiki.has(cardKey(card))) warnings.push(`Wiki「${card.title}」未进入索引:${unavailableWiki.get(cardKey(card))}`)
9621
+ this.cards = this.cards.filter(card => !card.metadata.wiki_kind || !unavailableWiki.has(cardKey(card)))
9439
9622
  /**
9440
9623
  * 索引页也是这次编译的产物,所以它变了也算「语料变了」。
9441
9624
  *
@@ -9848,12 +10031,11 @@ export class KnowledgeRuntime {
9848
10031
  }
9849
10032
 
9850
10033
  /**
9851
- * Coordinate 路:先解析 Query 中出现的 Object / Action / Scene,再把命中的卡片拆成
10034
+ * Coordinate 路:解析 Query 中出现的 Object / Action / Scene,再把命中的卡片拆成
9852
10035
  * Section 候选。它和 FTS / Vector 使用同一个 `unitId` 粒度,后面才能统一 RRF 与重排。
9853
10036
  *
9854
10037
  * 这里不让模型自由生成实体名:注册表负责 canonical / alias 命中,只有真实存在的
9855
- * 坐标 id 才能进入候选。意图目前使用轻量规则分类,结果会随 queryAnalysis 一起返回,
9856
- * 后续接入模型消歧时不需要改变召回合同。
10038
+ * 坐标 id 才能进入候选。未解析的原文继续交给关键词、向量路线与统一重排。
9857
10039
  */
9858
10040
  private async coordinateRoute(
9859
10041
  query: string,
@@ -10243,7 +10425,8 @@ export class KnowledgeRuntime {
10243
10425
  for (const ref of hit.sourceRefs) {
10244
10426
  const source = corpus.get(ref)
10245
10427
  if (!source) continue
10246
- evidence.push(...evidenceExcerpts(ref, source.title, source.content, queryTerms))
10428
+ const version = (await this.storeHandle())?.source(ref)?.version
10429
+ evidence.push(...evidenceExcerpts(ref, source.title, source.content, queryTerms).map(excerpt => ({ ...excerpt, readRef: { kb: this.baseId || 'default', source_id: ref, version, content_hash: createHash('sha256').update(source.content).digest('hex'), quote: excerpt.excerpt.replace(/…$/, '') } })))
10247
10430
  }
10248
10431
  if (evidence.length > 0) {
10249
10432
  hit.evidence = evidence
@@ -10317,7 +10500,7 @@ export class KnowledgeRuntime {
10317
10500
  const collected = new Map<string, RecallKnowledge>()
10318
10501
  for (const row of perHit[index] ?? []) {
10319
10502
  const resolved = rows.get(row.knowledgeId)
10320
- if (resolved !== undefined) collected.set(resolved.id, resolved)
10503
+ if (resolved !== undefined) collected.set(resolved.id, { ...resolved, ...(resolved.readRef ? { readRef: { ...resolved.readRef, kb: this.baseId || 'default' } } : {}) })
10321
10504
  }
10322
10505
  if (collected.size > 0) {
10323
10506
  hit.knowledge = [...collected.values()]
@@ -10356,7 +10539,7 @@ export class KnowledgeRuntime {
10356
10539
  includeProblems = false,
10357
10540
  mode?: RecallMode,
10358
10541
  lineage?: LineageContext,
10359
- options: { contextTopN?: number; rerank?: boolean; contextMaxChars?: number; detail?: boolean } = {},
10542
+ options: KnowledgeScope & { contextTopN?: number; rerank?: boolean; contextMaxChars?: number; detail?: boolean } = {},
10360
10543
  ): Promise<Envelope<RecallResult>> {
10361
10544
  const recallId = generatedId('recall')
10362
10545
  /**
@@ -10395,6 +10578,8 @@ export class KnowledgeRuntime {
10395
10578
  if (mode !== undefined && !['fts', 'vector', 'hybrid'].includes(String(mode))) {
10396
10579
  return failure(empty(), 'MODE_INVALID', '召回 mode 只支持 fts、vector、hybrid;graph 不是召回模式', false)
10397
10580
  }
10581
+ if (scopeError(options)) return failure(empty(), 'SCOPE_INVALID', scopeError(options)!, false)
10582
+ if (options.contextMaxChars !== undefined && (!Number.isInteger(options.contextMaxChars) || options.contextMaxChars < 0)) return failure(empty(), 'CONTEXT_BUDGET_INVALID', 'context_max_chars 必须为非负整数。', false)
10398
10583
  if (!query?.trim()) return failure(empty(), 'QUERY_REQUIRED', 'query 不能为空', true)
10399
10584
  if (!Number.isInteger(recallTopK) || recallTopK < 1 || recallTopK > 50) return failure(empty(), 'LIMIT_INVALID', '召回 top_k 必须是 1 到 50 的整数', false)
10400
10585
  if (!Number.isInteger(requestedTopN) || requestedTopN < 1 || (options.contextTopN !== undefined && requestedTopN > recallTopK)) return failure(empty(), 'CONTEXT_TOP_N_INVALID', `上下文 top_n 必须是 1 到 ${recallTopK} 的整数`, false)
@@ -10443,26 +10628,17 @@ export class KnowledgeRuntime {
10443
10628
  for (const key of liveKeys) {
10444
10629
  if (snapshotCardByKey.get(key)?.sourceRefs.some(ref => !liveSourceIds.has(ref))) liveKeys.delete(key)
10445
10630
  }
10446
- // A method becomes unavailable immediately when its supporting Case or structured content changes.
10447
- // Do not wait for a later rebuild to stop serving the old instructions.
10448
- const unsafeProcedures = new Set(snapshot.knowledge.filter(row => {
10449
- if (row.kind !== 'procedure') return false
10450
- const current = truthAtRead?.raw('SELECT status,head_version FROM knowledge WHERE id=?').get(row.id) as { status: string; head_version: number } | undefined
10451
- return !current || current.status === 'deprecated' || current.status === 'disputed' || current.head_version !== row.version
10452
- || !row.data?.procedure || row.data.procedure.completeness !== 'complete'
10453
- || row.data.procedure.caseRefs.some(ref => truthAtRead?.event(ref.id)?.version !== ref.version)
10454
- || row.sources.some(source => source.toVersion !== undefined && truthAtRead?.source(source.documentId)?.version !== source.toVersion)
10455
- }).map(row => row.id))
10456
- const unsafeCardIds = new Set(snapshot.placements.filter(([, placements]) => placements.some(p => unsafeProcedures.has(p.knowledgeId))).map(([id]) => id))
10457
- for (const key of liveKeys) if (unsafeCardIds.has(snapshotCardByKey.get(key)?.entityId ?? '')) liveKeys.delete(key)
10631
+ const unavailableWiki = await this.wikiUnavailability(snapshot.cards, new Map(snapshot.placements))
10632
+ for (const key of unavailableWiki.keys()) liveKeys.delete(key)
10458
10633
  let publishedGraph = visibleGraph(snapshot.graph, truthAtRead ? liveGraphKeys(truthAtRead) : new Set())
10459
10634
  const currentByKey = new Map(liveCards.map(card => [cardKey(card), card]))
10460
10635
  let cards: KnowledgeCard[] = snapshot.cards.filter(card => liveKeys.has(cardKey(card))).map(card => {
10461
10636
  const current = currentByKey.get(cardKey(card))!
10462
10637
  return { ...card, status: current.status, annotations: current.annotations, review: current.review,
10463
10638
  metadata: { ...card.metadata, ...(current.metadata.evidence_needs_review === true ? { evidence_needs_review: true } : {}) } }
10464
- })
10465
- const eligible = snapshot.notes.filter(unit => liveKeys.has(`${unit.entityType}:${unit.entityId}`)
10639
+ }).filter(card => matchesScope(card, options))
10640
+ const scopedKeys = new Set(cards.map(cardKey))
10641
+ const eligible = snapshot.notes.filter(unit => scopedKeys.has(`${unit.entityType}:${unit.entityId}`)
10466
10642
  && (includeProblems || currentByKey.get(`${unit.entityType}:${unit.entityId}`)?.status !== 'problem'))
10467
10643
  const eligibleIds = new Set(eligible.map(unit => unit.unitId))
10468
10644
  /**
@@ -10571,13 +10747,9 @@ export class KnowledgeRuntime {
10571
10747
  let rerankScored: Array<{ unitId: string; logit: number; rerankScore: number; kept: boolean }> = []
10572
10748
  if (options.rerank === false || this.config.rerankProvider === 'none') {
10573
10749
  rerank = { status: 'skipped', provider: this.config.rerankProvider }
10574
- hits = hits.slice(0, recallTopK)
10575
10750
  } else {
10576
10751
  const rerankQuery = [
10577
10752
  query,
10578
- ...(queryAnalysis.intent.id !== 'unknown' && queryAnalysis.intent.confidence >= 0.7
10579
- ? [`意图:${queryAnalysis.intent.id}`]
10580
- : []),
10581
10753
  ...(['object', 'action', 'scene'] as const).flatMap(kind => {
10582
10754
  const values = queryAnalysis.axes[kind].map(item => item.name).slice(0, 3)
10583
10755
  return values.length > 0 ? [`${kind}:${values.join('、')}`] : []
@@ -10594,7 +10766,15 @@ export class KnowledgeRuntime {
10594
10766
  }
10595
10767
  // 重排换的是相关性顺序,评分加权仍然要压在最上面 —— 否则「review 过的排前面一点」
10596
10768
  // 只在关掉重排时成立,而重排默认是开着的。
10597
- hits = [...hits].sort((left, right) => rankKey(right) - rankKey(left) || left.unitId.localeCompare(right.unitId)).slice(0, recallTopK)
10769
+ hits = [...hits].sort((left, right) => rankKey(right) - rankKey(left) || left.unitId.localeCompare(right.unitId))
10770
+ const rankedSections = hits
10771
+ // Select complete Wiki identities before applying top_k. Each Wiki contributes its best section.
10772
+ const selectedWikiKeys = new Set<string>()
10773
+ hits = rankedSections.filter(hit => {
10774
+ const key = `${hit.entityType}:${hit.entityId}`
10775
+ if (selectedWikiKeys.has(key) || selectedWikiKeys.size >= recallTopK) return false
10776
+ selectedWikiKeys.add(key); return true
10777
+ })
10598
10778
 
10599
10779
  // --- R4 · 证据片段 -----------------------------------------------------
10600
10780
  const truth = await this.storeHandle()
@@ -10664,7 +10844,7 @@ export class KnowledgeRuntime {
10664
10844
  * 不再各自占一个上下文名额。
10665
10845
  */
10666
10846
  const notesByWiki = new Map<string, RecallHit[]>()
10667
- for (const hit of hits) {
10847
+ for (const hit of rankedSections.filter(hit => selectedWikiKeys.has(wikiKey(hit)) && finalKeys.has(wikiKey(hit)))) {
10668
10848
  const key = wikiKey(hit)
10669
10849
  const bucket = notesByWiki.get(key)
10670
10850
  if (bucket) bucket.push(hit)
@@ -10720,8 +10900,7 @@ export class KnowledgeRuntime {
10720
10900
  /**
10721
10901
  * 上下文预算(0050):装箱 + 溢出。
10722
10902
  *
10723
- * 让位顺序写死在 `packContext` 里:先保证命中 note 的正文完整(它是证据),
10724
- * 再给扩展卡片的标题 + 摘要 + 关系,最后才给扩展卡片的正文。
10903
+ * 让位顺序写死在 `packContext` 里:优先交付命中的完整 Wiki,再交付扩展 Wiki。
10725
10904
  */
10726
10905
  /**
10727
10906
  * 每次调用可以覆盖预算(0050):指路行里就写着「读不到就调大预算重新召回」,
@@ -10729,6 +10908,14 @@ export class KnowledgeRuntime {
10729
10908
  */
10730
10909
  const maxChars = options.contextMaxChars ?? this.config.contextMaxChars
10731
10910
  const packed = await this.packContext({ items, maxChars, recallId })
10911
+ const deliveredKeys = new Set(packed.items.flatMap(item => item.wiki ? [cardKey(item.wiki)] : []))
10912
+ // Diagnostic HTTP requests may inspect unbudgeted candidate text. Normal delivery cannot leak it.
10913
+ const deliveredHits = options.detail ? hits : hits.map(hit => {
10914
+ if (deliveredKeys.has(wikiKey(hit))) return hit
10915
+ const { evidence: _evidence, knowledge: _knowledge, ...reference } = hit
10916
+ return { ...reference, content: '' }
10917
+ })
10918
+ const deliveredWiki = options.detail ? wiki : wiki.filter(card => deliveredKeys.has(cardKey(card)))
10732
10919
  /** 哪些扩展卡片真的进了上下文(被预算挡住的也如实报出来)。 */
10733
10920
  expansion.included = packed.items
10734
10921
  .filter(item => item.reason === 'expansion' && item.wiki !== null)
@@ -10789,9 +10976,11 @@ export class KnowledgeRuntime {
10789
10976
  }
10790
10977
 
10791
10978
  const data: RecallResult = {
10792
- hits, context: packed.items, wiki, graph, backend, routes: ranRoutes, recallMode, rerank, recallTopK, candidatePool, fusedCount, contextTopN,
10979
+ hits: deliveredHits, context: packed.items, wiki: deliveredWiki, graph, backend, routes: ranRoutes, recallMode, rerank, recallTopK, candidatePool, fusedCount, contextTopN,
10793
10980
  queryAnalysis,
10794
- insufficientEvidence, diagnostics, recallId, timings,
10981
+ insufficientEvidence: insufficientEvidence || !packed.items.some(item => item.reason === 'hit' && item.wiki),
10982
+ foundCandidates: hits.length > 0, contentDelivered: packed.items.some(item => item.reason === 'hit' && item.wiki),
10983
+ diagnostics, recallId, timings,
10795
10984
  expansion, budget: packed.report,
10796
10985
  ...(ledger === undefined ? {} : { ledger }),
10797
10986
  ...(indexVersion === undefined ? {} : { indexVersion }),
@@ -10936,6 +11125,20 @@ export class KnowledgeRuntime {
10936
11125
  * 路径是知识库里的真实文件,读它与读这里的内容是同一份数据,而且没有配额。这样
10937
11126
  * 「一次读不完」就不再等于「读不到」,也不会出现「以为拿到全文、其实只有前 4 万字符」。
10938
11127
  */
11128
+ async readSource(input: SourceReadInput, lineage?: LineageContext): Promise<Envelope<Record<string, unknown>>> {
11129
+ if (!this.config.allowSourceTruth) return failure({}, 'SOURCE_TRUTH_DISABLED', '当前配置禁止访问 Source Raw。', false)
11130
+ const db = await this.storeHandle()
11131
+ if (!db) return failure({}, 'STORE_UNAVAILABLE', '来源存储不可用。', true)
11132
+ try {
11133
+ const data = readSourceExact(db, input)
11134
+ await this.audit('source_truth.read_exact', { sourceId: input.sourceId, version: data.version, start: data.start, end: data.end, lineage })
11135
+ return success({ ...data, sourceIdentity: db.sourceIdentities(input.sourceId), readRef: { ...(data.readRef as object), kb: this.baseId || 'default' }, nextRead: data.nextRead ? { ...(data.nextRead as Record<string, unknown>), args: { ...((data.nextRead as any).args), kb: this.baseId || 'default' } } : undefined })
11136
+ } catch (error) {
11137
+ if (error instanceof SourceReadError) return failure({}, error.code, error.message, false)
11138
+ throw error
11139
+ }
11140
+ }
11141
+
10939
11142
  async sourceTruth(query: string, limit = 20, maxChars = 40000, lineage?: LineageContext, offset = 0, sourceOffset = 0): Promise<Envelope<{ sources: SourceRawRead[]; sourceReadId: string; maxChars: number; offset: number; returnedChars: number; truncated: boolean; hasMoreInCurrentPage: boolean; totalMatchedSources: number; sourceOffset: number; hasMoreSources: boolean; nextSourceOffset?: number; lineage?: LineageContext }>> {
10940
11143
  const sourceReadId = generatedId('source-read')
10941
11144
  const empty = { sources: [] as SourceRawRead[], sourceReadId, maxChars, offset, returnedChars: 0, truncated: false, hasMoreInCurrentPage: false, totalMatchedSources: 0, sourceOffset, hasMoreSources: false }
@@ -10944,6 +11147,7 @@ export class KnowledgeRuntime {
10944
11147
  if (!Number.isInteger(offset) || offset < 0) return failure(empty, 'SOURCE_OFFSET_INVALID', 'offset 必须是不小于 0 的整数;它接着上一页的 returnedChars 往下读', false)
10945
11148
  if (!Number.isInteger(sourceOffset) || sourceOffset < 0) return failure(empty, 'SOURCE_OFFSET_INVALID', 'source_offset 必须是不小于 0 的整数;需要读更多来源时使用返回的 nextSourceOffset', false)
10946
11149
  const queryTokens = tokens(query)
11150
+ const sourceDb = await this.storeHandle()
10947
11151
  const matchedSources = (await this.loadSources()).filter(item => !queryTokens.length || queryTokens.some(token => `${item.title}\n${item.content}`.toLocaleLowerCase().includes(token)))
10948
11152
  const sources = matchedSources.slice(sourceOffset, sourceOffset + limit)
10949
11153
  const hasMoreSources = sourceOffset + sources.length < matchedSources.length
@@ -10981,6 +11185,8 @@ export class KnowledgeRuntime {
10981
11185
  if (content.length === 0 && source.content.length > 0) continue
10982
11186
  bounded.push({
10983
11187
  ...source,
11188
+ version: sourceDb?.source(source.sourceId)?.version,
11189
+ readRef: { kb: this.baseId || 'default', source_id: source.sourceId, version: sourceDb?.source(source.sourceId)?.version, content_hash: createHash('sha256').update(source.content).digest('hex') },
10984
11190
  content,
10985
11191
  /*
10986
11192
  * 这里原先回一个**磁盘绝对路径**,工具描述让 Agent「截断了就去读那个文件」。