dsh-data-domain-knowledge 0.1.0-alpha.11 → 0.1.0-alpha.13

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 (147) hide show
  1. package/README.md +64 -13
  2. package/eval/README.md +19 -8
  3. package/eval/evaluate-retrieval.mjs +17 -8
  4. package/eval/queries.example.jsonl +1 -0
  5. package/lib/client.js +2294 -2096
  6. package/lib/doc-page.js +1 -1
  7. package/lib/pages.js +2 -2
  8. package/lib/types/axis-registry.d.ts +9 -4
  9. package/lib/types/axis-registry.d.ts.map +1 -1
  10. package/lib/types/axis-registry.js +62 -23
  11. package/lib/types/axis-registry.js.map +1 -1
  12. package/lib/types/card-organize.d.ts +5 -0
  13. package/lib/types/card-organize.d.ts.map +1 -1
  14. package/lib/types/card-organize.js +15 -0
  15. package/lib/types/card-organize.js.map +1 -1
  16. package/lib/types/compiler/embedding-identity.d.ts +3 -0
  17. package/lib/types/compiler/embedding-identity.d.ts.map +1 -0
  18. package/lib/types/compiler/embedding-identity.js +24 -0
  19. package/lib/types/compiler/embedding-identity.js.map +1 -0
  20. package/lib/types/compiler/snapshot.d.ts +16 -0
  21. package/lib/types/compiler/snapshot.d.ts.map +1 -0
  22. package/lib/types/compiler/snapshot.js +2 -0
  23. package/lib/types/compiler/snapshot.js.map +1 -0
  24. package/lib/types/diagnosis/store.d.ts.map +1 -1
  25. package/lib/types/diagnosis/store.js +2 -0
  26. package/lib/types/diagnosis/store.js.map +1 -1
  27. package/lib/types/environment/install.d.ts +7 -0
  28. package/lib/types/environment/install.d.ts.map +1 -0
  29. package/lib/types/environment/install.js +172 -0
  30. package/lib/types/environment/install.js.map +1 -0
  31. package/lib/types/environment/manifest.d.ts +208 -0
  32. package/lib/types/environment/manifest.d.ts.map +1 -0
  33. package/lib/types/environment/manifest.js +26 -0
  34. package/lib/types/environment/manifest.js.map +1 -0
  35. package/lib/types/environment/model-catalog.d.ts +191 -0
  36. package/lib/types/environment/model-catalog.d.ts.map +1 -0
  37. package/lib/types/environment/model-catalog.js +91 -0
  38. package/lib/types/environment/model-catalog.js.map +1 -0
  39. package/lib/types/environment/process.d.ts +10 -0
  40. package/lib/types/environment/process.d.ts.map +1 -0
  41. package/lib/types/environment/process.js +54 -0
  42. package/lib/types/environment/process.js.map +1 -0
  43. package/lib/types/environment/selection.d.ts +9 -0
  44. package/lib/types/environment/selection.d.ts.map +1 -0
  45. package/lib/types/environment/selection.js +29 -0
  46. package/lib/types/environment/selection.js.map +1 -0
  47. package/lib/types/environment/service.d.ts +47 -0
  48. package/lib/types/environment/service.d.ts.map +1 -0
  49. package/lib/types/environment/service.js +253 -0
  50. package/lib/types/environment/service.js.map +1 -0
  51. package/lib/types/environment/types.d.ts +37 -0
  52. package/lib/types/environment/types.d.ts.map +1 -0
  53. package/lib/types/environment/types.js +2 -0
  54. package/lib/types/environment/types.js.map +1 -0
  55. package/lib/types/events.d.ts +10 -0
  56. package/lib/types/events.d.ts.map +1 -1
  57. package/lib/types/events.js +38 -1
  58. package/lib/types/events.js.map +1 -1
  59. package/lib/types/images.d.ts +26 -0
  60. package/lib/types/images.d.ts.map +1 -0
  61. package/lib/types/images.js +196 -0
  62. package/lib/types/images.js.map +1 -0
  63. package/lib/types/index.d.ts.map +1 -1
  64. package/lib/types/index.js +59 -3
  65. package/lib/types/index.js.map +1 -1
  66. package/lib/types/knowledge-bases.d.ts +3 -0
  67. package/lib/types/knowledge-bases.d.ts.map +1 -1
  68. package/lib/types/knowledge-bases.js +9 -0
  69. package/lib/types/knowledge-bases.js.map +1 -1
  70. package/lib/types/knowledge.d.ts +14 -6
  71. package/lib/types/knowledge.d.ts.map +1 -1
  72. package/lib/types/knowledge.js +56 -39
  73. package/lib/types/knowledge.js.map +1 -1
  74. package/lib/types/layout.d.ts +2 -0
  75. package/lib/types/layout.d.ts.map +1 -1
  76. package/lib/types/layout.js +2 -0
  77. package/lib/types/layout.js.map +1 -1
  78. package/lib/types/retrieval/vector-index.d.ts +31 -0
  79. package/lib/types/retrieval/vector-index.d.ts.map +1 -0
  80. package/lib/types/retrieval/vector-index.js +15 -0
  81. package/lib/types/retrieval/vector-index.js.map +1 -0
  82. package/lib/types/retrieval/visibility.d.ts +6 -0
  83. package/lib/types/retrieval/visibility.d.ts.map +1 -0
  84. package/lib/types/retrieval/visibility.js +11 -0
  85. package/lib/types/retrieval/visibility.js.map +1 -0
  86. package/lib/types/runtime.d.ts +36 -6
  87. package/lib/types/runtime.d.ts.map +1 -1
  88. package/lib/types/runtime.js +520 -190
  89. package/lib/types/runtime.js.map +1 -1
  90. package/lib/types/source-evidence.d.ts +6 -0
  91. package/lib/types/source-evidence.d.ts.map +1 -0
  92. package/lib/types/source-evidence.js +28 -0
  93. package/lib/types/source-evidence.js.map +1 -0
  94. package/lib/types/store/axis-references.d.ts +5 -0
  95. package/lib/types/store/axis-references.d.ts.map +1 -0
  96. package/lib/types/store/axis-references.js +106 -0
  97. package/lib/types/store/axis-references.js.map +1 -0
  98. package/lib/types/store/derived.d.ts +11 -0
  99. package/lib/types/store/derived.d.ts.map +1 -1
  100. package/lib/types/store/derived.js +31 -6
  101. package/lib/types/store/derived.js.map +1 -1
  102. package/lib/types/store/knowledge-db.d.ts +4 -1
  103. package/lib/types/store/knowledge-db.d.ts.map +1 -1
  104. package/lib/types/store/knowledge-db.js +15 -9
  105. package/lib/types/store/knowledge-db.js.map +1 -1
  106. package/lib/types/store/migrate.d.ts.map +1 -1
  107. package/lib/types/store/migrate.js +16 -5
  108. package/lib/types/store/migrate.js.map +1 -1
  109. package/lib/types/types.d.ts +7 -1
  110. package/lib/types/types.d.ts.map +1 -1
  111. package/lib/types/types.js +3 -4
  112. package/lib/types/types.js.map +1 -1
  113. package/package.json +3 -1
  114. package/python/prepare-models.py +19 -0
  115. package/python/requirements.txt +8 -6
  116. package/scripts/doctor.mjs +14 -2
  117. package/scripts/environment.mjs +29 -0
  118. package/scripts/setup-embedding.sh +11 -2
  119. package/src/axis-registry.ts +59 -23
  120. package/src/card-organize.ts +15 -0
  121. package/src/client/EnvironmentSetup.tsx +145 -0
  122. package/src/client/KnowledgePanel.tsx +9 -4
  123. package/src/compiler/embedding-identity.ts +21 -0
  124. package/src/compiler/snapshot.ts +17 -0
  125. package/src/diagnosis/store.ts +1 -0
  126. package/src/environment/install.ts +129 -0
  127. package/src/environment/manifest.ts +28 -0
  128. package/src/environment/model-catalog.ts +105 -0
  129. package/src/environment/process.ts +54 -0
  130. package/src/environment/selection.ts +29 -0
  131. package/src/environment/service.ts +211 -0
  132. package/src/environment/types.ts +34 -0
  133. package/src/events.ts +46 -0
  134. package/src/images.ts +203 -0
  135. package/src/index.ts +54 -3
  136. package/src/knowledge-bases.ts +9 -0
  137. package/src/knowledge.ts +56 -40
  138. package/src/layout.ts +2 -0
  139. package/src/retrieval/vector-index.ts +44 -0
  140. package/src/retrieval/visibility.ts +14 -0
  141. package/src/runtime.ts +473 -233
  142. package/src/source-evidence.ts +30 -0
  143. package/src/store/axis-references.ts +96 -0
  144. package/src/store/derived.ts +31 -6
  145. package/src/store/knowledge-db.ts +16 -9
  146. package/src/store/migrate.ts +15 -5
  147. package/src/types.ts +10 -5
