@p-dsh-market/conversation-knowledge-map 0.1.0

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.
@@ -0,0 +1,188 @@
1
+ import path from 'node:path'
2
+
3
+ import { errorMessage, eventMessageText, shortText } from './protocol.js'
4
+
5
+ const WINDOWS_PATH = process.platform === 'win32'
6
+
7
+ export function normalizeWorkspacePath(value) {
8
+ const raw = String(value || '').trim()
9
+ if (!raw) return ''
10
+ const absolute = path.resolve(raw)
11
+ const normalized = path.normalize(absolute)
12
+ const root = path.parse(normalized).root
13
+ return normalized.length > root.length ? normalized.replace(/[\\/]$/, '') : normalized
14
+ }
15
+
16
+ export function sameWorkspacePath(left, right) {
17
+ const a = normalizeWorkspacePath(left)
18
+ const b = normalizeWorkspacePath(right)
19
+ if (!a || !b) return false
20
+ return WINDOWS_PATH ? a.toLowerCase() === b.toLowerCase() : a === b
21
+ }
22
+
23
+ function headerOf(record) {
24
+ return record?.header || record?.session || record || null
25
+ }
26
+
27
+ function titleOf(value) {
28
+ const title = value?.title ?? value?.value?.title ?? value
29
+ if (typeof title === 'string') return shortText(title, 160)
30
+ if (typeof title?.title === 'string') return shortText(title.title, 160)
31
+ if (typeof title?.text === 'string') return shortText(title.text, 160)
32
+ return ''
33
+ }
34
+
35
+ function titleResultsById(results) {
36
+ const map = new Map()
37
+ for (const item of results || []) {
38
+ if (item?.status === 'fulfilled') map.set(String(item.sessionId), titleOf(item.value))
39
+ }
40
+ return map
41
+ }
42
+
43
+ export function sessionRecordView(record, title = '', currentSessionId = '') {
44
+ const header = headerOf(record)
45
+ const id = String(header?.id || record?.id || '')
46
+ return {
47
+ id,
48
+ title: title || `未命名对话 ${id.slice(0, 8)}`,
49
+ createdAt: Number(header?.createdAt || 0),
50
+ cwd: String(header?.cwd || ''),
51
+ parentSession: String(header?.parentSession || ''),
52
+ origin: String(header?.origin || ''),
53
+ live: record?.live !== false,
54
+ persisted: record?.persisted !== false,
55
+ current: id === currentSessionId
56
+ }
57
+ }
58
+
59
+ export async function resolveAnchorSession({ sessionQuery, sessions }, sessionId) {
60
+ const id = String(sessionId || '').trim()
61
+ if (!id || id === 'active') return null
62
+ const live = sessions?.get?.(id)
63
+ if (live?.header?.id || live?.id === id) {
64
+ return { header: live.header || live, live: true, persisted: true }
65
+ }
66
+ if (typeof sessionQuery?.filterSessions === 'function') {
67
+ const records = await sessionQuery.filterSessions([{ kind: 'id', values: [id] }])
68
+ return records?.find((record) => String(headerOf(record)?.id || '') === id) || null
69
+ }
70
+ if (typeof sessionQuery?.listSessions === 'function') {
71
+ const records = await sessionQuery.listSessions()
72
+ return records?.find((record) => String(headerOf(record)?.id || '') === id) || null
73
+ }
74
+ return null
75
+ }
76
+
77
+ export async function listWorkspaceSessions({ sessionQuery, sessions }, cwd, currentSessionId = '', includeSubagents = false) {
78
+ const normalizedCwd = normalizeWorkspacePath(cwd)
79
+ if (!normalizedCwd) return []
80
+ let records = []
81
+ if (typeof sessionQuery?.filterSessions === 'function') {
82
+ records = await sessionQuery.filterSessions([{ kind: 'cwd', values: [cwd] }])
83
+ } else if (typeof sessionQuery?.listSessions === 'function') {
84
+ records = await sessionQuery.listSessions()
85
+ } else if (typeof sessions?.list === 'function') {
86
+ records = await sessions.list()
87
+ }
88
+ records = (records || []).filter((record) => {
89
+ const header = headerOf(record)
90
+ return sameWorkspacePath(header?.cwd, normalizedCwd) && (includeSubagents || header?.origin !== 'subagent')
91
+ })
92
+ const ids = records.map((record) => String(headerOf(record)?.id || record?.id || '')).filter(Boolean)
93
+ let titleResults = []
94
+ if (typeof sessionQuery?.readTitleSnapshots === 'function' && ids.length) {
95
+ titleResults = await sessionQuery.readTitleSnapshots(ids)
96
+ }
97
+ const titles = titleResultsById(titleResults)
98
+ return records
99
+ .map((record) => sessionRecordView(record, titles.get(String(headerOf(record)?.id || '')), currentSessionId))
100
+ .filter((record) => record.id)
101
+ .sort((left, right) => right.createdAt - left.createdAt || left.id.localeCompare(right.id))
102
+ }
103
+
104
+ function eventRole(type) {
105
+ if (type === 'user/message') return 'user'
106
+ if (type === 'assistant/message') return 'assistant'
107
+ return ''
108
+ }
109
+
110
+ function sourceSeqsOf(event) {
111
+ const value = event?.sourceEventSeqs || event?.data?.sourceEventSeqs || []
112
+ return Array.isArray(value) ? value.filter((item) => Number.isInteger(item) && item >= 0) : []
113
+ }
114
+
115
+ export function surfaceEventView(event) {
116
+ const role = eventRole(String(event?.type || ''))
117
+ const text = shortText(eventMessageText(event), 12000)
118
+ if (!role || !text) return null
119
+ return {
120
+ seq: Number.isInteger(event?.seq) ? event.seq : 0,
121
+ type: String(event.type),
122
+ role,
123
+ text,
124
+ sourceEventSeqs: sourceSeqsOf(event)
125
+ }
126
+ }
127
+
128
+ export async function readSelectedSurfaces({ sessionQuery, sessions }, { cwd, sessionIds, includeSubagents = false }) {
129
+ const selected = [...new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean))]
130
+ if (selected.length === 0) throw new Error('至少选择一个对话。')
131
+ const normalizedCwd = normalizeWorkspacePath(cwd)
132
+ if (!normalizedCwd) throw new Error('当前工作路径无效。')
133
+ const records = []
134
+ if (typeof sessionQuery?.filterSessions === 'function') {
135
+ records.push(...await sessionQuery.filterSessions([{ kind: 'id', values: selected }]))
136
+ } else if (typeof sessionQuery?.listSessions === 'function') {
137
+ const all = await sessionQuery.listSessions()
138
+ records.push(...all.filter((record) => selected.includes(String(headerOf(record)?.id || ''))))
139
+ }
140
+ const recordMap = new Map(records.map((record) => [String(headerOf(record)?.id || record?.id || ''), record]))
141
+ const sources = []
142
+ for (const id of selected) {
143
+ const record = recordMap.get(id)
144
+ const header = headerOf(record) || sessions?.get?.(id)?.header
145
+ if (!header?.id) throw new Error(`所选对话不存在或已不可读:${id}`)
146
+ if (!sameWorkspacePath(header.cwd, normalizedCwd)) throw new Error(`所选对话不属于当前工作路径:${id}`)
147
+ if (!includeSubagents && header.origin === 'subagent') throw new Error(`不能选择子 Agent 对话:${id}`)
148
+ if (typeof sessionQuery?.readSurface !== 'function') throw new Error('当前 DSH Runtime 未提供 sessionQuery.readSurface。')
149
+ let surface
150
+ try {
151
+ surface = await sessionQuery.readSurface(id)
152
+ } catch (error) {
153
+ throw new Error(`读取对话“${id}”失败:${errorMessage(error)}`)
154
+ }
155
+ const events = (surface?.events || []).map(surfaceEventView).filter(Boolean)
156
+ sources.push({
157
+ sessionId: id,
158
+ title: `对话 ${id.slice(0, 8)}`,
159
+ cwd: normalizeWorkspacePath(surface?.session?.cwd || header.cwd),
160
+ capturedThroughSeq: Number.isInteger(surface?.capturedThroughSeq) ? surface.capturedThroughSeq : null,
161
+ events,
162
+ text: events.map((event) => `${event.role === 'user' ? '用户' : '助手'}:${event.text}`).join('\n\n')
163
+ })
164
+ }
165
+ if (typeof sessionQuery?.readTitleSnapshots === 'function' && sources.length) {
166
+ const results = await sessionQuery.readTitleSnapshots(sources.map((source) => source.sessionId))
167
+ const titles = titleResultsById(results)
168
+ for (const source of sources) source.title = titles.get(source.sessionId) || source.title
169
+ }
170
+ return sources
171
+ }
172
+
173
+ export function chunkSourceText(source, maxChars = 9000) {
174
+ const text = String(source?.text || '')
175
+ if (!text) return [{ text: '', sourceRefs: [] }]
176
+ const chunks = []
177
+ let start = 0
178
+ while (start < text.length) {
179
+ const end = Math.min(text.length, start + maxChars)
180
+ const chunkText = text.slice(start, end)
181
+ const refs = (source.events || [])
182
+ .filter((event) => event.text && text.indexOf(event.text, start) >= start && text.indexOf(event.text, start) < end)
183
+ .map((event) => ({ sessionId: source.sessionId, eventSeqs: [event.seq] }))
184
+ chunks.push({ text: chunkText, sourceRefs: refs })
185
+ start = end
186
+ }
187
+ return chunks
188
+ }
@@ -0,0 +1,224 @@
1
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { randomUUID } from 'node:crypto'
4
+
5
+ import { DATA_DIR, shortText } from './protocol.js'
6
+ import { normalizeWorkspacePath } from './session-source.js'
7
+
8
+ const FILES = {
9
+ manifest: 'manifest.json',
10
+ mindMap: 'mind-map.json',
11
+ knowledgeGraph: 'knowledge-graph.json',
12
+ navigationHistory: 'navigation-history.json'
13
+ }
14
+
15
+ export const WORKSPACE_SCHEMA_VERSION = 1
16
+
17
+ function compatibilityOf(manifest) {
18
+ if (Number(manifest?.schemaVersion) === WORKSPACE_SCHEMA_VERSION) {
19
+ return { supported: true, state: 'current', schemaVersion: WORKSPACE_SCHEMA_VERSION, message: '' }
20
+ }
21
+ const version = Number.isInteger(manifest?.schemaVersion) ? manifest.schemaVersion : null
22
+ return {
23
+ supported: false,
24
+ state: 'legacy',
25
+ schemaVersion: version,
26
+ message: '当前工作区知识视图来自旧版本 Schema,暂不直接加载;请确认后重新生成。'
27
+ }
28
+ }
29
+
30
+ function isNotFound(error) {
31
+ return error?.code === 'ENOENT'
32
+ }
33
+
34
+ async function readJsonIfExists(filePath) {
35
+ try {
36
+ return JSON.parse(await readFile(filePath, 'utf8'))
37
+ } catch (error) {
38
+ if (isNotFound(error)) return null
39
+ throw new Error(`读取知识视图数据失败:${filePath}:${error.message}`)
40
+ }
41
+ }
42
+
43
+ async function writeJson(filePath, value) {
44
+ await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
45
+ }
46
+
47
+ async function replaceBundle(dataDir, values) {
48
+ const id = randomUUID()
49
+ const stagingDir = path.join(dataDir, `.staging-${id}`)
50
+ const finals = Object.entries(values).map(([key, value]) => ({
51
+ key,
52
+ temp: path.join(stagingDir, FILES[key]),
53
+ final: path.join(dataDir, FILES[key]),
54
+ value
55
+ }))
56
+ const backups = []
57
+ const installed = []
58
+ await mkdir(stagingDir, { recursive: true })
59
+ try {
60
+ for (const item of finals) await writeJson(item.temp, item.value)
61
+ for (const item of finals) {
62
+ const backup = `${item.final}.backup-${id}`
63
+ let hadOld = false
64
+ try {
65
+ await rename(item.final, backup)
66
+ hadOld = true
67
+ } catch (error) {
68
+ if (!isNotFound(error)) throw error
69
+ }
70
+ backups.push({ final: item.final, backup, hadOld })
71
+ await rename(item.temp, item.final)
72
+ installed.push(item.final)
73
+ }
74
+ for (const backup of backups) if (backup.hadOld) await rm(backup.backup, { force: true })
75
+ await rm(stagingDir, { recursive: true, force: true })
76
+ } catch (error) {
77
+ for (const filePath of installed) await rm(filePath, { force: true }).catch(() => {})
78
+ for (const backup of backups) {
79
+ if (backup.hadOld) await rename(backup.backup, backup.final).catch(() => {})
80
+ }
81
+ await rm(stagingDir, { recursive: true, force: true }).catch(() => {})
82
+ throw error
83
+ }
84
+ }
85
+
86
+ function workspaceKey(cwd) {
87
+ return process.platform === 'win32' ? cwd.toLowerCase() : cwd
88
+ }
89
+
90
+ export class WorkspaceRevisionError extends Error {
91
+ constructor(expected, actual) {
92
+ super(`工作区结果已被其他窗口更新(期望 revision ${expected},当前 revision ${actual}),请重新确认后再生成。`)
93
+ this.name = 'WorkspaceRevisionError'
94
+ this.expected = expected
95
+ this.actual = actual
96
+ }
97
+ }
98
+
99
+ export class WorkspaceStorage {
100
+ constructor({ now = () => Date.now() } = {}) {
101
+ this.now = now
102
+ this.locks = new Map()
103
+ }
104
+
105
+ resolveDataDir(cwd) {
106
+ const normalized = normalizeWorkspacePath(cwd)
107
+ if (!normalized || !path.isAbsolute(normalized)) throw new Error('工作路径必须是绝对路径。')
108
+ const dataDir = path.resolve(normalized, DATA_DIR)
109
+ if (path.dirname(dataDir) !== normalized) throw new Error('工作区数据目录解析失败。')
110
+ return dataDir
111
+ }
112
+
113
+ async readState(cwd) {
114
+ const dataDir = this.resolveDataDir(cwd)
115
+ const manifest = await readJsonIfExists(path.join(dataDir, FILES.manifest))
116
+ if (!manifest) return {
117
+ exists: false,
118
+ dataDir,
119
+ revision: 0,
120
+ manifest: null,
121
+ mindMap: null,
122
+ knowledgeGraph: null,
123
+ navigationHistory: [],
124
+ compatibility: { supported: true, state: 'empty', schemaVersion: WORKSPACE_SCHEMA_VERSION, message: '' }
125
+ }
126
+ const compatibility = compatibilityOf(manifest)
127
+ if (!compatibility.supported) return {
128
+ exists: true,
129
+ dataDir,
130
+ revision: Number.isInteger(manifest.revision) ? manifest.revision : 0,
131
+ manifest,
132
+ mindMap: null,
133
+ knowledgeGraph: null,
134
+ navigationHistory: [],
135
+ compatibility
136
+ }
137
+ const [mindMap, knowledgeGraph, navigationHistory] = await Promise.all([
138
+ readJsonIfExists(path.join(dataDir, FILES.mindMap)),
139
+ readJsonIfExists(path.join(dataDir, FILES.knowledgeGraph)),
140
+ readJsonIfExists(path.join(dataDir, FILES.navigationHistory))
141
+ ])
142
+ return {
143
+ exists: true,
144
+ dataDir,
145
+ revision: Number.isInteger(manifest.revision) ? manifest.revision : 0,
146
+ manifest,
147
+ mindMap,
148
+ knowledgeGraph,
149
+ navigationHistory: Array.isArray(navigationHistory) ? navigationHistory : [],
150
+ compatibility
151
+ }
152
+ }
153
+
154
+ async saveBundle({ cwd, expectedRevision = 0, generationId, sourceSessionIds, prompt, strict, outputMode, model, mindMap, knowledgeGraph }) {
155
+ const normalizedCwd = normalizeWorkspacePath(cwd)
156
+ const key = workspaceKey(normalizedCwd)
157
+ const previous = this.locks.get(key) || Promise.resolve()
158
+ const operation = previous.catch(() => {}).then(async () => {
159
+ const current = await this.readState(normalizedCwd)
160
+ if (Number(expectedRevision) !== current.revision) throw new WorkspaceRevisionError(expectedRevision, current.revision)
161
+ const revision = current.revision + 1
162
+ const manifest = {
163
+ schemaVersion: WORKSPACE_SCHEMA_VERSION,
164
+ plugin: '@p-dsh-market/conversation-knowledge-map',
165
+ generationId: String(generationId || ''),
166
+ revision,
167
+ cwd: normalizedCwd,
168
+ sourceSessionIds: [...new Set((sourceSessionIds || []).map(String))],
169
+ promptSummary: shortText(prompt, 500),
170
+ strict: strict === true,
171
+ outputMode: String(outputMode || 'both'),
172
+ model: model ? { provider: String(model.provider || ''), model: String(model.model || '') } : null,
173
+ generatedAt: this.now()
174
+ }
175
+ const navigationHistory = current.navigationHistory || []
176
+ await replaceBundle(this.resolveDataDir(normalizedCwd), {
177
+ manifest,
178
+ mindMap: mindMap || null,
179
+ knowledgeGraph: knowledgeGraph || null,
180
+ navigationHistory
181
+ })
182
+ return { revision, manifest, dataDir: this.resolveDataDir(normalizedCwd) }
183
+ })
184
+ this.locks.set(key, operation)
185
+ try {
186
+ return await operation
187
+ } finally {
188
+ if (this.locks.get(key) === operation) this.locks.delete(key)
189
+ }
190
+ }
191
+
192
+ async appendNavigation({ cwd, expectedRevision, navigation }) {
193
+ const normalizedCwd = normalizeWorkspacePath(cwd)
194
+ const key = workspaceKey(normalizedCwd)
195
+ const previous = this.locks.get(key) || Promise.resolve()
196
+ const operation = previous.catch(() => {}).then(async () => {
197
+ const current = await this.readState(normalizedCwd)
198
+ if (expectedRevision !== undefined && Number(expectedRevision) !== current.revision) {
199
+ throw new WorkspaceRevisionError(expectedRevision, current.revision)
200
+ }
201
+ const history = [...(current.navigationHistory || []), {
202
+ id: String(navigation.id || randomUUID()),
203
+ nodeId: String(navigation.nodeId || ''),
204
+ targetSessionId: String(navigation.targetSessionId || ''),
205
+ questionSummary: shortText(navigation.question, 500),
206
+ confirmedAt: this.now()
207
+ }].slice(-100)
208
+ if (!current.manifest) throw new Error('当前工作路径还没有已保存的知识视图。')
209
+ await replaceBundle(current.dataDir, {
210
+ manifest: current.manifest,
211
+ mindMap: current.mindMap,
212
+ knowledgeGraph: current.knowledgeGraph,
213
+ navigationHistory: history
214
+ })
215
+ return { revision: current.revision, navigation: history.at(-1) }
216
+ })
217
+ this.locks.set(key, operation)
218
+ try {
219
+ return await operation
220
+ } finally {
221
+ if (this.locks.get(key) === operation) this.locks.delete(key)
222
+ }
223
+ }
224
+ }
package/package.json ADDED
@@ -0,0 +1,74 @@
1
+ {
2
+ "name": "@p-dsh-market/conversation-knowledge-map",
3
+ "version": "0.1.0",
4
+ "description": "DSH 多对话思维导图与静态知识图谱",
5
+ "keywords": [
6
+ "dsh",
7
+ "dsh-plugin",
8
+ "p-dsh-market",
9
+ "conversation",
10
+ "mind-map",
11
+ "knowledge-graph"
12
+ ],
13
+ "type": "module",
14
+ "main": "lib/index.js",
15
+ "exports": {
16
+ ".": "./lib/index.js",
17
+ "./client": "./lib/client.js",
18
+ "./protocol": "./lib/protocol.js",
19
+ "./session-source": "./lib/session-source.js",
20
+ "./generation-orchestrator": "./lib/generation-orchestrator.js",
21
+ "./mind-map-schema": "./lib/mind-map-schema.js",
22
+ "./knowledge-graph-schema": "./lib/knowledge-graph-schema.js",
23
+ "./workspace-storage": "./lib/workspace-storage.js",
24
+ "./cordis.patch.yml": "./cordis.patch.yml",
25
+ "./package.json": "./package.json"
26
+ },
27
+ "files": [
28
+ "lib/index.js",
29
+ "lib/client.js",
30
+ "lib/protocol.js",
31
+ "lib/session-source.js",
32
+ "lib/generation-orchestrator.js",
33
+ "lib/mind-map-schema.js",
34
+ "lib/knowledge-graph-schema.js",
35
+ "lib/workspace-storage.js",
36
+ "skills/conversation-knowledge-map/SKILL.md",
37
+ "cordis.patch.yml",
38
+ "README.md"
39
+ ],
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "dsh": {
44
+ "protocolVersion": 1,
45
+ "client": {
46
+ "platform": "web"
47
+ },
48
+ "bundle": {
49
+ "patch": "./cordis.patch.yml"
50
+ },
51
+ "market": {
52
+ "displayName": "知识视图",
53
+ "capabilities": ["skills", "host", "client", "desktop-shell"]
54
+ },
55
+ "desktop": {
56
+ "permissions": ["shell:titlebar", "shell:page", "workspace:read", "workspace:write-plugin-data"],
57
+ "contributes": {
58
+ "titlebarActions": [
59
+ {
60
+ "id": "open-conversation-knowledge-map",
61
+ "slot": "desktop.titlebar.workspaceActions",
62
+ "label": "知识视图",
63
+ "order": 140,
64
+ "when": ["dshRunning", "pluginActive", "restartNotRequired"],
65
+ "action": {
66
+ "type": "pluginRpc",
67
+ "method": "conversationKnowledgeMap.open"
68
+ }
69
+ }
70
+ ]
71
+ }
72
+ }
73
+ }
74
+ }
@@ -0,0 +1,13 @@
1
+ ---
2
+ name: conversation-knowledge-map
3
+ description: 在用户明确要求整理多个同工作路径对话、生成思维导图或静态知识图谱时,使用知识视图插件的结构化生成、来源引用、严格约束和工作区持久化流程。
4
+ ---
5
+
6
+ # 知识视图生成约束
7
+
8
+ - 只有用户明确点击确认后,才能读取所选对话正文、调用模型或写入 `.g-dsh-market-knowledge`。
9
+ - 只使用锚点 Session 的 `header.cwd` 和用户从同一 `cwd` 选择的 Session;默认排除 `origin: subagent`。
10
+ - 默认读取 `sessionQuery.readSurface()`,不要读取或复制完整原始 JSONL。
11
+ - 思维导图节点必须包含标题、阶段性说明和来源引用;知识图谱关系必须包含证据和置信度。
12
+ - 知识图谱是静态结果,不自动更新、不在图上编辑、不从节点发散。
13
+ - 思维导图节点只生成后续问题;导航确认后不自动发送。