dsh-hams 0.1.3 → 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.
Files changed (50) hide show
  1. package/CHANGELOG.md +3 -3
  2. package/README.md +4 -0
  3. package/dist/index.js +11335 -0
  4. package/package.json +9 -5
  5. package/src/domain/check.ts +0 -193
  6. package/src/domain/errors.ts +0 -59
  7. package/src/domain/evidence.ts +0 -134
  8. package/src/domain/ids.ts +0 -31
  9. package/src/host/chat-runtime.ts +0 -489
  10. package/src/host/dsh-adapter.ts +0 -867
  11. package/src/host/session-adapter.ts +0 -276
  12. package/src/http/handler.ts +0 -1132
  13. package/src/index.js +0 -53
  14. package/src/service/audit.ts +0 -92
  15. package/src/service/chat.ts +0 -1100
  16. package/src/service/contracts.ts +0 -193
  17. package/src/service/defaults.ts +0 -182
  18. package/src/service/deliverables.ts +0 -240
  19. package/src/service/hams.ts +0 -3103
  20. package/src/service/knowledge.ts +0 -940
  21. package/src/service/member-config.ts +0 -39
  22. package/src/service/members.ts +0 -506
  23. package/src/service/memory.ts +0 -375
  24. package/src/service/permissions.ts +0 -118
  25. package/src/service/project-lifecycle.ts +0 -233
  26. package/src/service/projects.ts +0 -840
  27. package/src/service/receipts.ts +0 -63
  28. package/src/service/templates.ts +0 -140
  29. package/src/service/tick.ts +0 -751
  30. package/src/service/tools.ts +0 -711
  31. package/src/service/types.ts +0 -219
  32. package/src/service/work-item-create.ts +0 -159
  33. package/src/service/workspace-profile.ts +0 -443
  34. package/src/store/backup.ts +0 -239
  35. package/src/store/bootstrap.ts +0 -225
  36. package/src/store/db.ts +0 -179
  37. package/src/store/lock.ts +0 -194
  38. package/src/store/migrations.ts +0 -211
  39. package/src/store/schema/001_p1.sql +0 -846
  40. package/src/store/schema/001_p1.sql.ts +0 -865
  41. package/src/store/schema/002_knowledge.sql +0 -54
  42. package/src/store/schema/002_knowledge.sql.ts +0 -68
  43. package/src/store/schema/003_projects.sql.ts +0 -3
  44. package/src/store/schema/004_team.sql.ts +0 -50
  45. package/src/store/schema/005_target_check.sql.ts +0 -22
  46. package/src/store/schema/006_target_flow.sql.ts +0 -19
  47. package/src/store/schema/007_project_delete.sql.ts +0 -17
  48. package/src/store/schema/008_memory.sql.ts +0 -47
  49. package/src/store/schema/generate-projects.mjs +0 -16
  50. package/src/store/seed.ts +0 -114
@@ -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
- }