package/src/images.ts ADDED
@@ -0,0 +1,203 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { readFile, stat } from 'node:fs/promises'
3
+ import { basename, extname, join } from 'node:path'
4
+ import { ASSETS_DIR, PUBLISHED_IMAGE_MARKER } from './layout.js'
5
+ import { writeBufferAtomic, writeTextAtomic } from './fs-atomic.js'
6
+
7
+ /** Maximum decoded image size accepted by `images.publish`. */
8
+ export const MAX_PUBLISHED_IMAGE_BYTES = 20 * 1024 * 1024
9
+
10
+ const IMAGE_FORMATS = [
11
+ { mimeType: 'image/png', extension: '.png' },
12
+ { mimeType: 'image/jpeg', extension: '.jpg' },
13
+ { mimeType: 'image/gif', extension: '.gif' },
14
+ { mimeType: 'image/webp', extension: '.webp' },
15
+ { mimeType: 'image/avif', extension: '.avif' },
16
+ { mimeType: 'image/bmp', extension: '.bmp' },
17
+ { mimeType: 'image/svg+xml', extension: '.svg' },
18
+ ] as const
19
+
20
+ export class ImagePublishError extends Error {
21
+ constructor(readonly code: string, message: string, readonly suggestion?: string) {
22
+ super(message)
23
+ this.name = 'ImagePublishError'
24
+ }
25
+ }
26
+
27
+ export interface PublishKnowledgeImageInput {
28
+ filePath?: unknown
29
+ contentBase64?: unknown
30
+ filename?: unknown
31
+ mimeType?: unknown
32
+ }
33
+
34
+ export interface PublishedKnowledgeImage {
35
+ kb: string
36
+ filename: string
37
+ path: string
38
+ url: string
39
+ markdown: string
40
+ mimeType: string
41
+ bytes: number
42
+ sha256: string
43
+ }
44
+
45
+ function invalid(message: string, suggestion?: string): never {
46
+ throw new ImagePublishError('IMAGE_INVALID', message, suggestion)
47
+ }
48
+
49
+ function normalizeMimeType(value: string): string {
50
+ const mimeType = value.split(';', 1)[0]!.trim().toLowerCase()
51
+ return mimeType === 'image/jpg' ? 'image/jpeg' : mimeType
52
+ }
53
+
54
+ function detectImageMimeType(bytes: Buffer): string | undefined {
55
+ if (bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return 'image/png'
56
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg'
57
+ if (bytes.length >= 6 && (bytes.toString('ascii', 0, 6) === 'GIF87a' || bytes.toString('ascii', 0, 6) === 'GIF89a')) return 'image/gif'
58
+ if (bytes.length >= 12 && bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP') return 'image/webp'
59
+ if (bytes.length >= 12 && bytes.toString('ascii', 0, 2) === 'BM') return 'image/bmp'
60
+ if (bytes.length >= 12 && bytes.toString('ascii', 4, 8) === 'ftyp') {
61
+ const brands = bytes.toString('ascii', 8, Math.min(bytes.length, 32))
62
+ if (/(?:avif|avis)/.test(brands)) return 'image/avif'
63
+ }
64
+ // SVG is text, so recognize its root element after an optional XML declaration.
65
+ const text = bytes.toString('utf8', 0, Math.min(bytes.length, 4096)).replace(/^\uFEFF/, '').trimStart()
66
+ if (/^(?:<\?xml\b[^>]*>\s*)?<svg(?:\s|>)/i.test(text)) return 'image/svg+xml'
67
+ return undefined
68
+ }
69
+
70
+ function decodeBase64(value: string): { bytes: Buffer; declaredMimeType?: string } {
71
+ let encoded = value.trim()
72
+ let declaredMimeType: string | undefined
73
+ if (encoded.startsWith('data:')) {
74
+ const match = /^data:([^;,]+);base64,([\s\S]*)$/i.exec(encoded)
75
+ if (!match) invalid('content_base64 的 Data URI 格式无效。', '传标准 Base64,或传 data:image/png;base64,... 形式。')
76
+ declaredMimeType = normalizeMimeType(match[1]!)
77
+ encoded = match[2]!
78
+ }
79
+ encoded = encoded.replace(/\s+/g, '')
80
+ if (encoded.length > Math.ceil(MAX_PUBLISHED_IMAGE_BYTES * 4 / 3) + 8) {
81
+ throw new ImagePublishError('IMAGE_TOO_LARGE', `图片超过 ${MAX_PUBLISHED_IMAGE_BYTES / 1024 / 1024} MiB 限制。`)
82
+ }
83
+ if (encoded.length % 4 === 1 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}={0,2}|[A-Za-z0-9+/]{3}=?)?$/.test(encoded)) {
84
+ invalid('content_base64 不是有效的 Base64。')
85
+ }
86
+ const bytes = Buffer.from(encoded, 'base64')
87
+ if (bytes.length === 0) invalid('图片内容不能为空。')
88
+ if (bytes.length > MAX_PUBLISHED_IMAGE_BYTES) {
89
+ throw new ImagePublishError('IMAGE_TOO_LARGE', `图片超过 ${MAX_PUBLISHED_IMAGE_BYTES / 1024 / 1024} MiB 限制。`)
90
+ }
91
+ if (bytes.toString('base64').replace(/=+$/, '') !== encoded.replace(/=+$/, '')) invalid('content_base64 不是有效的 Base64。')
92
+ return { bytes, ...(declaredMimeType ? { declaredMimeType } : {}) }
93
+ }
94
+
95
+ function safeFilename(value: unknown, fallback: string, extension: string): string {
96
+ const raw = typeof value === 'string' ? value.trim() : ''
97
+ const leaf = (raw.replace(/\\/g, '/').split('/').pop() ?? '').replace(/[\u0000-\u001f\u007f]/g, '')
98
+ const suppliedExtension = extname(leaf)
99
+ const stem = (suppliedExtension ? leaf.slice(0, -suppliedExtension.length) : leaf)
100
+ .replace(/[^\p{L}\p{N}._ -]/gu, '_')
101
+ .replace(/\s+/g, '-')
102
+ .replace(/^\.+$/, '')
103
+ .slice(0, 120)
104
+ return `${stem || fallback}${extension}`
105
+ }
106
+
107
+ async function readExisting(path: string): Promise<Buffer | undefined> {
108
+ try { return await readFile(path) } catch (cause) {
109
+ if ((cause as NodeJS.ErrnoException)?.code === 'ENOENT') return undefined
110
+ throw cause
111
+ }
112
+ }
113
+
114
+ /** Store one validated image using the knowledge assets content-hash bucket layout. */
115
+ export async function publishKnowledgeImage(
116
+ rootDir: string,
117
+ kb: string,
118
+ input: PublishKnowledgeImageInput,
119
+ ): Promise<PublishedKnowledgeImage> {
120
+ const filePath = typeof input.filePath === 'string' ? input.filePath.trim() : ''
121
+ const base64 = typeof input.contentBase64 === 'string' ? input.contentBase64 : ''
122
+ if (Boolean(filePath) === Boolean(base64)) {
123
+ invalid('file_path 和 content_base64 必须且只能提供一个。')
124
+ }
125
+
126
+ let bytes: Buffer
127
+ let sourceName = ''
128
+ let dataUriMimeType: string | undefined
129
+ if (filePath) {
130
+ let details
131
+ try { details = await stat(filePath) } catch (cause) {
132
+ throw new ImagePublishError('IMAGE_SOURCE_NOT_FOUND', `图片文件无法读取:${cause instanceof Error ? cause.message : String(cause)}`)
133
+ }
134
+ if (!details.isFile()) throw new ImagePublishError('IMAGE_SOURCE_NOT_FILE', 'file_path 必须指向一个文件。')
135
+ if (details.size > MAX_PUBLISHED_IMAGE_BYTES) {
136
+ throw new ImagePublishError('IMAGE_TOO_LARGE', `图片超过 ${MAX_PUBLISHED_IMAGE_BYTES / 1024 / 1024} MiB 限制。`)
137
+ }
138
+ try { bytes = await readFile(filePath) } catch (cause) {
139
+ throw new ImagePublishError('IMAGE_SOURCE_NOT_FOUND', `图片文件无法读取:${cause instanceof Error ? cause.message : String(cause)}`)
140
+ }
141
+ sourceName = basename(filePath)
142
+ } else {
143
+ const decoded = decodeBase64(base64)
144
+ bytes = decoded.bytes
145
+ dataUriMimeType = decoded.declaredMimeType
146
+ }
147
+
148
+ if (bytes.length === 0) invalid('图片内容不能为空。')
149
+ if (bytes.length > MAX_PUBLISHED_IMAGE_BYTES) {
150
+ throw new ImagePublishError('IMAGE_TOO_LARGE', `图片超过 ${MAX_PUBLISHED_IMAGE_BYTES / 1024 / 1024} MiB 限制。`)
151
+ }
152
+ const mimeType = detectImageMimeType(bytes)
153
+ if (!mimeType) invalid('文件内容不是支持的图片格式(PNG、JPEG、GIF、WebP、AVIF、BMP、SVG)。')
154
+ const declaredMimeType = typeof input.mimeType === 'string' && input.mimeType.trim()
155
+ ? normalizeMimeType(input.mimeType)
156
+ : dataUriMimeType
157
+ if (declaredMimeType && declaredMimeType !== mimeType) {
158
+ invalid(`声明的 MIME 类型 ${declaredMimeType} 与图片内容 ${mimeType} 不一致。`)
159
+ }
160
+
161
+ const format = IMAGE_FORMATS.find(item => item.mimeType === mimeType)!
162
+ const sha256 = createHash('sha256').update(bytes).digest('hex')
163
+ const bucket = sha256.slice(0, 12)
164
+ const filename = safeFilename(input.filename ?? sourceName, `image-${bucket}`, format.extension)
165
+ const assetDir = join(rootDir, ASSETS_DIR, bucket)
166
+ const assetPath = join(assetDir, filename)
167
+ let existing = await readExisting(assetPath)
168
+ let storedFilename = filename
169
+
170
+ // The established bucket uses only the first 12 hash characters. If that prefix and the
171
+ // supplied filename both collide with different bytes, add more hash characters instead of
172
+ // replacing the existing asset.
173
+ if (existing && !existing.equals(bytes)) {
174
+ const extension = format.extension
175
+ const stem = filename.slice(0, -extension.length)
176
+ storedFilename = `${stem}-${sha256.slice(12, 20)}${extension}`
177
+ existing = await readExisting(join(assetDir, storedFilename))
178
+ if (existing && !existing.equals(bytes)) {
179
+ storedFilename = `image-${sha256}${extension}`
180
+ existing = await readExisting(join(assetDir, storedFilename))
181
+ }
182
+ }
183
+
184
+ if (existing && !existing.equals(bytes)) {
185
+ throw new ImagePublishError('ASSET_NAME_COLLISION', '目标文件名已被不同内容占用,未覆盖现有图片。')
186
+ }
187
+ if (!existing?.equals(bytes)) await writeBufferAtomic(join(assetDir, storedFilename), bytes)
188
+ // Standalone hosted images have no SourceRaw or card by design. Keep that intent beside the
189
+ // asset so the orphan sweep does not mistake it for a disposable extraction artifact.
190
+ await writeTextAtomic(join(assetDir, PUBLISHED_IMAGE_MARKER), 'images.publish\n')
191
+ const relativePath = `${ASSETS_DIR}/${bucket}/${storedFilename}`
192
+ const url = `/knowledge/assets/${encodeURIComponent(kb)}/${relativePath.split('/').map(encodeURIComponent).join('/')}`
193
+ return {
194
+ kb,
195
+ filename: storedFilename,
196
+ path: relativePath,
197
+ url,
198
+ markdown: `![${storedFilename}](${url})`,
199
+ mimeType,
200
+ bytes: bytes.length,
201
+ sha256,
202
+ }
203
+ }
package/src/index.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  type ModelSeam,
28
28
  } from './runtime.js'
