dsh-mindmap 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.
- package/CHANGELOG.md +29 -0
- package/README.md +66 -0
- package/README.zh-CN.md +66 -0
- package/client.js +1383 -0
- package/cordis.patch.yml +5 -0
- package/index.js +417 -0
- package/package.json +49 -0
package/cordis.patch.yml
ADDED
package/index.js
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
// dsh-mindmap —— host 半边:mindmap_* 文件工具。
|
|
2
|
+
//
|
|
3
|
+
// 设计(001 拍板决策 + 002/003 spike 结论):
|
|
4
|
+
// - 脑图 = 会话工作目录里的普通 .md 文件(决策 1);本模块只做纯文件操作,
|
|
5
|
+
// 不解析 markdown——解析在 client 半边(结果渲染文本同时进模型上下文,
|
|
6
|
+
// 带树会 double token;见 004 完成报告的架构说明)。
|
|
7
|
+
// - 根节点标题 = 文档名(决策 2):renameRoot 触发文件重命名,撞名报错不覆盖;
|
|
8
|
+
// 文件被外部改名时根标题由 client 从路径推导,天然跟随。
|
|
9
|
+
// - 四工具都带 path/name 参数(决策 3:多脑图并存,作用于指定那颗)。
|
|
10
|
+
// - 结果 JSON {ok, op, path, rootTitle, content, renamedFrom?}:content 全文
|
|
11
|
+
// 供模型续编辑,client 用同一份重放面板(工具结果即实时通道,002 第二节)。
|
|
12
|
+
// - requireApproval 配置(决策 6):默认 false 免审批;置 true 时 mindmap_update
|
|
13
|
+
// 走原生 ask(tools/pre-execute,照 dsh-grafana 的钩子模式)。配置经 bundle
|
|
14
|
+
// patch 的 config 覆盖传入 apply(ctx, config)。
|
|
15
|
+
// - 无任何 npm 依赖(不用 @deepseek-ai/dsh-tools:link 安装不解析 peer,
|
|
16
|
+
// 见 003 偏差 1),参数 schema 手写 JSON Schema。
|
|
17
|
+
import { access, opendir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
18
|
+
import { dirname, isAbsolute, join, relative, resolve as resolvePath } from 'node:path'
|
|
19
|
+
|
|
20
|
+
export const name = 'mindmap'
|
|
21
|
+
export const inject = ['tools', 'systemPrompt', 'webServer', 'sessions']
|
|
22
|
+
|
|
23
|
+
const MAX_CONTENT_BYTES = 2 * 1024 * 1024
|
|
24
|
+
const MAX_NAME_CHARS = 80
|
|
25
|
+
const TOOL_TIMEOUT_MS = 15_000
|
|
26
|
+
const MAX_TREE_ENTRIES = 500
|
|
27
|
+
const MAX_BODY_BYTES = 1 << 20
|
|
28
|
+
|
|
29
|
+
const GUIDANCE = `## Mindmap editing (dsh-mindmap)
|
|
30
|
+
|
|
31
|
+
A mindmap is a plain markdown file in the session working directory. The right-side panel renders it live; the filename (without .md) is the root node title. These files are ordinary documents: the user reviews and commits them with git themselves.
|
|
32
|
+
|
|
33
|
+
Tools:
|
|
34
|
+
- mindmap_create(name): create <name>.md in the working directory (fails if it exists) and show it in the panel.
|
|
35
|
+
- mindmap_open(path): open an existing .md as a mindmap in the panel.
|
|
36
|
+
- mindmap_get(path): read the current markdown content.
|
|
37
|
+
- mindmap_update(path, content, renameRoot?): write the FULL updated markdown. renameRoot renames the file to match a new root title (fails on name collision); use it only when the user asks to rename the root node.
|
|
38
|
+
|
|
39
|
+
Markdown mapping (the panel's parser): headings nest by level (H1 are root children, H2 under the previous H1, ...); list items are child nodes nested by 2-space indentation; an EMPTY list item ("- " followed by nothing) renders as a placeholder node — use placeholders for planned-but-unwritten nodes; a fenced code block becomes a leaf node titled "[lang] first line"; plain paragraphs become the note text of the nearest heading.
|
|
40
|
+
|
|
41
|
+
Behavior rules:
|
|
42
|
+
- Always mindmap_get before editing, then send the complete updated document to mindmap_update. One tool call per step so the panel follows along live.
|
|
43
|
+
- Never delete the whole document or restructure it without an explicit user request. Make the smallest change that answers the request.
|
|
44
|
+
- When the user steps away or pauses (e.g. "我去买咖啡"), stop all mindmap edits immediately and wait — never continue autonomously.
|
|
45
|
+
- Never run any git command for these files. The user commits themselves.
|
|
46
|
+
- Mindmap files stay inside the session working directory.`
|
|
47
|
+
|
|
48
|
+
function textOut(value) {
|
|
49
|
+
return [{ type: 'text', text: String(value) }]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 会话工作目录:工具执行的 agent → session → header.cwd(dsh-session 契约)。 */
|
|
53
|
+
function sessionCwd(exec) {
|
|
54
|
+
return exec?.agent?.session?.header?.cwd
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
//#region 013 目录树 API(host 自建只读 HTTP 路由;dsh-better-sidebar 同款机制)
|
|
58
|
+
/** 带 status/code 的错误:路由层据此回 JSON 信封。 */
|
|
59
|
+
function httpError(status, code, message) {
|
|
60
|
+
const error = new Error(message)
|
|
61
|
+
error.status = status
|
|
62
|
+
error.code = code
|
|
63
|
+
return error
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 同源/loopback fence:只服务本 web 页面发来的请求。
|
|
68
|
+
* - Host 头必须是 loopback 或与 Origin 同 host;
|
|
69
|
+
* - sec-fetch-site=cross-site 一律拒绝(better-sidebar 同款思路)。
|
|
70
|
+
*/
|
|
71
|
+
function isTrustedRequest(req) {
|
|
72
|
+
const host = String(req?.headers?.host ?? '')
|
|
73
|
+
if (!host) return false
|
|
74
|
+
const site = String(req?.headers?.['sec-fetch-site'] ?? '')
|
|
75
|
+
if (site === 'cross-site') return false
|
|
76
|
+
const origin = String(req?.headers?.origin ?? '')
|
|
77
|
+
if (!origin) {
|
|
78
|
+
const hostname = host.replace(/:\d+$/, '').replace(/^\[|\]$/g, '')
|
|
79
|
+
return hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '::1'
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
return new URL(origin).host === host
|
|
83
|
+
} catch {
|
|
84
|
+
return false
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 会话 id → 工作目录(与工具同源:sessions header.cwd)。 */
|
|
89
|
+
function sessionCwdOf(sessions, sessionId) {
|
|
90
|
+
const cwd = sessions?.get?.(sessionId)?.header?.cwd
|
|
91
|
+
return typeof cwd === 'string' && cwd ? cwd : null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 请求路径校验:缺省 = 根 cwd;显式路径必须绝对且落在 cwd 内。 */
|
|
95
|
+
function resolveTreePath(cwd, input) {
|
|
96
|
+
if (!cwd) throw httpError(400, 'no-cwd', 'session has no working directory')
|
|
97
|
+
if (input === undefined || input === null || String(input).trim() === '') return cwd
|
|
98
|
+
const p = String(input).trim()
|
|
99
|
+
if (!isAbsolute(p)) throw httpError(400, 'bad-request', `path must be absolute: ${JSON.stringify(p)}`)
|
|
100
|
+
const resolved = resolvePath(p)
|
|
101
|
+
const rel = relative(cwd, resolved)
|
|
102
|
+
if (rel.startsWith('..') || isAbsolute(rel)) {
|
|
103
|
+
throw httpError(400, 'bad-request', `path must stay inside the session working directory (${cwd})`)
|
|
104
|
+
}
|
|
105
|
+
return resolved
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 单层目录列表:目录优先排序、条目上限截断、隐藏标记。 */
|
|
109
|
+
async function listDirectoryLevel(path, maxEntries = MAX_TREE_ENTRIES) {
|
|
110
|
+
let dir
|
|
111
|
+
try {
|
|
112
|
+
dir = await opendir(path)
|
|
113
|
+
} catch (error) {
|
|
114
|
+
throw httpError(400, 'fs-error', `cannot list "${path}": ${error instanceof Error ? error.message : String(error)}`)
|
|
115
|
+
}
|
|
116
|
+
const rows = []
|
|
117
|
+
let overflow = 0
|
|
118
|
+
try {
|
|
119
|
+
for await (const dirent of dir) {
|
|
120
|
+
if (rows.length >= maxEntries) {
|
|
121
|
+
overflow += 1
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
rows.push({
|
|
125
|
+
name: dirent.name,
|
|
126
|
+
path: join(path, dirent.name),
|
|
127
|
+
isDir: dirent.isDirectory(),
|
|
128
|
+
hidden: dirent.name.startsWith('.'),
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
} catch (error) {
|
|
132
|
+
throw httpError(400, 'fs-error', `cannot list "${path}": ${error instanceof Error ? error.message : String(error)}`)
|
|
133
|
+
}
|
|
134
|
+
rows.sort((a, b) => (a.isDir === b.isDir ? a.name.localeCompare(b.name) : a.isDir ? -1 : 1))
|
|
135
|
+
return { path, entries: rows, truncated: overflow > 0 }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** 有界 JSON body 读取(better-sidebar 同款防御)。 */
|
|
139
|
+
async function readJsonBody(req) {
|
|
140
|
+
const chunks = []
|
|
141
|
+
let total = 0
|
|
142
|
+
for await (const chunk of req) {
|
|
143
|
+
const buffer = Buffer.from(chunk)
|
|
144
|
+
total += buffer.length
|
|
145
|
+
if (total > MAX_BODY_BYTES) throw httpError(400, 'bad-request', 'request body too large')
|
|
146
|
+
chunks.push(buffer)
|
|
147
|
+
}
|
|
148
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
149
|
+
if (text.trim() === '') return {}
|
|
150
|
+
try {
|
|
151
|
+
return JSON.parse(text)
|
|
152
|
+
} catch {
|
|
153
|
+
throw httpError(400, 'bad-request', 'request body is not valid JSON')
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** JSON 响应信封。 */
|
|
158
|
+
function sendJson(res, status, body) {
|
|
159
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
160
|
+
res.end(JSON.stringify(body))
|
|
161
|
+
}
|
|
162
|
+
//#endregion
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* 根标题 → 安全文件名主干:去 .md 后缀;拒绝路径分隔符、越界名与控制字符。
|
|
166
|
+
* @returns 干净的文件名主干。
|
|
167
|
+
*/
|
|
168
|
+
function sanitizeStem(input) {
|
|
169
|
+
const raw = String(input ?? '').trim()
|
|
170
|
+
const stem = raw.toLowerCase().endsWith('.md') ? raw.slice(0, -3).trim() : raw
|
|
171
|
+
if (!stem) throw new Error('mindmap name must not be empty.')
|
|
172
|
+
if (stem === '.' || stem === '..') throw new Error(`Invalid mindmap name ${JSON.stringify(raw)}.`)
|
|
173
|
+
if (/[\\/:*?"<>|]/.test(stem)) throw new Error(`Invalid mindmap name ${JSON.stringify(raw)}: path separators and :*?"<>| are not allowed.`)
|
|
174
|
+
// eslint-disable-next-line no-control-regex
|
|
175
|
+
if (/[\u0000-\u001f]/.test(stem)) throw new Error(`Invalid mindmap name: control characters are not allowed.`)
|
|
176
|
+
if ([...stem].length > MAX_NAME_CHARS) throw new Error(`mindmap name must not exceed ${MAX_NAME_CHARS} characters.`)
|
|
177
|
+
return stem
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* 解析脑图文件路径:相对路径以会话 cwd 为基;结果必须落在 cwd 内(决策 1),
|
|
182
|
+
* 且必须以 .md 结尾。cwd 缺失时仅接受绝对路径。
|
|
183
|
+
* @returns 绝对规范化路径。
|
|
184
|
+
*/
|
|
185
|
+
function resolveMindmapPath(cwd, input) {
|
|
186
|
+
if (typeof input !== 'string' || !input.trim()) throw new Error('path is required.')
|
|
187
|
+
const p = input.trim()
|
|
188
|
+
if (!/\.md$/i.test(p)) throw new Error(`mindmap path must end with .md: ${JSON.stringify(p)}.`)
|
|
189
|
+
if (!cwd) {
|
|
190
|
+
if (!isAbsolute(p)) throw new Error('The session has no working directory; pass an absolute .md path.')
|
|
191
|
+
return resolvePath(p)
|
|
192
|
+
}
|
|
193
|
+
const resolved = resolvePath(cwd, p)
|
|
194
|
+
const rel = relative(cwd, resolved)
|
|
195
|
+
if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) {
|
|
196
|
+
throw new Error(`mindmap path must stay inside the session working directory (${cwd}).`)
|
|
197
|
+
}
|
|
198
|
+
return resolved
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function pathExists(p) {
|
|
202
|
+
try {
|
|
203
|
+
await access(p)
|
|
204
|
+
return true
|
|
205
|
+
} catch {
|
|
206
|
+
return false
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function byteLength(value) {
|
|
211
|
+
return new TextEncoder().encode(value).byteLength
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** 工具结果信封:client 面板与模型共用的唯一载体。 */
|
|
215
|
+
function buildResult(op, path, extra = {}) {
|
|
216
|
+
const base = String(path ?? '').split(/[\\/]/).pop() || 'mindmap'
|
|
217
|
+
return JSON.stringify({ ok: true, op, path, rootTitle: base.replace(/\.md$/i, ''), ...extra })
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function defineTool(spec) {
|
|
221
|
+
// 内联 defineTool 的最小等价物(避免 peer 依赖;见 003 偏差 1):
|
|
222
|
+
// 参数已按手写 JSON Schema 声明,execute 自行校验必填与类型。
|
|
223
|
+
return spec
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function apply(ctx, config = {}) {
|
|
227
|
+
const requireApproval = config.requireApproval === true
|
|
228
|
+
ctx.systemPrompt.section({ name: 'tool:mindmap', order: 106, text: GUIDANCE })
|
|
229
|
+
|
|
230
|
+
if (requireApproval) {
|
|
231
|
+
// 后悔药开关(决策 6):默认免审批打断「人一句、AI 一步」的节奏;开启后
|
|
232
|
+
// mindmap_update(含 renameRoot)走原生用户审批,读操作不拦。
|
|
233
|
+
ctx.on('tools/pre-execute', async (exec, next) => {
|
|
234
|
+
const decision = await next()
|
|
235
|
+
if (decision.kind !== 'allow') return decision
|
|
236
|
+
if (exec.name !== 'mindmap_update') return decision
|
|
237
|
+
const args = exec.arguments ?? {}
|
|
238
|
+
const renameNote = typeof args.renameRoot === 'string' && args.renameRoot ? `, rename root to "${args.renameRoot}"` : ''
|
|
239
|
+
const bytes = typeof args.content === 'string' ? byteLength(args.content) : 0
|
|
240
|
+
return {
|
|
241
|
+
kind: 'ask',
|
|
242
|
+
reason: `Write mindmap ${JSON.stringify(String(args.path ?? '?'))} (${bytes} bytes${renameNote}). dsh-mindmap is configured with requireApproval.`,
|
|
243
|
+
}
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
ctx.tools.register(defineTool({
|
|
248
|
+
name: 'mindmap_create',
|
|
249
|
+
description: 'Create a new mindmap markdown file <name>.md in the session working directory and show it in the mindmap panel. Fails if the file already exists. The filename becomes the root node title.',
|
|
250
|
+
parameters: {
|
|
251
|
+
type: 'object',
|
|
252
|
+
properties: {
|
|
253
|
+
name: { type: 'string', description: 'Mindmap document name (without .md). Becomes the filename and the root node title.' },
|
|
254
|
+
},
|
|
255
|
+
required: ['name'],
|
|
256
|
+
},
|
|
257
|
+
output: { schema: { type: 'string' }, render: (_args, value) => textOut(value) },
|
|
258
|
+
timeoutMs: TOOL_TIMEOUT_MS,
|
|
259
|
+
async execute(args, exec) {
|
|
260
|
+
const cwd = sessionCwd(exec)
|
|
261
|
+
if (!cwd) throw new Error('The session has no working directory; cannot create a mindmap.')
|
|
262
|
+
const stem = sanitizeStem(args?.name)
|
|
263
|
+
const path = resolveMindmapPath(cwd, `${stem}.md`)
|
|
264
|
+
if (await pathExists(path)) throw new Error(`Mindmap already exists: ${JSON.stringify(path)}. Open it with mindmap_open instead.`)
|
|
265
|
+
await writeFile(path, '', 'utf8')
|
|
266
|
+
return buildResult('create', path, { content: '', created: true })
|
|
267
|
+
},
|
|
268
|
+
}))
|
|
269
|
+
|
|
270
|
+
ctx.tools.register(defineTool({
|
|
271
|
+
name: 'mindmap_open',
|
|
272
|
+
description: 'Open an existing .md file as a mindmap in the panel. The filename becomes the root node title. Use it when the user wants to view or continue an existing mindmap document.',
|
|
273
|
+
parameters: {
|
|
274
|
+
type: 'object',
|
|
275
|
+
properties: {
|
|
276
|
+
path: { type: 'string', description: 'Path to the .md file, relative to the session working directory or absolute.' },
|
|
277
|
+
},
|
|
278
|
+
required: ['path'],
|
|
279
|
+
},
|
|
280
|
+
output: { schema: { type: 'string' }, render: (_args, value) => textOut(value) },
|
|
281
|
+
timeoutMs: TOOL_TIMEOUT_MS,
|
|
282
|
+
async execute(args, exec) {
|
|
283
|
+
const path = resolveMindmapPath(sessionCwd(exec), args?.path)
|
|
284
|
+
const content = await readFile(path, 'utf8')
|
|
285
|
+
return buildResult('open', path, { content })
|
|
286
|
+
},
|
|
287
|
+
}))
|
|
288
|
+
|
|
289
|
+
ctx.tools.register(defineTool({
|
|
290
|
+
name: 'mindmap_get',
|
|
291
|
+
description: 'Read the current markdown content of a mindmap document. Always call it before editing so changes apply to the latest text.',
|
|
292
|
+
parameters: {
|
|
293
|
+
type: 'object',
|
|
294
|
+
properties: {
|
|
295
|
+
path: { type: 'string', description: 'Path to the .md file, relative to the session working directory or absolute.' },
|
|
296
|
+
},
|
|
297
|
+
required: ['path'],
|
|
298
|
+
},
|
|
299
|
+
output: { schema: { type: 'string' }, render: (_args, value) => textOut(value) },
|
|
300
|
+
timeoutMs: TOOL_TIMEOUT_MS,
|
|
301
|
+
async execute(args, exec) {
|
|
302
|
+
const path = resolveMindmapPath(sessionCwd(exec), args?.path)
|
|
303
|
+
const content = await readFile(path, 'utf8')
|
|
304
|
+
return buildResult('get', path, { content })
|
|
305
|
+
},
|
|
306
|
+
}))
|
|
307
|
+
|
|
308
|
+
ctx.tools.register(defineTool({
|
|
309
|
+
name: 'mindmap_update',
|
|
310
|
+
description: 'Write the FULL updated markdown of a mindmap document. Call mindmap_get first, then send the complete new content so the panel updates in one step. Optionally renameRoot to change the root title (renames the file; fails on name collision).',
|
|
311
|
+
parameters: {
|
|
312
|
+
type: 'object',
|
|
313
|
+
properties: {
|
|
314
|
+
path: { type: 'string', description: 'Path to the .md file, relative to the session working directory or absolute.' },
|
|
315
|
+
content: { type: 'string', description: 'The complete new markdown content of the document.' },
|
|
316
|
+
renameRoot: { type: 'string', description: 'Optional new root title: renames the file to <renameRoot>.md. Only when the user asks to rename the root node.' },
|
|
317
|
+
},
|
|
318
|
+
required: ['path'],
|
|
319
|
+
},
|
|
320
|
+
output: { schema: { type: 'string' }, render: (_args, value) => textOut(value) },
|
|
321
|
+
timeoutMs: TOOL_TIMEOUT_MS,
|
|
322
|
+
async execute(args, exec) {
|
|
323
|
+
const cwd = sessionCwd(exec)
|
|
324
|
+
const path = resolveMindmapPath(cwd, args?.path)
|
|
325
|
+
const hasContent = typeof args?.content === 'string'
|
|
326
|
+
if (!hasContent && typeof args?.renameRoot !== 'string') {
|
|
327
|
+
throw new Error('mindmap_update requires content (or renameRoot alone for a pure rename).')
|
|
328
|
+
}
|
|
329
|
+
if (hasContent && byteLength(args.content) > MAX_CONTENT_BYTES) {
|
|
330
|
+
throw new Error(`mindmap content exceeds the ${MAX_CONTENT_BYTES}-byte limit.`)
|
|
331
|
+
}
|
|
332
|
+
if (!(await pathExists(path))) throw new Error(`Mindmap not found: ${JSON.stringify(path)}. Create it with mindmap_create first.`)
|
|
333
|
+
|
|
334
|
+
let finalPath = path
|
|
335
|
+
let renamedFrom
|
|
336
|
+
if (typeof args?.renameRoot === 'string' && args.renameRoot.trim()) {
|
|
337
|
+
const stem = sanitizeStem(args.renameRoot)
|
|
338
|
+
// 重命名目标取原文件所在目录(path 已校验落在 cwd 内,其目录必然同域;
|
|
339
|
+
// cwd 缺失的绝对路径场景同样成立)。
|
|
340
|
+
const target = resolvePath(dirname(path), `${stem}.md`)
|
|
341
|
+
if (target !== path) {
|
|
342
|
+
if (await pathExists(target)) {
|
|
343
|
+
throw new Error(`Cannot rename root: ${JSON.stringify(target)} already exists. Pick another name.`)
|
|
344
|
+
}
|
|
345
|
+
await rename(path, target)
|
|
346
|
+
renamedFrom = path
|
|
347
|
+
finalPath = target
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (hasContent) await writeFile(finalPath, args.content, 'utf8')
|
|
351
|
+
const content = hasContent ? args.content : await readFile(finalPath, 'utf8')
|
|
352
|
+
return buildResult('update', finalPath, { content, ...(renamedFrom ? { renamedFrom } : {}) })
|
|
353
|
+
},
|
|
354
|
+
}))
|
|
355
|
+
|
|
356
|
+
// 013 目录树 tab:/mindmap/api/tree 只读路由(dsh-better-sidebar 同款机制——
|
|
357
|
+
// host 插件在 dsh webServer 上自建路由,客户端 fetch 拉会话工作目录的单层
|
|
358
|
+
// 列表;与 native/browse picker 互斥无关)。只有读路由,没有写路由:
|
|
359
|
+
// 客户端永不直接写文件(红线与 001 决策不动)。
|
|
360
|
+
ctx.effect(() => ctx.webServer.register({
|
|
361
|
+
kind: 'prefix',
|
|
362
|
+
path: '/mindmap/api',
|
|
363
|
+
handler: async (req, res) => {
|
|
364
|
+
if (!isTrustedRequest(req)) {
|
|
365
|
+
sendJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } })
|
|
366
|
+
return
|
|
367
|
+
}
|
|
368
|
+
if (req.method !== 'POST') {
|
|
369
|
+
sendJson(res, 405, { ok: false, error: { code: 'method-error', message: 'method not allowed' } })
|
|
370
|
+
return
|
|
371
|
+
}
|
|
372
|
+
try {
|
|
373
|
+
const method = new URL(req.url ?? '/', 'http://dsh.internal').pathname.slice('/mindmap/api/'.length)
|
|
374
|
+
if (method !== 'tree' || method.includes('/')) {
|
|
375
|
+
sendJson(res, 404, { ok: false, error: { code: 'not-found', message: `unknown mindmap API method ${JSON.stringify(method)}` } })
|
|
376
|
+
return
|
|
377
|
+
}
|
|
378
|
+
const payload = await readJsonBody(req)
|
|
379
|
+
const sessionId = payload.sessionId
|
|
380
|
+
if (typeof sessionId !== 'string' || !sessionId) {
|
|
381
|
+
sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'missing or invalid "sessionId"' } })
|
|
382
|
+
return
|
|
383
|
+
}
|
|
384
|
+
const cwd = sessionCwdOf(ctx.sessions, sessionId)
|
|
385
|
+
if (!cwd) {
|
|
386
|
+
sendJson(res, 400, { ok: false, error: { code: 'no-cwd', message: 'session has no working directory' } })
|
|
387
|
+
return
|
|
388
|
+
}
|
|
389
|
+
const dir = resolveTreePath(cwd, payload.path)
|
|
390
|
+
const listing = await listDirectoryLevel(dir)
|
|
391
|
+
sendJson(res, 200, { ok: true, value: { ...listing, cwd } })
|
|
392
|
+
} catch (error) {
|
|
393
|
+
const status = error && typeof error.status === 'number' ? error.status : 500
|
|
394
|
+
sendJson(res, status, {
|
|
395
|
+
ok: false,
|
|
396
|
+
error: {
|
|
397
|
+
code: error && typeof error.code === 'string' ? error.code : 'internal',
|
|
398
|
+
message: error instanceof Error ? error.message : String(error),
|
|
399
|
+
},
|
|
400
|
+
})
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
}), 'dsh-mindmap: /mindmap/api routes')
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export const internals = Object.freeze({
|
|
407
|
+
GUIDANCE,
|
|
408
|
+
MAX_CONTENT_BYTES,
|
|
409
|
+
sanitizeStem,
|
|
410
|
+
resolveMindmapPath,
|
|
411
|
+
sessionCwd,
|
|
412
|
+
sessionCwdOf,
|
|
413
|
+
resolveTreePath,
|
|
414
|
+
listDirectoryLevel,
|
|
415
|
+
isTrustedRequest,
|
|
416
|
+
buildResult,
|
|
417
|
+
})
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-mindmap",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Mindmap plugin for DeepSeek Harness: a plain markdown file in the working directory IS the mindmap; the chat edits it step by step and the right-side panel follows live.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"check": "node --check index.js && node --check client.js && node --check test/index.test.js && node --check test/client.test.js",
|
|
9
|
+
"test": "node --test",
|
|
10
|
+
"verify": "npm run check && npm test"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"index.js",
|
|
14
|
+
"client.js",
|
|
15
|
+
"cordis.patch.yml",
|
|
16
|
+
"README.md",
|
|
17
|
+
"README.zh-CN.md",
|
|
18
|
+
"CHANGELOG.md"
|
|
19
|
+
],
|
|
20
|
+
"dsh": {
|
|
21
|
+
"bundle": {
|
|
22
|
+
"patch": "./cordis.patch.yml"
|
|
23
|
+
},
|
|
24
|
+
"client": {
|
|
25
|
+
"inject": [
|
|
26
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
27
|
+
"@deepseek-ai/dsh-client-ui-layout"
|
|
28
|
+
],
|
|
29
|
+
"platform": "web",
|
|
30
|
+
"immediately": true
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=20.11"
|
|
35
|
+
},
|
|
36
|
+
"keywords": [
|
|
37
|
+
"deepseek",
|
|
38
|
+
"harness",
|
|
39
|
+
"dsh",
|
|
40
|
+
"dsh-plugin",
|
|
41
|
+
"mindmap",
|
|
42
|
+
"markdown"
|
|
43
|
+
],
|
|
44
|
+
"exports": {
|
|
45
|
+
".": "./index.js",
|
|
46
|
+
"./client": "./client.js",
|
|
47
|
+
"./package.json": "./package.json"
|
|
48
|
+
}
|
|
49
|
+
}
|