dsh-mindmap 0.3.0 → 0.5.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/index.js CHANGED
@@ -14,8 +14,8 @@
14
14
  // namespace 可在设置面板运行时切换(见 SETTINGS_NAMESPACE/Config)。
15
15
  // - 依赖:仅 @deepseek-ai/schemastery(settings schema;发布包正常解析,
16
16
  // link 开发需先 npm i)。工具参数 schema 仍手写 JSON Schema(003 偏差 1)。
17
- import { access, opendir, readFile, rename, writeFile } from 'node:fs/promises'
18
- import { dirname, isAbsolute, join, relative, resolve as resolvePath } from 'node:path'
17
+ import { access, opendir, readFile, rename, stat, writeFile } from 'node:fs/promises'
18
+ import { dirname, isAbsolute, join, relative, resolve as resolvePath, sep } from 'node:path'
19
19
  import Schema from '@deepseek-ai/schemastery'
20
20
 
21
21
  export const name = 'mindmap'
@@ -31,6 +31,7 @@ export const Config = Schema.object({
31
31
  lineStyle: Schema.union(['curve', 'elbow']).default('elbow').description('Connector line style between nodes: curve (bezier) or elbow (orthogonal).'),
32
32
  cardStyle: Schema.union(['rounded', 'square']).default('rounded').description('Node card corner style.'),
33
33
  colorTheme: Schema.union(['ocean', 'sunset', 'forest']).default('ocean').description('Node color theme.'),
34
+ growthAnimation: Schema.boolean().default(true).description('Progressive growth animation: newly added/changed nodes fade in one by one after each update (total capped at ~2s). Turn off for instant full render.'),
34
35
  })
35
36
 
36
37
  const MAX_CONTENT_BYTES = 2 * 1024 * 1024
@@ -53,7 +54,8 @@ Markdown mapping (the panel's parser): headings nest by level (H1 are root child
53
54
 
54
55
  Behavior rules:
55
56
  - When the user asks to create a mindmap, call mindmap_create. When the user asks to open, view, show, or switch to an existing mindmap, call mindmap_open (do not use mindmap_get alone). Both operations bring that document to the visible mindmap panel automatically.
56
- - 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.
57
+ - Always mindmap_get before editing, then send the complete updated document to mindmap_update. Every call must carry the FULL document, never a fragment.
58
+ - Update step by step: whenever the request involves several parts, call mindmap_update as soon as each part is ready — several small updates beat one giant update at the end. The panel plays a growth animation on newly added/changed nodes, so step-by-step updates make the tree visibly grow while you work. Do not call mindmap_update twice in a row with identical content.
57
59
  - Never delete the whole document or restructure it without an explicit user request. Make the smallest change that answers the request.
58
60
  - When the user steps away or pauses (e.g. "我去买咖啡"), stop all mindmap edits immediately and wait — never continue autonomously.
59
61
  - Never run any git command for these files. The user commits themselves.
@@ -105,6 +107,12 @@ function sessionCwdOf(sessions, sessionId) {
105
107
  return typeof cwd === 'string' && cwd ? cwd : null
106
108
  }
107
109
 
110
+ /** 相对路径是否越出 base(`..` 本身或以 `..` + 分隔符开头;不能只看 `..` 前缀——
111
+ * `..notes.md` 这类文件名会被误判)。 */
112
+ function escapesBase(rel) {
113
+ return rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)
114
+ }
115
+
108
116
  /** 请求路径校验:缺省 = 根 cwd;显式路径必须绝对且落在 cwd 内。 */