29
29
  import { unwrapGroup } from './settings-volatile.js'
30
+ import { KnowledgeEnvironment } from './environment/service.js'
30
31
  import { DOC_PAGE_CHARS, DOC_PAGE_CHARS_MAX, documentContentPage } from './document-page.js'
31
32
  import type { ModelCall } from './extract-model.js'
32
33
  import { runWikiTrial, trialSummary } from './wiki-trial.js'
@@ -43,6 +44,7 @@ import type { DiagnosisRunRecord } from './diagnosis/types.js'
43
44
  import { KNOWLEDGE_SETTINGS_SCHEMA, SETTINGS_NAMESPACE } from './settings-schema.js'
44
45
  import { DEFAULT_KB_ID, KnowledgeBases } from './knowledge-bases.js'
45
46
  import type { KnowledgeQuery } from './knowledge.js'
47
+ import { ImagePublishError, publishKnowledgeImage } from './images.js'
46
48
  import {
47
49
  applyConvert, CONVERT_SETTINGS_NAMESPACE, CONVERT_SETTINGS_SCHEMA, MOUNT_PATH as CONVERT_MOUNT_PATH,
48
50
  type ConvertHost,
@@ -515,7 +517,9 @@ export function apply(ctx: Context, pluginConfig: KnowledgePluginConfig = {}): v
515
517
  },
