dsh-hams 0.1.2 → 0.1.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.
- package/README.md +4 -0
- package/dist/index.js +11335 -0
- package/package.json +9 -5
- package/src/domain/check.ts +0 -193
- package/src/domain/errors.ts +0 -59
- package/src/domain/evidence.ts +0 -134
- package/src/domain/ids.ts +0 -31
- package/src/host/chat-runtime.ts +0 -489
- package/src/host/dsh-adapter.ts +0 -867
- package/src/host/session-adapter.ts +0 -276
- package/src/http/handler.ts +0 -1132
- package/src/index.js +0 -53
- package/src/service/audit.ts +0 -92
- package/src/service/chat.ts +0 -1100
- package/src/service/contracts.ts +0 -193
- package/src/service/defaults.ts +0 -182
- package/src/service/deliverables.ts +0 -240
- package/src/service/hams.ts +0 -3103
- package/src/service/knowledge.ts +0 -940
- package/src/service/member-config.ts +0 -39
- package/src/service/members.ts +0 -506
- package/src/service/memory.ts +0 -375
- package/src/service/permissions.ts +0 -118
- package/src/service/project-lifecycle.ts +0 -233
- package/src/service/projects.ts +0 -840
- package/src/service/receipts.ts +0 -63
- package/src/service/templates.ts +0 -140
- package/src/service/tick.ts +0 -751
- package/src/service/tools.ts +0 -711
- package/src/service/types.ts +0 -219
- package/src/service/work-item-create.ts +0 -159
- package/src/service/workspace-profile.ts +0 -443
- package/src/store/backup.ts +0 -239
- package/src/store/bootstrap.ts +0 -225
- package/src/store/db.ts +0 -179
- package/src/store/lock.ts +0 -194
- package/src/store/migrations.ts +0 -211
- package/src/store/schema/001_p1.sql +0 -846
- package/src/store/schema/001_p1.sql.ts +0 -865
- package/src/store/schema/002_knowledge.sql +0 -54
- package/src/store/schema/002_knowledge.sql.ts +0 -68
- package/src/store/schema/003_projects.sql.ts +0 -3
- package/src/store/schema/004_team.sql.ts +0 -50
- package/src/store/schema/005_target_check.sql.ts +0 -22
- package/src/store/schema/006_target_flow.sql.ts +0 -19
- package/src/store/schema/007_project_delete.sql.ts +0 -17
- package/src/store/schema/008_memory.sql.ts +0 -47
- package/src/store/schema/generate-projects.mjs +0 -16
- package/src/store/seed.ts +0 -114
package/src/service/knowledge.ts
DELETED
|
@@ -1,940 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 知识:读模型 + 真实数据接入(M6)。
|
|
3
|
-
*
|
|
4
|
-
* 三类资产对应三张表,来源都是**本机真实数据**,不是样例:
|
|
5
|
-
*
|
|
6
|
-
* | 类别 | 表 | 真实来源 |
|
|
7
|
-
* |---|---|---|
|
|
8
|
-
* | 文档 | `kb_doc` | 面板内撰写的 Markdown(`<dataDir>/knowledge/docs/*.md`)与显式导入的本地文件(不复制,存绝对路径) |
|
|
9
|
-
* | 技能 | `kb_skill` / `kb_skill_version` | 宿主已安装的技能目录(默认 `$DSH_HOME/skills/<name>/SKILL.md`),按内容哈希去重 |
|
|
10
|
-
* | 数据表 | `kb_table` | HAMS 自己的业务表:同步时登记列结构,详情页按需读实时行数 |
|
|
11
|
-
*
|
|
12
|
-
* 三条边界:
|
|
13
|
-
* 1. **技能同步是镜像,不是授权**:把宿主已装技能登记成「已发布版本」,`published_by`
|
|
14
|
-
* 记的是触发同步的人;真正改技能内容仍要回到宿主目录,HAMS 不在这里写宿主文件。
|
|
15
|
-
* 2. 导入的文档只读:`source_kind='file'` 时改内容要重新导入,避免面板写出与源文件不一致的副本。
|
|
16
|
-
* 3. 扫描有硬上限(目录深度、文件数、单文件字节数),并且跳过隐藏目录与依赖目录;
|
|
17
|
-
* 技能目录只读 `SKILL.md`,不递归读技能里的其他文件。
|
|
18
|
-
*/
|
|
19
|
-
import { createHash } from 'node:crypto'
|
|
20
|
-
import {
|
|
21
|
-
copyFileSync,
|
|
22
|
-
existsSync,
|
|
23
|
-
mkdirSync,
|
|
24
|
-
readFileSync,
|
|
25
|
-
readdirSync,
|
|
26
|
-
statSync,
|
|
27
|
-
writeFileSync,
|
|
28
|
-
} from 'node:fs'
|
|
29
|
-
import { homedir } from 'node:os'
|
|
30
|
-
import path from 'node:path'
|
|
31
|
-
import type { DatabaseSync } from 'node:sqlite'
|
|
32
|
-
import { stateInvalid } from '../domain/errors.ts'
|
|
33
|
-
import type { IdFactory } from '../domain/ids.ts'
|
|
34
|
-
|
|
35
|
-
const MAX_PREVIEW_CHARS = 16_000
|
|
36
|
-
const MAX_DOC_BYTES = 512 * 1024
|
|
37
|
-
const MAX_IMPORT_BYTES = 2 * 1024 * 1024
|
|
38
|
-
const MAX_IMPORT_FILES = 500
|
|
39
|
-
|
|
40
|
-
/** 同步进 kb_table 的业务表;白名单是为了不把 sqlite_* 与临时表登记成「知识」。 */
|
|
41
|
-
export const KNOWLEDGE_TABLE_SOURCES = [
|
|
42
|
-
'work_item',
|
|
43
|
-
'project',
|
|
44
|
-
'member',
|
|
45
|
-
'evidence',
|
|
46
|
-
'work_template',
|
|
47
|
-
'task_run',
|
|
48
|
-
'approval',
|
|
49
|
-
'proposal',
|
|
50
|
-
'work_item_check',
|
|
51
|
-
'metric_observation',
|
|
52
|
-
'asset',
|
|
53
|
-
'audit_log',
|
|
54
|
-
] as const
|
|
55
|
-
|
|
56
|
-
const SKIP_DIRS = new Set(['node_modules', '_build', '.git', 'dist', '.venv', 'venv', '__pycache__', '.cache'])
|
|
57
|
-
const DENY_PATH_FRAGMENTS = ['/.dsh/sessions', '/.ssh', '/.aws', '/.gnupg', '/Library/Keychains']
|
|
58
|
-
|
|
59
|
-
export interface KnowledgeOptions {
|
|
60
|
-
/** HAMS 数据目录(`$DSH_HOME/hams`);面板撰写的文档写在这里。 */
|
|
61
|
-
dataDir: string
|
|
62
|
-
/** 真实技能目录;默认空数组时同步会报「未配置技能源」,不会静默成功。 */
|
|
63
|
-
skillDirs?: string[]
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export interface KnowledgeCounts {
|
|
67
|
-
docs: number
|
|
68
|
-
skills: number
|
|
69
|
-
tables: number
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export interface KnowledgeDocItem {
|
|
73
|
-
kind: 'doc'
|
|
74
|
-
id: string
|
|
75
|
-
title: string
|
|
76
|
-
subtitle: string
|
|
77
|
-
status: string
|
|
78
|
-
source_kind: string
|
|
79
|
-
revision: number
|
|
80
|
-
bytes: number
|
|
81
|
-
updated_at: number
|
|
82
|
-
owner_id: string
|
|
83
|
-
owner_name: string | null
|
|
84
|
-
path: string
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
export interface KnowledgeSkillItem {
|
|
88
|
-
kind: 'skill'
|
|
89
|
-
id: string
|
|
90
|
-
name: string
|
|
91
|
-
subtitle: string
|
|
92
|
-
version: string | null
|
|
93
|
-
content_hash: string | null
|
|
94
|
-
published_at: number | null
|
|
95
|
-
usage_count: number
|
|
96
|
-
path: string | null
|
|
97
|
-
/** 本轮同步时原始文件是否还在(不在也能读历史版本,值只表示镜像源缺失)。 */
|
|
98
|
-
present: boolean
|
|
99
|
-
created_at: number
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
export interface KnowledgeTableItem {
|
|
103
|
-
kind: 'table'
|
|
104
|
-
id: string
|
|
105
|
-
name: string
|
|
106
|
-
subtitle: string
|
|
107
|
-
source_kind: string
|
|
108
|
-
source_ref: string | null
|
|
109
|
-
columns: number
|
|
110
|
-
row_count: number | null
|
|
111
|
-
updated_at: number
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
export type KnowledgeItem = KnowledgeDocItem | KnowledgeSkillItem | KnowledgeTableItem
|
|
115
|
-
|
|
116
|
-
export interface DocDetail extends KnowledgeDocItem {
|
|
117
|
-
present: boolean
|
|
118
|
-
changed: boolean
|
|
119
|
-
content: string
|
|
120
|
-
truncated: boolean
|
|
121
|
-
file_bytes: number | null
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
export interface SkillDetail extends KnowledgeSkillItem {
|
|
125
|
-
description: string | null
|
|
126
|
-
when_to_use: string | null
|
|
127
|
-
content: string
|
|
128
|
-
truncated: boolean
|
|
129
|
-
versions: {
|
|
130
|
-
id: string
|
|
131
|
-
version: string
|
|
132
|
-
status: string
|
|
133
|
-
content_hash: string
|
|
134
|
-
published_by: string | null
|
|
135
|
-
published_at: number | null
|
|
136
|
-
}[]
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
export interface TableDetail extends KnowledgeTableItem {
|
|
140
|
-
columns_detail: { name: string; type: string; notnull: number; pk: number }[]
|
|
141
|
-
live: boolean
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
export interface KnowledgeListResult {
|
|
145
|
-
items: KnowledgeItem[]
|
|
146
|
-
counts: KnowledgeCounts
|
|
147
|
-
sources: { skill_dirs: string[]; docs_dir: string; tables: number }
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
export interface SyncReport {
|
|
151
|
-
at: number
|
|
152
|
-
skills: { roots: number; scanned: number; created: number; updated: number; unchanged: number; missing: number; errors: string[] }
|
|
153
|
-
tables: { created: number; updated: number; unchanged: number; errors: string[] }
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
export interface ImportReport {
|
|
157
|
-
at: number
|
|
158
|
-
dir: string
|
|
159
|
-
scanned: number
|
|
160
|
-
imported: number
|
|
161
|
-
updated: number
|
|
162
|
-
skipped: number
|
|
163
|
-
errors: { path: string; message: string }[]
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
function sha256(text: string | Buffer): string {
|
|
167
|
-
return createHash('sha256').update(text).digest('hex')
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function clampPreview(text: string): { content: string; truncated: boolean } {
|
|
171
|
-
if (text.length <= MAX_PREVIEW_CHARS) return { content: text, truncated: false }
|
|
172
|
-
return { content: `${text.slice(0, MAX_PREVIEW_CHARS)}\n\n…(已截断,共 ${text.length} 字符)`, truncated: true }
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/** 只解析顶层 `key: value` 的 frontmatter;嵌套结构原样留在正文,不假装是 YAML 解析器。 */
|
|
176
|
-
export function parseFrontmatter(text: string): { meta: Record<string, string>; body: string } {
|
|
177
|
-
if (!text.startsWith('---')) return { meta: {}, body: text }
|
|
178
|
-
const end = text.indexOf('\n---', 3)
|
|
179
|
-
if (end === -1) return { meta: {}, body: text }
|
|
180
|
-
const block = text.slice(3, end)
|
|
181
|
-
const meta: Record<string, string> = {}
|
|
182
|
-
for (const line of block.split(/\r?\n/)) {
|
|
183
|
-
const match = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(line.trim())
|
|
184
|
-
if (!match) continue
|
|
185
|
-
let value = match[2].trim()
|
|
186
|
-
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
187
|
-
value = value.slice(1, -1)
|
|
188
|
-
}
|
|
189
|
-
meta[match[1]] = value
|
|
190
|
-
}
|
|
191
|
-
const after = text.slice(end + 4)
|
|
192
|
-
return { meta, body: after.replace(/^\r?\n/, '') }
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
function titleOfFile(text: string, fallback: string): string {
|
|
196
|
-
const { meta, body } = parseFrontmatter(text)
|
|
197
|
-
if (meta.title && meta.title.trim()) return meta.title.trim()
|
|
198
|
-
const heading = /^#\s+(.+)$/m.exec(body)
|
|
199
|
-
if (heading) return heading[1].trim()
|
|
200
|
-
return fallback.replace(/\.(md|markdown)$/i, '')
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
function isDeniedPath(target: string): boolean {
|
|
204
|
-
const normalized = target.replace(/\\/g, '/')
|
|
205
|
-
return DENY_PATH_FRAGMENTS.some((fragment) => normalized.includes(fragment))
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
function expandHome(input: string): string {
|
|
209
|
-
if (input === '~') return homedir()
|
|
210
|
-
if (input.startsWith('~/')) return path.join(homedir(), input.slice(2))
|
|
211
|
-
return input
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
function slug(name: string, fallbackSeed: string): string {
|
|
215
|
-
const ascii = name
|
|
216
|
-
.toLowerCase()
|
|
217
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
218
|
-
.replace(/^-+|-+$/g, '')
|
|
219
|
-
if (ascii) return ascii.slice(0, 40)
|
|
220
|
-
return `s-${sha256(fallbackSeed).slice(0, 8)}`
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
function columnList(db: DatabaseSync, table: string): { name: string; type: string; notnull: number; pk: number }[] {
|
|
224
|
-
return (db.prepare(`PRAGMA table_info("${table}")`).all() as {
|
|
225
|
-
name: string
|
|
226
|
-
type: string
|
|
227
|
-
notnull: number
|
|
228
|
-
pk: number
|
|
229
|
-
}[]).map((row) => ({ name: row.name, type: row.type, notnull: Number(row.notnull), pk: Number(row.pk) }))
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
function tableExists(db: DatabaseSync, table: string): boolean {
|
|
233
|
-
return Boolean(
|
|
234
|
-
db.prepare("SELECT 1 AS ok FROM sqlite_master WHERE type = 'table' AND name = ?").get(table) as
|
|
235
|
-
| { ok: number }
|
|
236
|
-
| undefined,
|
|
237
|
-
)
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
export class KnowledgeService {
|
|
241
|
-
private db: DatabaseSync
|
|
242
|
-
private now: () => number
|
|
243
|
-
private ids: IdFactory
|
|
244
|
-
private dataDir: string
|
|
245
|
-
private skillDirs: string[]
|
|
246
|
-
|
|
247
|
-
constructor(options: { db: DatabaseSync; now: () => number; idFactory: IdFactory } & KnowledgeOptions) {
|
|
248
|
-
this.db = options.db
|
|
249
|
-
this.now = options.now
|
|
250
|
-
this.ids = options.idFactory
|
|
251
|
-
this.dataDir = path.resolve(options.dataDir)
|
|
252
|
-
this.skillDirs = (options.skillDirs ?? []).map((dir) => path.resolve(expandHome(dir)))
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
get docsDir(): string {
|
|
256
|
-
return path.join(this.dataDir, 'knowledge', 'docs')
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
getSkillsDirs(): string[] {
|
|
260
|
-
return [...this.skillDirs]
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
// ───────────────────────── 读 ─────────────────────────
|
|
264
|
-
|
|
265
|
-
list(filter: { kind?: string; q?: string; limit?: number } = {}): KnowledgeListResult {
|
|
266
|
-
const limit = Math.min(Math.max(filter.limit ?? 200, 1), 500)
|
|
267
|
-
const docs = this.listDocs()
|
|
268
|
-
const skills = this.listSkills()
|
|
269
|
-
const tables = this.listTables()
|
|
270
|
-
const counts: KnowledgeCounts = { docs: docs.length, skills: skills.length, tables: tables.length }
|
|
271
|
-
let items: KnowledgeItem[] = []
|
|
272
|
-
if (!filter.kind || filter.kind === 'doc') items.push(...docs)
|
|
273
|
-
if (!filter.kind || filter.kind === 'skill') items.push(...skills)
|
|
274
|
-
if (!filter.kind || filter.kind === 'table') items.push(...tables)
|
|
275
|
-
const q = (filter.q ?? '').trim().toLowerCase()
|
|
276
|
-
if (q) {
|
|
277
|
-
items = items.filter((item) => {
|
|
278
|
-
const name = 'name' in item ? item.name : ''
|
|
279
|
-
const title = 'title' in item ? item.title : ''
|
|
280
|
-
return `${item.id} ${name} ${title} ${item.subtitle}`.toLowerCase().includes(q)
|
|
281
|
-
})
|
|
282
|
-
}
|
|
283
|
-
items.sort((a, b) => {
|
|
284
|
-
const ta = a.kind === 'doc' ? a.updated_at : a.kind === 'skill' ? (a.published_at ?? a.created_at) : a.updated_at
|
|
285
|
-
const tb = b.kind === 'doc' ? b.updated_at : b.kind === 'skill' ? (b.published_at ?? b.created_at) : b.updated_at
|
|
286
|
-
return tb - ta
|
|
287
|
-
})
|
|
288
|
-
return {
|
|
289
|
-
items: items.slice(0, limit),
|
|
290
|
-
counts,
|
|
291
|
-
sources: { skill_dirs: this.getSkillsDirs(), docs_dir: this.docsDir, tables: tables.length },
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
private listDocs(): KnowledgeDocItem[] {
|
|
296
|
-
const rows = this.db
|
|
297
|
-
.prepare(
|
|
298
|
-
`SELECT d.*, m.name AS owner_name FROM kb_doc d
|
|
299
|
-
LEFT JOIN member m ON m.id = d.owner_id
|
|
300
|
-
ORDER BY d.updated_at DESC`,
|
|
301
|
-
)
|
|
302
|
-
.all() as {
|
|
303
|
-
id: string
|
|
304
|
-
title: string
|
|
305
|
-
body_ref: string
|
|
306
|
-
revision: number
|
|
307
|
-
owner_id: string
|
|
308
|
-
owner_name: string | null
|
|
309
|
-
source_kind: string
|
|
310
|
-
bytes: number
|
|
311
|
-
status: string
|
|
312
|
-
updated_at: number
|
|
313
|
-
}[]
|
|
314
|
-
return rows.map((row) => ({
|
|
315
|
-
kind: 'doc' as const,
|
|
316
|
-
id: row.id,
|
|
317
|
-
title: row.title,
|
|
318
|
-
subtitle: row.source_kind === 'file' ? row.body_ref : 'HAMS 内撰写',
|
|
319
|
-
status: row.status,
|
|
320
|
-
source_kind: row.source_kind,
|
|
321
|
-
revision: Number(row.revision),
|
|
322
|
-
bytes: Number(row.bytes),
|
|
323
|
-
updated_at: Number(row.updated_at),
|
|
324
|
-
owner_id: row.owner_id,
|
|
325
|
-
owner_name: row.owner_name,
|
|
326
|
-
path: row.body_ref,
|
|
327
|
-
}))
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
private listSkills(): KnowledgeSkillItem[] {
|
|
331
|
-
const rows = this.db
|
|
332
|
-
.prepare(
|
|
333
|
-
`SELECT s.*, v.version AS version, v.content_hash AS content_hash, v.published_at AS published_at,
|
|
334
|
-
v.manifest_json AS manifest_json
|
|
335
|
-
FROM kb_skill s
|
|
336
|
-
LEFT JOIN kb_skill_version v ON v.id = s.published_version_id
|
|
337
|
-
ORDER BY COALESCE(v.published_at, s.created_at) DESC`,
|
|
338
|
-
)
|
|
339
|
-
.all() as {
|
|
340
|
-
id: string
|
|
341
|
-
name: string
|
|
342
|
-
domain: string | null
|
|
343
|
-
published_version_id: string | null
|
|
344
|
-
usage_count: number
|
|
345
|
-
created_at: number
|
|
346
|
-
version: string | null
|
|
347
|
-
content_hash: string | null
|
|
348
|
-
published_at: number | null
|
|
349
|
-
manifest_json: string | null
|
|
350
|
-
}[]
|
|
351
|
-
return rows.map((row) => {
|
|
352
|
-
const manifest = this.parseManifest(row.manifest_json)
|
|
353
|
-
const filePath = typeof manifest.path === 'string' ? manifest.path : null
|
|
354
|
-
return {
|
|
355
|
-
kind: 'skill' as const,
|
|
356
|
-
id: row.id,
|
|
357
|
-
name: row.name,
|
|
358
|
-
subtitle: typeof manifest.description === 'string' ? manifest.description : (row.domain ?? ''),
|
|
359
|
-
version: row.version,
|
|
360
|
-
content_hash: row.content_hash,
|
|
361
|
-
published_at: row.published_at === null ? null : Number(row.published_at),
|
|
362
|
-
usage_count: Number(row.usage_count),
|
|
363
|
-
path: filePath,
|
|
364
|
-
present: Boolean(filePath && existsSync(filePath)),
|
|
365
|
-
created_at: Number(row.created_at),
|
|
366
|
-
}
|
|
367
|
-
})
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
private listTables(): KnowledgeTableItem[] {
|
|
371
|
-
const rows = this.db
|
|
372
|
-
.prepare('SELECT * FROM kb_table ORDER BY name')
|
|
373
|
-
.all() as {
|
|
374
|
-
id: string
|
|
375
|
-
name: string
|
|
376
|
-
source_kind: string
|
|
377
|
-
source_ref: string | null
|
|
378
|
-
schema_json: string
|
|
379
|
-
field_map_status: string
|
|
380
|
-
updated_at: number
|
|
381
|
-
}[]
|
|
382
|
-
return rows.map((row) => {
|
|
383
|
-
const schema = this.parseSchema(row.schema_json)
|
|
384
|
-
return {
|
|
385
|
-
kind: 'table' as const,
|
|
386
|
-
id: row.id,
|
|
387
|
-
name: row.name,
|
|
388
|
-
subtitle: `${row.source_kind}${row.source_ref ? ` · ${row.source_ref}` : ''}`,
|
|
389
|
-
source_kind: row.source_kind,
|
|
390
|
-
source_ref: row.source_ref,
|
|
391
|
-
columns: schema.columns.length,
|
|
392
|
-
row_count: this.liveRowCount(row.source_kind, row.source_ref),
|
|
393
|
-
updated_at: Number(row.updated_at),
|
|
394
|
-
}
|
|
395
|
-
})
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
get(kind: string, id: string): KnowledgeItem | DocDetail | SkillDetail | TableDetail {
|
|
399
|
-
if (kind === 'doc') return this.getDoc(id)
|
|
400
|
-
if (kind === 'skill') return this.getSkill(id)
|
|
401
|
-
if (kind === 'table') return this.getTable(id)
|
|
402
|
-
throw stateInvalid('知识类型只能是 doc / skill / table')
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
getDoc(id: string): DocDetail {
|
|
406
|
-
const row = this.db.prepare('SELECT * FROM kb_doc WHERE id = ?').get(id) as
|
|
407
|
-
| {
|
|
408
|
-
id: string
|
|
409
|
-
title: string
|
|
410
|
-
body_ref: string
|
|
411
|
-
revision: number
|
|
412
|
-
owner_id: string
|
|
413
|
-
source_kind: string
|
|
414
|
-
content_hash: string
|
|
415
|
-
bytes: number
|
|
416
|
-
status: string
|
|
417
|
-
updated_at: number
|
|
418
|
-
}
|
|
419
|
-
| undefined
|
|
420
|
-
if (!row) throw stateInvalid(`文档不存在:${id}`)
|
|
421
|
-
const owner = this.db.prepare('SELECT name FROM member WHERE id = ?').get(row.owner_id) as
|
|
422
|
-
| { name: string }
|
|
423
|
-
| undefined
|
|
424
|
-
const file = this.readFileSafe(row.body_ref)
|
|
425
|
-
const currentHash = file.ok ? sha256(file.text) : ''
|
|
426
|
-
const changed = Boolean(file.ok && row.content_hash && currentHash !== row.content_hash)
|
|
427
|
-
// 预览只给正文:frontmatter 里的 title/revision 已经单独返回,重复显示会让人误以为要编辑元数据。
|
|
428
|
-
const bodyText = file.ok ? parseFrontmatter(file.text).body : ''
|
|
429
|
-
const preview = clampPreview(file.ok ? bodyText : '(源文件暂时读不到;先确认路径或重新导入)')
|
|
430
|
-
return {
|
|
431
|
-
kind: 'doc',
|
|
432
|
-
id: row.id,
|
|
433
|
-
title: row.title,
|
|
434
|
-
subtitle: row.source_kind === 'file' ? row.body_ref : 'HAMS 内撰写',
|
|
435
|
-
status: row.status,
|
|
436
|
-
source_kind: row.source_kind,
|
|
437
|
-
revision: Number(row.revision),
|
|
438
|
-
bytes: Number(row.bytes),
|
|
439
|
-
updated_at: Number(row.updated_at),
|
|
440
|
-
owner_id: row.owner_id,
|
|
441
|
-
owner_name: owner?.name ?? null,
|
|
442
|
-
path: row.body_ref,
|
|
443
|
-
present: file.ok,
|
|
444
|
-
changed,
|
|
445
|
-
content: preview.content,
|
|
446
|
-
truncated: preview.truncated,
|
|
447
|
-
file_bytes: file.ok ? Buffer.byteLength(file.text, 'utf8') : null,
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
getSkill(id: string): SkillDetail {
|
|
452
|
-
const skill = this.listSkills().find((item) => item.id === id)
|
|
453
|
-
if (!skill) throw stateInvalid(`技能不存在:${id}`)
|
|
454
|
-
const versionRow = this.db
|
|
455
|
-
.prepare('SELECT * FROM kb_skill_version WHERE id = (SELECT published_version_id FROM kb_skill WHERE id = ?)')
|
|
456
|
-
.get(id) as { manifest_json: string } | undefined
|
|
457
|
-
const manifest = this.parseManifest(versionRow?.manifest_json ?? null)
|
|
458
|
-
const preview = skill.path ? this.readFileSafe(skill.path) : { ok: false, text: '' }
|
|
459
|
-
const bodyText = preview.ok ? parseFrontmatter(preview.text).body : ''
|
|
460
|
-
const content = clampPreview(preview.ok ? bodyText : '(技能源文件不在磁盘上了;历史版本仍保留)')
|
|
461
|
-
const versions = this.db
|
|
462
|
-
.prepare(
|
|
463
|
-
`SELECT id, version, status, content_hash, published_by, published_at
|
|
464
|
-
FROM kb_skill_version WHERE skill_id = ? ORDER BY published_at DESC, rowid DESC LIMIT 10`,
|
|
465
|
-
)
|
|
466
|
-
.all(id) as {
|
|
467
|
-
id: string
|
|
468
|
-
version: string
|
|
469
|
-
status: string
|
|
470
|
-
content_hash: string
|
|
471
|
-
published_by: string | null
|
|
472
|
-
published_at: number | null
|
|
473
|
-
}[]
|
|
474
|
-
return {
|
|
475
|
-
...skill,
|
|
476
|
-
description: typeof manifest.description === 'string' ? manifest.description : null,
|
|
477
|
-
when_to_use: typeof manifest.whenToUse === 'string' ? manifest.whenToUse : null,
|
|
478
|
-
content: content.content,
|
|
479
|
-
truncated: content.truncated,
|
|
480
|
-
versions: versions.map((item) => ({
|
|
481
|
-
...item,
|
|
482
|
-
published_at: item.published_at === null ? null : Number(item.published_at),
|
|
483
|
-
})),
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
getTable(id: string): TableDetail {
|
|
488
|
-
const row = this.db.prepare('SELECT * FROM kb_table WHERE id = ?').get(id) as
|
|
489
|
-
| {
|
|
490
|
-
id: string
|
|
491
|
-
name: string
|
|
492
|
-
source_kind: string
|
|
493
|
-
source_ref: string | null
|
|
494
|
-
schema_json: string
|
|
495
|
-
field_map_status: string
|
|
496
|
-
updated_at: number
|
|
497
|
-
}
|
|
498
|
-
| undefined
|
|
499
|
-
if (!row) throw stateInvalid(`数据表不存在:${id}`)
|
|
500
|
-
const schema = this.parseSchema(row.schema_json)
|
|
501
|
-
return {
|
|
502
|
-
kind: 'table',
|
|
503
|
-
id: row.id,
|
|
504
|
-
name: row.name,
|
|
505
|
-
subtitle: `${row.source_kind}${row.source_ref ? ` · ${row.source_ref}` : ''}`,
|
|
506
|
-
source_kind: row.source_kind,
|
|
507
|
-
source_ref: row.source_ref,
|
|
508
|
-
columns: schema.columns.length,
|
|
509
|
-
row_count: this.liveRowCount(row.source_kind, row.source_ref),
|
|
510
|
-
updated_at: Number(row.updated_at),
|
|
511
|
-
columns_detail: schema.columns,
|
|
512
|
-
live: this.isLiveLocalTable(row.source_kind, row.source_ref),
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
// ───────────────────────── 同步(真实来源) ─────────────────────────
|
|
517
|
-
|
|
518
|
-
sync(actorId: string): SyncReport {
|
|
519
|
-
const at = this.now()
|
|
520
|
-
const report: SyncReport = {
|
|
521
|
-
at,
|
|
522
|
-
skills: { roots: this.skillDirs.length, scanned: 0, created: 0, updated: 0, unchanged: 0, missing: 0, errors: [] },
|
|
523
|
-
tables: { created: 0, updated: 0, unchanged: 0, errors: [] },
|
|
524
|
-
}
|
|
525
|
-
if (!this.skillDirs.length) {
|
|
526
|
-
report.skills.errors.push('没有配置技能目录(默认应为 $DSH_HOME/skills)')
|
|
527
|
-
}
|
|
528
|
-
for (const root of this.skillDirs) {
|
|
529
|
-
if (!existsSync(root)) {
|
|
530
|
-
report.skills.errors.push(`技能目录不存在:${root}`)
|
|
531
|
-
continue
|
|
532
|
-
}
|
|
533
|
-
let entries: { name: string; isDirectory(): boolean; isSymbolicLink(): boolean }[]
|
|
534
|
-
try {
|
|
535
|
-
entries = readdirSync(root, { withFileTypes: true })
|
|
536
|
-
} catch (error) {
|
|
537
|
-
report.skills.errors.push(`读不了技能目录 ${root}:${(error as Error).message}`)
|
|
538
|
-
continue
|
|
539
|
-
}
|
|
540
|
-
for (const entry of entries) {
|
|
541
|
-
if (entry.isSymbolicLink() || !entry.isDirectory()) continue
|
|
542
|
-
const file = path.join(root, entry.name, 'SKILL.md')
|
|
543
|
-
if (!existsSync(file)) continue
|
|
544
|
-
try {
|
|
545
|
-
const before = this.db.prepare('SELECT id FROM kb_skill WHERE name = ?').get(entry.name) as
|
|
546
|
-
| { id: string }
|
|
547
|
-
| undefined
|
|
548
|
-
const result = this.syncSkillFile(actorId, file, entry.name, before?.id)
|
|
549
|
-
report.skills.scanned += 1
|
|
550
|
-
if (result === 'created') report.skills.created += 1
|
|
551
|
-
else if (result === 'updated') report.skills.updated += 1
|
|
552
|
-
else report.skills.unchanged += 1
|
|
553
|
-
} catch (error) {
|
|
554
|
-
report.skills.errors.push(`${file}:${(error as Error).message}`)
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
report.skills.missing = this.countMissingSkills()
|
|
559
|
-
|
|
560
|
-
for (const table of KNOWLEDGE_TABLE_SOURCES) {
|
|
561
|
-
try {
|
|
562
|
-
if (!tableExists(this.db, table)) continue
|
|
563
|
-
const outcome = this.syncTable(table, at)
|
|
564
|
-
if (outcome === 'created') report.tables.created += 1
|
|
565
|
-
else if (outcome === 'updated') report.tables.updated += 1
|
|
566
|
-
else report.tables.unchanged += 1
|
|
567
|
-
} catch (error) {
|
|
568
|
-
report.tables.errors.push(`${table}:${(error as Error).message}`)
|
|
569
|
-
}
|
|
570
|
-
}
|
|
571
|
-
return report
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
private syncSkillFile(
|
|
575
|
-
actorId: string,
|
|
576
|
-
file: string,
|
|
577
|
-
dirName: string,
|
|
578
|
-
existingId: string | undefined,
|
|
579
|
-
): 'created' | 'updated' | 'unchanged' {
|
|
580
|
-
const text = readFileSync(file, 'utf8')
|
|
581
|
-
const hash = sha256(text)
|
|
582
|
-
const { meta } = parseFrontmatter(text)
|
|
583
|
-
const name = (meta.name || dirName).trim()
|
|
584
|
-
const skill = existingId
|
|
585
|
-
? (this.db.prepare('SELECT * FROM kb_skill WHERE id = ?').get(existingId) as
|
|
586
|
-
| { id: string; name: string; published_version_id: string | null }
|
|
587
|
-
| undefined)
|
|
588
|
-
: undefined
|
|
589
|
-
const now = this.now()
|
|
590
|
-
const manifest = JSON.stringify({
|
|
591
|
-
source: 'host-skills',
|
|
592
|
-
name,
|
|
593
|
-
description: meta.description ?? '',
|
|
594
|
-
whenToUse: meta.whenToUse ?? '',
|
|
595
|
-
path: file,
|
|
596
|
-
root: path.dirname(path.dirname(file)),
|
|
597
|
-
bytes: Buffer.byteLength(text, 'utf8'),
|
|
598
|
-
mtime: statSync(file).mtimeMs,
|
|
599
|
-
syncedAt: now,
|
|
600
|
-
})
|
|
601
|
-
|
|
602
|
-
if (skill?.published_version_id) {
|
|
603
|
-
const published = this.db
|
|
604
|
-
.prepare('SELECT content_hash FROM kb_skill_version WHERE id = ?')
|
|
605
|
-
.get(skill.published_version_id) as { content_hash: string } | undefined
|
|
606
|
-
if (published && published.content_hash === hash) return 'unchanged'
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
let id = skill?.id
|
|
610
|
-
let created = false
|
|
611
|
-
if (!id) {
|
|
612
|
-
const byName = this.db.prepare('SELECT id FROM kb_skill WHERE name = ?').get(name) as { id: string } | undefined
|
|
613
|
-
if (byName) id = byName.id
|
|
614
|
-
}
|
|
615
|
-
if (!id) {
|
|
616
|
-
id = `ks-${slug(name, file)}`
|
|
617
|
-
if (this.db.prepare('SELECT 1 FROM kb_skill WHERE id = ?').get(id)) id = `ks-${slug(name, file)}-${hash.slice(0, 6)}`
|
|
618
|
-
this.db.prepare('INSERT INTO kb_skill (id, name, domain, published_version_id, usage_count, created_at) VALUES (?, ?, ?, NULL, 0, ?)').run(
|
|
619
|
-
id,
|
|
620
|
-
name,
|
|
621
|
-
meta.domain ?? null,
|
|
622
|
-
now,
|
|
623
|
-
)
|
|
624
|
-
created = true
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
this.db.prepare("UPDATE kb_skill_version SET status = 'retired' WHERE skill_id = ? AND status = 'published'").run(id)
|
|
628
|
-
let version = `sync-${hash.slice(0, 12)}`
|
|
629
|
-
const clash = this.db.prepare('SELECT 1 FROM kb_skill_version WHERE skill_id = ? AND version = ?').get(id, version)
|
|
630
|
-
if (clash) version = `${version}-${now.toString(36)}`
|
|
631
|
-
const versionId = this.ids()
|
|
632
|
-
this.db.prepare(
|
|
633
|
-
`INSERT INTO kb_skill_version
|
|
634
|
-
(id, skill_id, version, manifest_json, content_hash, status, authored_by, published_by, published_at)
|
|
635
|
-
VALUES (?, ?, ?, ?, ?, 'published', ?, ?, ?)`,
|
|
636
|
-
).run(versionId, id, version, manifest, hash, actorId, actorId, now)
|
|
637
|
-
this.db.prepare('UPDATE kb_skill SET published_version_id = ?, name = ?, domain = ? WHERE id = ?').run(
|
|
638
|
-
versionId,
|
|
639
|
-
name,
|
|
640
|
-
meta.domain ?? null,
|
|
641
|
-
id,
|
|
642
|
-
)
|
|
643
|
-
return created ? 'created' : 'updated'
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
private countMissingSkills(): number {
|
|
647
|
-
const rows = this.db
|
|
648
|
-
.prepare(
|
|
649
|
-
`SELECT v.manifest_json FROM kb_skill s
|
|
650
|
-
JOIN kb_skill_version v ON v.id = s.published_version_id`,
|
|
651
|
-
)
|
|
652
|
-
.all() as { manifest_json: string }[]
|
|
653
|
-
let missing = 0
|
|
654
|
-
for (const row of rows) {
|
|
655
|
-
const manifest = this.parseManifest(row.manifest_json)
|
|
656
|
-
if (typeof manifest.path === 'string' && !existsSync(manifest.path)) missing += 1
|
|
657
|
-
}
|
|
658
|
-
return missing
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
private syncTable(table: string, at: number): 'created' | 'updated' | 'unchanged' {
|
|
662
|
-
const columns = columnList(this.db, table)
|
|
663
|
-
const schema = JSON.stringify({ table, columns })
|
|
664
|
-
const id = `kt-${table}`
|
|
665
|
-
const existing = this.db.prepare('SELECT * FROM kb_table WHERE id = ?').get(id) as
|
|
666
|
-
| { id: string; schema_json: string; source_ref: string | null }
|
|
667
|
-
| undefined
|
|
668
|
-
if (!existing) {
|
|
669
|
-
this.db.prepare(
|
|
670
|
-
`INSERT INTO kb_table (id, name, source_kind, source_ref, schema_json, field_map_status, updated_at)
|
|
671
|
-
VALUES (?, ?, 'local', ?, ?, 'ok', ?)`,
|
|
672
|
-
).run(id, `HAMS 业务表 · ${table}`, `hams:${table}`, schema, at)
|
|
673
|
-
return 'created'
|
|
674
|
-
}
|
|
675
|
-
if (existing.schema_json !== schema || existing.source_ref !== `hams:${table}`) {
|
|
676
|
-
this.db
|
|
677
|
-
.prepare("UPDATE kb_table SET schema_json = ?, source_ref = ?, updated_at = ? WHERE id = ?")
|
|
678
|
-
.run(schema, `hams:${table}`, at, id)
|
|
679
|
-
return 'updated'
|
|
680
|
-
}
|
|
681
|
-
return 'unchanged'
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
// ───────────────────────── 写:文档 ─────────────────────────
|
|
685
|
-
|
|
686
|
-
createDoc(actorId: string, input: { title: string; body: string; status?: string }): { id: string; revision: number; path: string } {
|
|
687
|
-
const title = String(input.title ?? '').trim()
|
|
688
|
-
if (!title) throw stateInvalid('标题不能为空')
|
|
689
|
-
if (title.length > 120) throw stateInvalid('标题超过 120 字')
|
|
690
|
-
const body = typeof input.body === 'string' ? input.body : ''
|
|
691
|
-
if (!body.trim()) throw stateInvalid('正文不能为空')
|
|
692
|
-
if (Buffer.byteLength(body, 'utf8') > MAX_DOC_BYTES) throw stateInvalid(`正文超过 ${MAX_DOC_BYTES / 1024} KB`)
|
|
693
|
-
const status = input.status === 'published' ? 'published' : 'draft'
|
|
694
|
-
const id = this.ids()
|
|
695
|
-
const now = this.now()
|
|
696
|
-
const file = path.join(this.docsDir, `${id}.md`)
|
|
697
|
-
mkdirSync(this.docsDir, { recursive: true })
|
|
698
|
-
const content = this.docFileContent({ title, body, ownerId: actorId, createdAt: now, revision: 1 })
|
|
699
|
-
writeFileSync(file, content, 'utf8')
|
|
700
|
-
this.db.prepare(
|
|
701
|
-
`INSERT INTO kb_doc (id, title, body_ref, revision, owner_id, source_kind, content_hash, bytes, status, updated_at)
|
|
702
|
-
VALUES (?, ?, ?, 1, ?, 'hams', ?, ?, ?, ?)`,
|
|
703
|
-
).run(id, title, file, actorId, sha256(content), Buffer.byteLength(content, 'utf8'), status, now)
|
|
704
|
-
return { id, revision: 1, path: file }
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
updateDoc(
|
|
708
|
-
actorId: string,
|
|
709
|
-
docId: string,
|
|
710
|
-
input: { title?: string; body: string; expectedRevision?: number },
|
|
711
|
-
allowForeign: boolean,
|
|
712
|
-
): { id: string; revision: number; path: string } {
|
|
713
|
-
const row = this.db.prepare('SELECT * FROM kb_doc WHERE id = ?').get(docId) as
|
|
714
|
-
| {
|
|
715
|
-
id: string
|
|
716
|
-
title: string
|
|
717
|
-
body_ref: string
|
|
718
|
-
revision: number
|
|
719
|
-
owner_id: string
|
|
720
|
-
source_kind: string
|
|
721
|
-
}
|
|
722
|
-
| undefined
|
|
723
|
-
if (!row) throw stateInvalid(`文档不存在:${docId}`)
|
|
724
|
-
if (row.source_kind !== 'hams') throw stateInvalid('导入的文件是只读镜像,请修改源文件后重新导入')
|
|
725
|
-
if (row.owner_id !== actorId && !allowForeign) throw stateInvalid('只有作者或有成员管理权限的人能改这篇文档')
|
|
726
|
-
if (input.expectedRevision !== undefined && Number(input.expectedRevision) !== Number(row.revision)) {
|
|
727
|
-
throw stateInvalid(`修订号已变:期望 ${input.expectedRevision},当前 ${row.revision}`, '刷新后比较两版内容再提交')
|
|
728
|
-
}
|
|
729
|
-
const title = input.title === undefined ? row.title : String(input.title).trim()
|
|
730
|
-
if (!title) throw stateInvalid('标题不能为空')
|
|
731
|
-
const body = typeof input.body === 'string' ? input.body : ''
|
|
732
|
-
if (!body.trim()) throw stateInvalid('正文不能为空')
|
|
733
|
-
if (Buffer.byteLength(body, 'utf8') > MAX_DOC_BYTES) throw stateInvalid(`正文超过 ${MAX_DOC_BYTES / 1024} KB`)
|
|
734
|
-
|
|
735
|
-
const now = this.now()
|
|
736
|
-
const revision = Number(row.revision) + 1
|
|
737
|
-
if (existsSync(row.body_ref)) {
|
|
738
|
-
copyFileSync(row.body_ref, path.join(this.docsDir, `${docId}.v${row.revision}.md`))
|
|
739
|
-
}
|
|
740
|
-
const content = this.docFileContent({ title, body, ownerId: row.owner_id, createdAt: now, revision })
|
|
741
|
-
mkdirSync(this.docsDir, { recursive: true })
|
|
742
|
-
writeFileSync(row.body_ref, content, 'utf8')
|
|
743
|
-
this.db.prepare(
|
|
744
|
-
`UPDATE kb_doc SET title = ?, content_hash = ?, bytes = ?, revision = ?, updated_at = ? WHERE id = ?`,
|
|
745
|
-
).run(title, sha256(content), Buffer.byteLength(content, 'utf8'), revision, now, docId)
|
|
746
|
-
return { id: docId, revision, path: row.body_ref }
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
private docFileContent(input: { title: string; body: string; ownerId: string; createdAt: number; revision: number }): string {
|
|
750
|
-
const stamp = new Date(input.createdAt).toISOString()
|
|
751
|
-
return [
|
|
752
|
-
'---',
|
|
753
|
-
`title: ${JSON.stringify(input.title)}`,
|
|
754
|
-
`owner: ${input.ownerId}`,
|
|
755
|
-
`revision: ${input.revision}`,
|
|
756
|
-
`updated: ${stamp}`,
|
|
757
|
-
'---',
|
|
758
|
-
'',
|
|
759
|
-
input.body.replace(/\r\n/g, '\n').trimEnd(),
|
|
760
|
-
'',
|
|
761
|
-
].join('\n')
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
publishDoc(actorId: string, docId: string, allowForeign: boolean): { id: string; status: string } {
|
|
765
|
-
const row = this.db.prepare('SELECT id, owner_id, status FROM kb_doc WHERE id = ?').get(docId) as
|
|
766
|
-
| { id: string; owner_id: string; status: string }
|
|
767
|
-
| undefined
|
|
768
|
-
if (!row) throw stateInvalid(`文档不存在:${docId}`)
|
|
769
|
-
if (row.owner_id !== actorId && !allowForeign) throw stateInvalid('只有作者或有成员管理权限的人能发布这篇文档')
|
|
770
|
-
if (row.status === 'archived') throw stateInvalid('已归档的文档不能直接发布')
|
|
771
|
-
if (row.status !== 'published') {
|
|
772
|
-
this.db.prepare("UPDATE kb_doc SET status = 'published', updated_at = ? WHERE id = ?").run(this.now(), docId)
|
|
773
|
-
}
|
|
774
|
-
return { id: docId, status: 'published' }
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
/** 导入单个本机文件(工作区画像用);返回 created / updated / skipped。 */
|
|
778
|
-
importFile(
|
|
779
|
-
actorId: string,
|
|
780
|
-
file: string,
|
|
781
|
-
options: { title?: string; status?: 'draft' | 'published' } = {},
|
|
782
|
-
): 'created' | 'updated' | 'skipped' {
|
|
783
|
-
const target = path.resolve(expandHome(file))
|
|
784
|
-
if (isDeniedPath(target)) throw stateInvalid('这个路径不在允许导入的范围里(会话记录、密钥目录不读)')
|
|
785
|
-
if (!existsSync(target)) throw stateInvalid(`文件不存在:${target}`)
|
|
786
|
-
if (!statSync(target).isFile()) throw stateInvalid(`不是文件:${target}`)
|
|
787
|
-
return this.upsertImportedFile(actorId, target, options)
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
private upsertImportedFile(
|
|
791
|
-
actorId: string,
|
|
792
|
-
file: string,
|
|
793
|
-
options: { title?: string; status?: 'draft' | 'published' } = {},
|
|
794
|
-
): 'created' | 'updated' | 'skipped' {
|
|
795
|
-
const size = statSync(file).size
|
|
796
|
-
if (size > MAX_IMPORT_BYTES) throw stateInvalid(`超过 ${MAX_IMPORT_BYTES / 1024 / 1024} MB,跳过`)
|
|
797
|
-
const text = readFileSync(file, 'utf8')
|
|
798
|
-
const hash = sha256(text)
|
|
799
|
-
const existing = this.db.prepare('SELECT * FROM kb_doc WHERE body_ref = ?').get(file) as
|
|
800
|
-
| { id: string; content_hash: string; title: string }
|
|
801
|
-
| undefined
|
|
802
|
-
if (existing) {
|
|
803
|
-
if (existing.content_hash === hash) return 'skipped'
|
|
804
|
-
this.db
|
|
805
|
-
.prepare(
|
|
806
|
-
`UPDATE kb_doc SET title = ?, content_hash = ?, bytes = ?, revision = revision + 1, updated_at = ?
|
|
807
|
-
WHERE id = ?`,
|
|
808
|
-
)
|
|
809
|
-
.run(
|
|
810
|
-
options.title ?? titleOfFile(text, path.basename(file)),
|
|
811
|
-
hash,
|
|
812
|
-
Buffer.byteLength(text, 'utf8'),
|
|
813
|
-
this.now(),
|
|
814
|
-
existing.id,
|
|
815
|
-
)
|
|
816
|
-
return 'updated'
|
|
817
|
-
}
|
|
818
|
-
const id = this.ids()
|
|
819
|
-
const now = this.now()
|
|
820
|
-
this.db.prepare(
|
|
821
|
-
`INSERT INTO kb_doc (id, title, body_ref, revision, owner_id, source_kind, content_hash, bytes, status, updated_at)
|
|
822
|
-
VALUES (?, ?, ?, 1, ?, 'file', ?, ?, ?, ?)`,
|
|
823
|
-
).run(
|
|
824
|
-
id,
|
|
825
|
-
options.title ?? titleOfFile(text, path.basename(file)),
|
|
826
|
-
file,
|
|
827
|
-
actorId,
|
|
828
|
-
hash,
|
|
829
|
-
Buffer.byteLength(text, 'utf8'),
|
|
830
|
-
options.status ?? 'published',
|
|
831
|
-
now,
|
|
832
|
-
)
|
|
833
|
-
return 'created'
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
importDocs(
|
|
837
|
-
actorId: string,
|
|
838
|
-
input: { dir: string; recursive?: boolean; maxFiles?: number },
|
|
839
|
-
): ImportReport {
|
|
840
|
-
const raw = String(input.dir ?? '').trim()
|
|
841
|
-
if (!raw) throw stateInvalid('导入目录不能为空')
|
|
842
|
-
const dir = path.resolve(expandHome(raw))
|
|
843
|
-
if (isDeniedPath(dir)) throw stateInvalid('这个目录不在允许导入的范围里(会话记录、密钥目录不读)')
|
|
844
|
-
if (!existsSync(dir)) throw stateInvalid(`目录不存在:${dir}`)
|
|
845
|
-
if (!statSync(dir).isDirectory()) throw stateInvalid(`不是目录:${dir}`)
|
|
846
|
-
|
|
847
|
-
const recursive = input.recursive !== false
|
|
848
|
-
const maxFiles = Math.min(Math.max(Number(input.maxFiles ?? 200), 1), MAX_IMPORT_FILES)
|
|
849
|
-
const report: ImportReport = { at: this.now(), dir, scanned: 0, imported: 0, updated: 0, skipped: 0, errors: [] }
|
|
850
|
-
const files: string[] = []
|
|
851
|
-
const walk = (current: string, depth: number): void => {
|
|
852
|
-
let entries
|
|
853
|
-
try {
|
|
854
|
-
entries = readdirSync(current, { withFileTypes: true })
|
|
855
|
-
} catch (error) {
|
|
856
|
-
report.errors.push({ path: current, message: (error as Error).message })
|
|
857
|
-
return
|
|
858
|
-
}
|
|
859
|
-
for (const entry of entries) {
|
|
860
|
-
if (files.length >= maxFiles) return
|
|
861
|
-
const name = entry.name
|
|
862
|
-
if (name.startsWith('.') || SKIP_DIRS.has(name)) continue
|
|
863
|
-
const full = path.join(current, name)
|
|
864
|
-
if (entry.isDirectory()) {
|
|
865
|
-
if (recursive && depth < 4) walk(full, depth + 1)
|
|
866
|
-
continue
|
|
867
|
-
}
|
|
868
|
-
if (!entry.isFile()) continue
|
|
869
|
-
if (!/\.(md|markdown)$/i.test(name)) continue
|
|
870
|
-
files.push(full)
|
|
871
|
-
}
|
|
872
|
-
}
|
|
873
|
-
walk(dir, 0)
|
|
874
|
-
|
|
875
|
-
for (const file of files) {
|
|
876
|
-
report.scanned += 1
|
|
877
|
-
try {
|
|
878
|
-
const outcome = this.upsertImportedFile(actorId, file)
|
|
879
|
-
if (outcome === 'created') report.imported += 1
|
|
880
|
-
else if (outcome === 'updated') report.updated += 1
|
|
881
|
-
else report.skipped += 1
|
|
882
|
-
} catch (error) {
|
|
883
|
-
report.errors.push({ path: file, message: (error as Error).message })
|
|
884
|
-
}
|
|
885
|
-
}
|
|
886
|
-
return report
|
|
887
|
-
}
|
|
888
|
-
|
|
889
|
-
// ───────────────────────── 内部 ─────────────────────────
|
|
890
|
-
|
|
891
|
-
private readFileSafe(file: string): { ok: boolean; text: string } {
|
|
892
|
-
try {
|
|
893
|
-
if (!file || !existsSync(file)) return { ok: false, text: '' }
|
|
894
|
-
const stat = statSync(file)
|
|
895
|
-
if (!stat.isFile()) return { ok: false, text: '' }
|
|
896
|
-
return { ok: true, text: readFileSync(file, 'utf8') }
|
|
897
|
-
} catch {
|
|
898
|
-
return { ok: false, text: '' }
|
|
899
|
-
}
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
private parseManifest(raw: string | null): Record<string, unknown> {
|
|
903
|
-
if (!raw) return {}
|
|
904
|
-
try {
|
|
905
|
-
const value = JSON.parse(raw) as unknown
|
|
906
|
-
return value && typeof value === 'object' ? (value as Record<string, unknown>) : {}
|
|
907
|
-
} catch {
|
|
908
|
-
return {}
|
|
909
|
-
}
|
|
910
|
-
}
|
|
911
|
-
|
|
912
|
-
private parseSchema(raw: string): { columns: { name: string; type: string; notnull: number; pk: number }[] } {
|
|
913
|
-
try {
|
|
914
|
-
const value = JSON.parse(raw) as { columns?: unknown }
|
|
915
|
-
return {
|
|
916
|
-
columns: Array.isArray(value.columns)
|
|
917
|
-
? (value.columns as { name: string; type: string; notnull: number; pk: number }[])
|
|
918
|
-
: [],
|
|
919
|
-
}
|
|
920
|
-
} catch {
|
|
921
|
-
return { columns: [] }
|
|
922
|
-
}
|
|
923
|
-
}
|
|
924
|
-
|
|
925
|
-
private isLiveLocalTable(sourceKind: string, sourceRef: string | null): boolean {
|
|
926
|
-
if (sourceKind !== 'local' || !sourceRef?.startsWith('hams:')) return false
|
|
927
|
-
const table = sourceRef.slice(5)
|
|
928
|
-
return /^[a-z_][a-z0-9_]*$/.test(table) && tableExists(this.db, table)
|
|
929
|
-
}
|
|
930
|
-
|
|
931
|
-
private liveRowCount(sourceKind: string, sourceRef: string | null): number | null {
|
|
932
|
-
if (!this.isLiveLocalTable(sourceKind, sourceRef)) return null
|
|
933
|
-
const table = String(sourceRef).slice(5)
|
|
934
|
-
try {
|
|
935
|
-
return Number((this.db.prepare(`SELECT COUNT(*) AS c FROM "${table}"`).get() as { c: number }).c)
|
|
936
|
-
} catch {
|
|
937
|
-
return null
|
|
938
|
-
}
|
|
939
|
-
}
|
|
940
|
-
}
|