109
117
  function resolveTreePath(cwd, input) {
110
118
  if (!cwd) throw httpError(400, 'no-cwd', 'session has no working directory')
@@ -113,7 +121,7 @@ function resolveTreePath(cwd, input) {
113
121
  if (!isAbsolute(p)) throw httpError(400, 'bad-request', `path must be absolute: ${JSON.stringify(p)}`)
114
122
  const resolved = resolvePath(p)
115
123
  const rel = relative(cwd, resolved)
116
- if (rel.startsWith('..') || isAbsolute(rel)) {
124
+ if (escapesBase(rel)) {
117
125
  throw httpError(400, 'bad-request', `path must stay inside the session working directory (${cwd})`)
118
126
  }
119
127
  return resolved
@@ -206,7 +214,7 @@ function resolveMindmapPath(cwd, input) {
206
214
  }
207
215
  const resolved = resolvePath(cwd, p)
208
216
  const rel = relative(cwd, resolved)
209
- if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) {
217
+ if (rel === '' || escapesBase(rel)) {
210
218
  throw new Error(`mindmap path must stay inside the session working directory (${cwd}).`)
211
219
  }
212
220
  return resolved
@@ -221,6 +229,20 @@ async function pathExists(p) {
221
229
  }
222
230
  }
223
231
 
232
+ /**
233
+ * 两个路径是否指向同一个文件(dev + inode 比较)。大小写不敏感 FS(macOS/
234
+ * Windows)上仅大小写不同的路径命中同一文件——case-only 改名时据此区分
235
+ * 「目标就是自己」(放行)与「真有另一个同名文件」(碰撞报错)。
236
+ */
237
+ async function sameFile(a, b) {
238
+ try {
239
+ const [sa, sb] = await Promise.all([stat(a), stat(b)])
240
+ return sa.dev === sb.dev && sa.ino === sb.ino
241
+ } catch {
242
+ return false
243
+ }
244
+ }
245
+
224
246
  function byteLength(value) {
225
247
  return new TextEncoder().encode(value).byteLength
226
248
  }
@@ -238,7 +260,16 @@ function defineTool(spec) {
238
260
  }
239
261
 
240
262
  export function apply(ctx, config = {}) {
241
- const entryConfig = { requireApproval: config.requireApproval === true, defaultPanelWidth: 42 }
263
+ // 入口配置作为 settings 组合层的 base:视觉三件套与默认宽度在这里
264
+ // 透传(带 schema 同款默认值),用户设置层仍可在设置面板覆盖。
265
+ const entryConfig = {
266
+ requireApproval: config.requireApproval === true,
267
+ defaultPanelWidth: typeof config.defaultPanelWidth === 'number' ? config.defaultPanelWidth : 42,
268
+ lineStyle: config.lineStyle === 'curve' ? 'curve' : 'elbow',
269
+ cardStyle: config.cardStyle === 'square' ? 'square' : 'rounded',
270
+ colorTheme: config.colorTheme === 'sunset' || config.colorTheme === 'forest' ? config.colorTheme : 'ocean',
271
+ growthAnimation: config.growthAnimation !== false,
272
+ }
242
273
 
243
274
  // 015 设置面板:settings 服务可用时以命名空间解析值为准
244
275
  // (schema 默认 → 组合层 base → 用户设置层),否则回退入口配置
@@ -288,8 +319,15 @@ export function apply(ctx, config = {}) {
288
319
  if (!cwd) throw new Error('The session has no working directory; cannot create a mindmap.')
289
320
  const stem = sanitizeStem(args?.name)
290
321
  const path = resolveMindmapPath(cwd, `${stem}.md`)
291
- if (await pathExists(path)) throw new Error(`Mindmap already exists: ${JSON.stringify(path)}. Open it with mindmap_open instead.`)
292
- await writeFile(path, '', 'utf8')
322
+ // wx = 不存在才创建:原子拒绝已存在(含并发竞态)与同名目录,无 TOCTOU 窗口。
323
+ try {
324
+ await writeFile(path, '', { encoding: 'utf8', flag: 'wx' })
325
+ } catch (error) {
326
+ if (error && (error.code === 'EEXIST' || error.code === 'EISDIR')) {
327
+ throw new Error(`Mindmap already exists: ${JSON.stringify(path)}. Open it with mindmap_open instead.`)
328
+ }
329
+ throw error
330
+ }
293
331
  return buildResult('create', path, { content: '', created: true })
294
332
  },
295
333
  }))
@@ -334,7 +372,7 @@ export function apply(ctx, config = {}) {
334
372
 
335
373
  ctx.tools.register(defineTool({
336
374
  name: 'mindmap_update',
337
- 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).',
375
+ description: 'Write the FULL updated markdown of a mindmap document. Call mindmap_get first, then send the complete new content. When the edit has several parts, call once per finished part (always full content) so the panel grows the new nodes step by step. Optionally renameRoot to change the root title (renames the file; fails on name collision).',
338
376
  parameters: {
339
377
  type: 'object',
340
378
  properties: {
@@ -366,7 +404,9 @@ export function apply(ctx, config = {}) {
366
404
  // cwd 缺失的绝对路径场景同样成立)。
367
405
  const target = resolvePath(dirname(path), `${stem}.md`)
368
406
  if (target !== path) {
369
- if (await pathExists(target)) {
407
+ // case-only 改名(如 Plan → plan)在大小写不敏感 FS 上 pathExists(target)
408
+ // 命中的就是自己——用 sameFile 放行;真碰撞(不同文件)才报错。
409
+ if (await pathExists(target) && !(await sameFile(path, target))) {
370
410
  throw new Error(`Cannot rename root: ${JSON.stringify(target)} already exists. Pick another name.`)
371
411
  }
372
412
  await rename(path, target)
@@ -398,7 +438,7 @@ export function apply(ctx, config = {}) {
398
438
  }
399
439
  try {
400
440
  const method = new URL(req.url ?? '/', 'http://dsh.internal').pathname.slice('/mindmap/api/'.length)
401
- if (method !== 'tree' || method.includes('/')) {
441
+ if (method !== 'tree') {
402
442
  sendJson(res, 404, { ok: false, error: { code: 'not-found', message: `unknown mindmap API method ${JSON.stringify(method)}` } })
403
443
  return
404
444
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mindmap",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
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
5
  "repository": {
6
6
  "type": "git",
@@ -13,6 +13,8 @@
13
13
  "publishConfig": {
14
14
  "access": "public"
15
15
  },
16
+ "author": "guhanfei-ai",
17
+ "license": "MIT",
16
18
  "type": "module",
17
19
  "main": "index.js",
18
20
  "scripts": {