516
518
  }
517
519
  }
520
+ let environment: KnowledgeEnvironment
518
521
  const runtimeOptions = (): KnowledgeRuntimeOptions => ({
522
+ environmentGuard: () => environment.requireReady(),
519
523
  // 0029:家当目录。配置里三个路径为空时,运行时从这里解析模型与解释器。
520
524
  resourceRoot: resourceRoot(config.agentRoot),
521
525
  // 0032:记忆段。默认库的记忆落在 `<agentRoot>/memory/default`,与知识树平级。
@@ -552,11 +556,19 @@ export function apply(ctx: Context, pluginConfig: KnowledgePluginConfig = {}): v
552
556
  // 0044:非默认库的 runtime 也要拿到模型接缝。只传 `config` 不够 —— 默认库走上面
553
557
  // 那个 `new KnowledgeRuntime(... runtimeOptions())`,别的库走注册表这条支路,
554
558
  // 两边必须都带上,否则「新建一个库 → 导入文档」静默变成规则抽取。
555
- runtimeOptions: () => ({ modelSeam: modelSeamFor }),
559
+ runtimeOptions: () => ({ resourceRoot: resourceRoot(config.agentRoot), modelSeam: modelSeamFor, environmentGuard: () => environment.requireReady() }),
556
560
  })
557
561
  compileModelForBase = id => bases.compileModelOf(id)
558
562
  runtime.baseId = bases.defaultId
559
563
  activeKnowledgeBases = bases
564
+ environment = new KnowledgeEnvironment({
565
+ resourceRoot: resourceRoot(config.agentRoot),
566
+ snapshot: () => ({ config: runtime.config, paths: runtime.resourcePaths() }),
567
+ probe: kind => runtime.environmentProbe(kind),
568
+ reload: () => { runtime.reloadResources(); bases.reloadResources() },
569
+ })
570
+ // Startup checks imports and resource files; model inference runs on demand.
571
+ void environment.check(false).catch(() => undefined)
560
572
 
561
573
  type CompileModelCatalogItem = { provider: string; id: string; name: string; description?: string; available: boolean; current?: boolean }
562
574
  const compileModelCatalog = async (kbId: string): Promise<{
@@ -652,6 +664,7 @@ export function apply(ctx: Context, pluginConfig: KnowledgePluginConfig = {}): v
652
664
  const previousAgentRoot = config.agentRoot
653
665
  config = normalizeKnowledge(pluginConfig.knowledge)
654
666
  runtime.updateConfig(config)
667
+ bases.updateRuntimeConfig(config)
655
668
  /**
656
669
  * 转换设置挂在同一个 entry 上,volatile 事件需要让转换宿主重新读取。
657
670
  */
@@ -764,7 +777,7 @@ export function apply(ctx: Context, pluginConfig: KnowledgePluginConfig = {}): v
764
777
  applyIntent(ctx, { intentRoot: () => intentRoot(config.agentRoot) })
765
778
 
766
779
  /**
767
- * 需要本轮用户审批的写工具。
780
+ * 需要本轮用户审批的知识写工具。`images.publish` 只写入本地素材目录,按用户要求免审批。
768
781
  *
769
782
  * 0044 换过一批名字:`write_knowledge_card`(只接受已确认提议)退役了,因为正文现在
770
783
  * 只读、卡片由编译直接写入;`list_card_proposals` 也退役了,因为待办队列不存在了。
@@ -811,6 +824,31 @@ export function apply(ctx: Context, pluginConfig: KnowledgePluginConfig = {}): v
811
824
  })),
812
825
  })))
813
826
 
827
+ ctx.tools.register(tool(
828
+ 'images.publish',
829
+ 'Publish one local image into a knowledge base assets bucket without user approval. Pass exactly one of `file_path` or `content_base64` (standard Base64 or a data URI); optional `filename` overrides the stored name, and optional `mime_type` must match the file bytes. Supports PNG, JPEG, GIF, WebP, AVIF, BMP and SVG up to 20 MiB. Returns the local asset URL and Markdown image reference.',
830
+ {
831
+ file_path: { type: 'string', description: '宿主可读取的本地图片文件路径;与 content_base64 二选一' },
832
+ content_base64: { type: 'string', description: '图片的标准 Base64,或 data:image/...;base64,...;与 file_path 二选一' },
833
+ filename: { type: 'string', description: '可选文件名;默认使用 file_path 的文件名,Base64 输入默认 image-<hash>.<ext>' },
834
+ mime_type: { type: 'string', description: '可选 MIME 类型;必须与图片二进制内容一致' },
835
+ kb: { type: 'string', description: '知识库 id;省略时写入默认库' },
836
+ },
837
+ async args => withRuntime(args.kb, async target => {
838
+ try {
839
+ return success(await publishKnowledgeImage(target.rootDir, target.baseId, {
840
+ filePath: args.file_path,
841
+ contentBase64: args.content_base64,
842
+ filename: args.filename,
843
+ mimeType: args.mime_type,
844
+ }))
845
+ } catch (cause) {
846
+ if (cause instanceof ImagePublishError) return failure({}, cause.code, cause.message, false, cause.suggestion)
847
+ return failure({}, 'IMAGE_PUBLISH_FAILED', `图片写入失败:${cause instanceof Error ? cause.message : String(cause)}`, true)
848
+ }
849
+ }),
850
+ ))
851
+
814
852
  ctx.tools.register(tool('add_source', 'Add or update a SourceRaw document (原始可追溯文档). DSH requests one user approval before execution.', { title: { type: 'string' }, content: { type: 'string', required: true }, external_id: { type: 'string' }, metadata: { type: 'json' }, source_id: { type: 'string' }, allow_duplicate_title: { type: 'boolean', description: '默认 false:标题与已有来源重复时拒收(SOURCE_TITLE_DUPLICATED),要求改标题。传 true 才允许并存。' }, kb: { type: 'string' }, session_key: { type: 'string' }, task_id: { type: 'string' }, subtask_id: { type: 'string' }, run_id: { type: 'string' }, turn_id: { type: 'string' }, trace_id: { type: 'string' }, domain: { type: 'string' } }, async (args, exec) => withRuntime(args.kb, target => target.addSource({ title: args.title, content: args.content, externalId: args.external_id, metadata: args.metadata, sourceId: args.source_id, ...(args.allow_duplicate_title === true ? { allowDuplicateTitle: true } : {}), lineage: lineageOf(args, exec) })), {
815
853
  call: addSourceCallView,
816
854
  meta: (args, value) => addSourceMetaOf(args, dataOf(value)),
@@ -1003,6 +1041,19 @@ export function apply(ctx: Context, pluginConfig: KnowledgePluginConfig = {}): v
1003
1041
  },
1004
1042
  ))
1005
1043
  } },
1044
+ { kind: 'exact' as const, path: '/knowledge/environment', handler: async (req: IncomingMessage, res: ServerResponse) => {
1045
+ if (req.method !== 'GET') return send(res, 405, envelopeError('METHOD_NOT_ALLOWED', '只支持 GET'))
1046
+ return send(res, 200, success(environment.view()))
1047
+ } },
1048
+ { kind: 'exact' as const, path: '/knowledge/environment/prepare', handler: async (req: IncomingMessage, res: ServerResponse) => {
1049
+ if (req.method !== 'POST') return send(res, 405, envelopeError('METHOD_NOT_ALLOWED', '只支持 POST'))
1050
+ try {
1051
+ const body = await readBody(req, 4000) as { action?: string; selection?: unknown } | null
1052
+ if (body?.action === 'cancel') { environment.cancel(); return send(res, 200, success(environment.view())) }
1053
+ if (body?.action !== 'check' && body?.action !== 'install') return send(res, 400, failure({}, 'ACTION_INVALID', 'action 必须是 check、install 或 cancel', false))
1054
+ return send(res, 200, success(environment.start(body.action, body.selection)))
1055
+ } catch (cause) { return send(res, 400, envelopeError('REQUEST_INVALID', cause instanceof Error ? cause.message : String(cause))) }
1056
+ } },
1006
1057
  { kind: 'exact' as const, path: '/knowledge/embedding/health', handler: async (req: IncomingMessage, res: ServerResponse) => {
1007
1058
  const resolved = resolveBase(query(req).searchParams.get('kb'))
1008
1059
  if ('failure' in resolved) return sendEnvelope(res, resolved.failure)
@@ -2160,6 +2211,6 @@ export function apply(ctx: Context, pluginConfig: KnowledgePluginConfig = {}): v
2160
2211
  } },
2161
2212
  ]
2162
2213
  const disposers = routes.map(register)
2163
- return () => { runtime.close(); for (const dispose of disposers) void dispose() }
2214
+ return () => { environment.close(); runtime.close(); bases.closeAll(); for (const dispose of disposers) void dispose() }
2164
2215
  })
2165
2216
  }
@@ -280,6 +280,15 @@ export class KnowledgeBases {
280
280
  this.runtimes.clear()
281
281
  }
282
282
 
283
+ /** Volatile settings apply to every cached base, including non-default bases. */
284
+ updateRuntimeConfig(config: Partial<import('./types.js').RuntimeKnowledgeConfig>): void {
285
+ for (const runtime of this.runtimes.values()) runtime.updateConfig(config)
286
+ }
287
+
288
+ reloadResources(): void {
289
+ for (const runtime of this.runtimes.values()) runtime.reloadResources()
290
+ }
291
+
283
292
  // ------------------------------------------------------------- 统计刷新
284
293
 
285
294
  /**
package/src/knowledge.ts CHANGED
@@ -21,22 +21,21 @@
21
21
  * 还把两个来源都抹了。**也不许删掉任何一条** —— 删了就没有「冲突」这件事了,
22
22
  * 而冲突本身是要给人看的信号。
23
23
  *
24
- * ## 身份就是判重键
24
+ * ## 稳定身份与判重键
25
25
  *
26
- * `id = k_<归一化文本的哈希>`。同一个事实无论来自哪份文档都得到同一个 id,于是
27
- * 「多来源支持」是**往同一行追加 sources**,而不是靠一条知识挂多个坐标。
28
- * 文本改了就是一个新 id —— 旧的那条**留着**(不许删),两条之间可以记一条 redirect。
26
+ * 新行使用 `k_<归一化文本的 SHA-256>`,存量 ID 保留。判重先比较归一化文本,
27
+ * 不把哈希相同当作事实相同。「多来源支持」往同一行追加版本证据。
28
+ * 人工编辑正文保留 ID 并增加内容版本;重新抽出不同文本则保留为另一条知识。
29
29
  *
30
30
  * ## 为什么存在记忆段
31
31
  *
32
32
  * Knowledge 不可重建:它是抽取产物 + 人的裁决。重跑一次抽取不会得到同样的结果,
33
- * 而人的裁决根本没有第二次。所以它和轴注册表同一个库(`<memoryDir>/knowledge.sqlite`)。
33
+ * 而人的裁决根本没有第二次。所以它与轴注册表共享经过属主、版本校验的 `knowledge.db`。
34
34
  */
35
35
 
36
- import { mkdir } from 'node:fs/promises'
37
- import { dirname } from 'node:path'
38
36
  import { createHash } from 'node:crypto'
39
37
  import { loadSqlite, type SqliteDatabase } from './fts.js'
38
+ import { KnowledgeDb } from './store/knowledge-db.js'
40
39
 
41
40
  /**
42
41
  * 一条 Knowledge 的**真值状态**。
@@ -93,6 +92,10 @@ export interface KnowledgeSource {
93
92
  * 「这条知识依据的是第 3 版,而那版已经被人改过了」。
94
93
  */
95
94
  toVersion?: number
95
+ /** Evidence changes do not change the truth state of the Knowledge. */
96
+ currentVersion?: number
97
+ evidenceStatus?: 'current' | 'historical' | 'unversioned' | 'missing' | 'human'
98
+ quoteInCurrentVersion?: boolean
96
99
  createdAt: string
97
100
  }
98
101
 
@@ -147,9 +150,12 @@ export interface KnowledgeCardRef {
147
150
  */
148
151
  export function normalizeKnowledge(text: string): string {
149
152
  return text
153
+ .normalize('NFKC')
150
154
  .toLowerCase()
151
155
  .replace(/\s+/g, '')
152
- .replace(/[,。、;:?!,.;:?!"'“”‘’()()【】\[\]《》<>—\-_/\\]/g, '')
156
+ // Operators, decimal points, signs and identifier separators carry meaning.
157
+ .replace(/[,。、;:?!,;:?!“”‘’]/g, '')
158
+ .replace(/\.$/g, '')
153
159
  }
154
160
 
155
161
  /** FNV-1a(32 位):只用来生成稳定短 id,不用于任何安全目的。 */
@@ -172,7 +178,7 @@ function fnv1a(value: string): number {
172
178
  * 如果未来一个库混合多个 Scope 或有效期,必须先把 Scope/有效性纳入判重策略,再改变 id。
173
179
  */
174
180
  export function knowledgeId(text: string): string {
175
- return `k_${fnv1a(normalizeKnowledge(text)).toString(36)}`
181
+ return `k_${createHash('sha256').update(normalizeKnowledge(text), 'utf8').digest('hex')}`
176
182
  }
177
183
 
178
184
  export interface KnowledgeIngest {
@@ -458,8 +464,9 @@ CREATE INDEX IF NOT EXISTS revision_blob_idx ON revision(blob_hash);
458
464
  */
459
465
  export class KnowledgeStore {
460
466
  private db: SqliteDatabase | null = null
467
+ private owner: KnowledgeDb | null = null
461
468
 
462
- constructor(private readonly file: string) {}
469
+ constructor(private readonly file: string, private readonly shared?: SqliteDatabase) {}
463
470
 
464
471
  /**
465
472
  * 这个文件里有没有某张表。
@@ -481,14 +488,23 @@ export class KnowledgeStore {
481
488
  const sqlite = await loadSqlite()
482
489
  if (sqlite === null) return false
483
490
  try {
484
- await mkdir(dirname(this.file), { recursive: true })
485
- this.db = new sqlite.DatabaseSync(this.file)
491
+ if (this.shared) this.db = this.shared
492
+ else {
493
+ const opened = await KnowledgeDb.open(this.file, sqlite.DatabaseSync)
494
+ if (!opened.ok) return false
495
+ this.owner = opened.db
496
+ this.db = opened.db.connection()
497
+ }
486
498
  this.db.exec(SCHEMA)
499
+ // The retired source_type table can predate revision-bound evidence too.
500
+ if (!hasColumn(this.db, 'knowledge_sources', 'to_version')) {
501
+ this.db.exec('ALTER TABLE knowledge_sources ADD COLUMN to_version INTEGER')
502
+ }
487
503
  /*
488
504
  * 旧库的 `knowledge_sources` 带有已经撤掉的 `source_type NOT NULL` 列。
489
505
  * `CREATE TABLE IF NOT EXISTS` 不会改变旧表;如果直接继续,新的出处写入会因
490
506
  * 没有给这个列赋值而全部失败,表现成「抽取报告有知识,但 Knowledge 为 0」。
491
- * 只重建这一张可派生关系表,保留正文与出处数据,并让迁移具备事务性。
507
+ * 只重建这一张出处表,保留原始证据数据,并让迁移具备事务性。
492
508
  */
493
509
  if (hasColumn(this.db, 'knowledge_sources', 'source_type')) {
494
510
  this.db.exec('BEGIN IMMEDIATE')
@@ -521,26 +537,26 @@ export class KnowledgeStore {
521
537
  throw cause
522
538
  }
523
539
  }
524
- /*
525
- * 0062 v2 补的列。老库(`CREATE TABLE IF NOT EXISTS` 对已存在的表是空操作)不会
526
- * 自己长出它来,所以要显式 ALTER —— 与知识库文件「版本不符走迁移、不重建」同一政策。
527
- */
528
- if (!hasColumn(this.db, 'knowledge_sources', 'to_version')) {
529
- this.db.exec('ALTER TABLE knowledge_sources ADD COLUMN to_version INTEGER')
530
- }
531
540
  if (!hasColumn(this.db, 'knowledge', 'head_version')) {
532
541
  this.db.exec("ALTER TABLE knowledge ADD COLUMN head_version INTEGER NOT NULL DEFAULT 0")
533
542
  }
543
+ // Keep legacy IDs and references; replace only the lossy comparison key.
544
+ const reindex = this.db.prepare('UPDATE knowledge SET normalized = ? WHERE id = ?')
545
+ for (const row of this.db.prepare('SELECT id, text, normalized FROM knowledge').all() as Array<{ id: string; text: string; normalized: string }>) {
546
+ const normalized = normalizeKnowledge(row.text)
547
+ if (normalized !== row.normalized) reindex.run(normalized, row.id)
548
+ }
534
549
  this.backfillContentVersions()
535
550
  return true
536
551
  } catch {
552
+ this.owner?.close()
553
+ this.owner = null
537
554
  this.db = null
538
555
  return false
539
556
  }
540
557
  }
541
558
 
542
- available(): boolean { return this.db !== null
543
- }
559
+ available(): boolean { return this.db !== null }
544
560
 
545
561
  /**
546
562
  * 为旧 Knowledge 行补首版。
@@ -613,9 +629,12 @@ export class KnowledgeStore {
613
629
  const text = input.text.trim()
614
630
  if (!text) throw new Error('KNOWLEDGE_TEXT_REQUIRED')
615
631
  const normalized = normalizeKnowledge(text)
616
- const id = knowledgeId(text)
632
+ // Edited and migrated facts keep their identity. A hash is not proof of equality.
633
+ const match = this.db.prepare("SELECT id FROM knowledge WHERE normalized = ? AND merged_into = '' ORDER BY created_at, id LIMIT 1").get(normalized) as { id: string } | undefined
634
+ const id = match?.id ?? knowledgeId(text)
617
635
  const now = new Date().toISOString()
618
- const existing = await this.get(id)
636
+ const existing = this.read(id)
637
+ if (existing !== null && normalizeKnowledge(existing.text) !== normalized) throw new Error('KNOWLEDGE_ID_COLLISION')
619
638
 
620
639
  if (existing === null) {
621
640
  this.db.exec('BEGIN')
@@ -627,7 +646,7 @@ export class KnowledgeStore {
627
646
  (input.objectId ?? '').trim(), (input.actionId ?? '').trim(), (input.sceneId ?? '').trim(),
628
647
  input.status ?? 'candidate', (input.runId ?? '').trim(), '', 0, now, now,
629
648
  )
630
- const added = await this.appendSources(id, input.sources ?? [])
649
+ const added = this.appendSources(id, input.sources ?? [])
631
650
  this.appendContentVersion(id, text, {
632
651
  actor: input.actor ?? 'compiler',
633
652
  kind: input.revisionKind ?? 'auto',
@@ -636,7 +655,7 @@ export class KnowledgeStore {
636
655
  })
637
656
  this.db.prepare('UPDATE knowledge SET head_version = 1 WHERE id = ?').run(id)
638
657
  this.db.exec('COMMIT')
639
- return { knowledge: (await this.get(id))!, merged: false, addedSources: added }
658
+ return { knowledge: this.read(id)!, merged: false, addedSources: added }
640
659
  } catch (cause) {
641
660
  this.db.exec('ROLLBACK')
642
661
  throw cause
@@ -664,7 +683,7 @@ export class KnowledgeStore {
664
683
  incomingCoordinate.objectId, incomingCoordinate.actionId, incomingCoordinate.sceneId, now, id,
665
684
  )
666
685
  }
667
- const added = await this.appendSources(id, input.sources ?? [])
686
+ const added = this.appendSources(id, input.sources ?? [])
668
687
  if (existingHasCoordinate || !incomingHasCoordinate) {
669
688
  this.db.prepare('UPDATE knowledge SET updated_at = ? WHERE id = ?').run(now, id)
670
689
  }
@@ -713,25 +732,18 @@ export class KnowledgeStore {
713
732
  }
714
733
 
715
734
  /** 追加来源。返回这次真正加进去几条(去重之后)。 */
716
- private async appendSources(id: string, sources: Array<Partial<KnowledgeSource>>): Promise<number> {
735
+ private appendSources(id: string, sources: Array<Partial<KnowledgeSource>>): number {
717
736
  if (sources.length === 0) return 0
718
- const current = await this.get(id)
737
+ const current = this.read(id)
719
738
  const existingByKey = new Map((current?.sources ?? []).map(source => [sourceKey(source), source]))
720
739
  const seen = new Set(existingByKey.keys())
721
- let ordinal = current?.sources.length ?? 0
740
+ let ordinal = Number((this.db!.prepare('SELECT COALESCE(MAX(ordinal), -1) + 1 AS n FROM knowledge_sources WHERE knowledge_id = ?').get(id) as { n: number }).n)
722
741
  let added = 0
723
742
  const now = new Date().toISOString()
724
743
  for (const source of sources) {
725
744
  const row = normalizeSource(source, now)
726
745
  const key = sourceKey(row)
727
746
  if (seen.has(key)) {
728
- // 同一出处的来源版本是血缘元数据,不是新的 Knowledge 内容版本。
729
- const previous = existingByKey.get(key)
730
- if (row.toVersion !== undefined && previous?.toVersion !== row.toVersion) {
731
- this.db!.prepare(
732
- 'UPDATE knowledge_sources SET to_version = ? WHERE knowledge_id = ? AND document_id = ? AND quote = ? AND author = ? AND statement = ?',
733
- ).run(row.toVersion, id, row.documentId, row.quote, row.author, row.statement)
734
- }
735
747
  continue
736
748
  }
737
749
  seen.add(key)
@@ -748,7 +760,10 @@ export class KnowledgeStore {
748
760
  return added
749
761
  }
750
762
 
751
- async get(id: string): Promise<Knowledge | null> { if (this.db === null) throw new Error('KNOWLEDGE_STORE_UNAVAILABLE')
763
+ async get(id: string): Promise<Knowledge | null> { return this.read(id) }
764
+
765
+ private read(id: string): Knowledge | null {
766
+ if (this.db === null) throw new Error('KNOWLEDGE_STORE_UNAVAILABLE')
752
767
  const row = this.db.prepare('SELECT * FROM knowledge WHERE id = ?').get(id) as Record<string, unknown> | undefined
753
768
  if (row === undefined) return null
754
769
  const sources = this.db.prepare(
@@ -1341,7 +1356,8 @@ export class KnowledgeStore {
1341
1356
  }
1342
1357
 
1343
1358
  close(): void {
1344
- this.db?.close()
1359
+ this.owner?.close()
1360
+ this.owner = null
1345
1361
  this.db = null
1346
1362
  }
1347
1363
  }
@@ -1447,7 +1463,7 @@ function hasColumn(db: { prepare: (sql: string) => { all: (...params: unknown[])
1447
1463
 
1448
1464
  function sourceKey(source: KnowledgeSource): string {
1449
1465
  return source.documentId
1450
- ? `d\u0001${source.documentId}\u0001${source.quote}`
1466
+ ? `d\u0001${source.documentId}\u0001${source.toVersion ?? ''}\u0001${source.quote}`
1451
1467
  : `h\u0001${source.author}\u0001${source.statement}`
1452
1468
  }
1453
1469
 
package/src/layout.ts CHANGED
@@ -381,6 +381,8 @@ export function intentRoot(agentRoot: string): string {
381
381
  export const KNOWLEDGE_DB_FILE = 'knowledge.db'
382
382
  export const DERIVED_DB_FILE = 'derived.db'
383
383
  export const ASSETS_DIR = 'assets'
384
+ /** Hidden marker: standalone published images must survive source/card orphan sweeps. */
385
+ export const PUBLISHED_IMAGE_MARKER = '.dsh-published-image'
384
386
  /**
385
387
  * 原始输入的落点(0067)。与 {@link ASSETS_DIR} **并列且同构**:一个装内容里提取出来的
386
388
  * 图,一个装用户送进来的原件 —— 两者都是「从这个知识库里能直接拿到手的东西」,
@@ -0,0 +1,44 @@
1
+ import type { KnowledgeConfig } from '../types.js'
2
+ import type { DerivedDb } from '../store/derived.js'
3
+
4
+ export interface VectorItem {
5
+ unitId: string
6
+ contentHash: string
7
+ }
8
+
9
+ export interface VectorManifest {
10
+ version: number
11
+ createdAt: string
12
+ provider: KnowledgeConfig['embeddingProvider']
13
+ model: string
14
+ modelFingerprint?: string
15
+ /**
16
+ * 编码实现的指纹(0031)。旧清单没有这个字段 → 视为不兼容,于是修复编码器之后
17
+ * 一定触发一次真正的重编码,而不是把旧向量按 contentHash 复用回来。
18
+ */
19
+ encoder?: string
20
+ device: string
21
+ dimension: number
22
+ quantization: 'binary+hamming+int8'
23
+ itemCount: number
24
+ items: VectorItem[]
25
+ }
26
+
27
+ export interface LoadedVectorIndex {
28
+ manifest: VectorManifest
29
+ binary: Buffer
30
+ int8: Buffer
31
+ }
32
+
33
+ /** Validate a generation's BLOBs before either reuse or query. */
34
+ export function decodeVectorIndex(loaded: ReturnType<DerivedDb['vectorIndex']>, encoder: string): LoadedVectorIndex | null {
35
+ if (!loaded) return null
36
+ const manifest = loaded.manifest as VectorManifest
37
+ if (!manifest || manifest.quantization !== 'binary+hamming+int8' || manifest.encoder !== encoder
38
+ || !Number.isInteger(manifest.dimension) || manifest.dimension < 8 || manifest.dimension % 8 !== 0
39
+ || !Number.isInteger(manifest.itemCount) || !Array.isArray(manifest.items)
40
+ || manifest.itemCount !== manifest.items.length
41
+ || loaded.binary.length !== manifest.itemCount * Math.ceil(manifest.dimension / 8)
42
+ || loaded.int8.length !== manifest.itemCount * manifest.dimension) return null
43
+ return { ...loaded, manifest }
44
+ }
@@ -0,0 +1,14 @@
1
+ import type { ContextGraph } from '../types.js'
2
+ import type { KnowledgeDb } from '../store/knowledge-db.js'
3
+
4
+ export function liveGraphKeys(db: KnowledgeDb): Set<string> {
5
+ const rows = db.raw("SELECT entity_type AS type, entity_id AS id FROM card UNION SELECT 'source' AS type, entity_id AS id FROM source").all() as Array<{ type: string; id: string }>
6
+ return new Set(rows.map(row => `${row.type}:${row.id}`))
7
+ }
8
+
9
+ /** A retained generation must not restore entities that the user deleted. */
10
+ export function visibleGraph(graph: ContextGraph, live: Set<string>): ContextGraph {
11
+ const nodes = graph.nodes.filter(node => live.has(`${node.type}:${node.id}`))
12
+ const keys = new Set(nodes.map(node => `${node.type}:${node.id}`))
13
+ return { nodes, edges: graph.edges.filter(edge => keys.has(String(edge.from)) && keys.has(String(edge.to))) }
14
+ }