dsh-claude-move 0.2.1

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,619 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // lib/discovery.mjs — Claude Code 数据自动发现(F1-F4,零 DSH 依赖)。
3
+ //
4
+ // 定位 Claude 数据根目录($CLAUDE_CONFIG_DIR / ~/.claude),流式扫描
5
+ // projects/<slug>/**/*.jsonl 的头部元数据与消息/工具计数,汇总为按最近活动
6
+ // 排序的项目索引(目录存在性、git 分支与脏状态),并发现个人上下文
7
+ // (全局 CLAUDE.md、skills、每项目 memory、settings.json)。增量缓存按文件
8
+ // mtime+size 复用上次扫描结果,重复扫描只重读变化文件。
9
+ //
10
+ // 源文件一律只读;本模块绝不写入 Claude 数据目录。缓存只写本插件自己的
11
+ // 缓存目录(resolveCacheDir)。
12
+ //
13
+ // 发现约定沿用 Demogorgon314/dsh-resume-plugin(MIT,见 THIRD_PARTY_NOTICES.md):
14
+ // projects/<slug>/<sessionId>.jsonl、custom-title→customTitle、ai-title→aiTitle、
15
+ // gitBranch 字段。
16
+
17
+ import { createReadStream, existsSync, readFileSync } from 'node:fs'
18
+ import { readdir, readFile, stat, mkdir, writeFile, rename, unlink } from 'node:fs/promises'
19
+ import { execFile } from 'node:child_process'
20
+ import { promisify } from 'node:util'
21
+ import { homedir } from 'node:os'
22
+ import path from 'node:path'
23
+ import { createInterface } from 'node:readline'
24
+ import { parseFrontmatter, extractMetadataType } from './frontmatter.mjs'
25
+
26
+ const execFileAsync = promisify(execFile)
27
+
28
+ /** 索引结构版本;结构变化时 bump(旧缓存直接弃用重扫)。 */
29
+ export const INDEX_VERSION = 1
30
+
31
+ /** 单个 transcript 超过该字节数时标记 oversized(仍流式扫描,不整读内存)。 */
32
+ export const DEFAULT_MAX_TRANSCRIPT_BYTES = 64 * 1024 * 1024
33
+
34
+ /** 标题兜底用首条用户提问的截断长度。 */
35
+ export const TITLE_FALLBACK_MAX = 120
36
+
37
+ /** git 子进程超时(毫秒),可用插件配置 gitTimeoutMs 覆盖。 */
38
+ export const DEFAULT_GIT_TIMEOUT_MS = 5000
39
+
40
+ /** 全量扫描的项目并发上限(C1),可用插件配置 scanConcurrency 覆盖。 */
41
+ export const DEFAULT_SCAN_CONCURRENCY = 8
42
+
43
+ /**
44
+ * 定位 Claude 数据根目录(F1)。
45
+ * @param env - 环境对象,缺省 process.env。
46
+ * @param home - 用户主目录,缺省 os.homedir()。
47
+ * @returns `$CLAUDE_CONFIG_DIR`(支持 `~` 前缀)或 `~/.claude` 的绝对路径。
48
+ */
49
+ export function locateClaudeHome(env = process.env, home = homedir()) {
50
+ const raw = env.CLAUDE_CONFIG_DIR
51
+ if (typeof raw === 'string' && raw.trim().length > 0) {
52
+ const expanded = raw.startsWith('~')
53
+ ? path.join(home, raw.slice(1).replace(/^[\\/]+/, ''))
54
+ : raw
55
+ return path.resolve(expanded)
56
+ }
57
+ return path.join(home, '.claude')
58
+ }
59
+
60
+ /**
61
+ * 解析 ISO 时间戳;无效输入返回 null(调用方回退到文件 mtime)。
62
+ * @param iso - ISO-8601 字符串。
63
+ * @returns Unix epoch 毫秒或 null。
64
+ */
65
+ export function parseTimestamp(iso) {
66
+ if (typeof iso !== 'string') return null
67
+ const n = Date.parse(iso)
68
+ return Number.isFinite(n) ? n : null
69
+ }
70
+
71
+ /**
72
+ * 标题归一化:单行、截断。
73
+ * @param text - 原始标题。
74
+ * @param limit - 最大长度(码点)。
75
+ * @returns 归一化标题。
76
+ */
77
+ export function normalizeTitle(text, limit = TITLE_FALLBACK_MAX) {
78
+ const oneLine = String(text ?? '').replace(/\s+/g, ' ').trim()
79
+ return oneLine.slice(0, limit)
80
+ }
81
+
82
+ /**
83
+ * 读取一个技能文件并解析 frontmatter。README/MEMORY 等非技能文档与缺失
84
+ * name/description 的文件一律跳过(与 skills-provider 一致,issue#1)。
85
+ * @param file - SKILL.md 或扁平 .md 的绝对路径。
86
+ * @returns 技能条目,或 null(不存在/空体/不可读/非技能文档)。
87
+ */
88
+ export async function readSkillFile(file) {
89
+ try {
90
+ const content = await readFile(file, 'utf8')
91
+ const { meta, body } = parseFrontmatter(content)
92
+ if (body.trim().length === 0) return null
93
+ const name = (meta.name ?? path.basename(file).replace(/\.md$/, '')).trim()
94
+ const description = (meta.description ?? '').trim()
95
+ if (name.length === 0 || description.length === 0) return null
96
+ const level = meta.level ? Number.parseInt(meta.level, 10) : undefined
97
+ return {
98
+ name,
99
+ description,
100
+ ...(Number.isFinite(level) ? { level } : {}),
101
+ ...(meta['argument-hint'] ? { argumentHint: meta['argument-hint'] } : {}),
102
+ path: file,
103
+ }
104
+ } catch {
105
+ return null
106
+ }
107
+ }
108
+
109
+ /**
110
+ * 扫描一个技能根目录:`<root>/<name>/SKILL.md` 目录束与 `<root>/<name>.md`
111
+ * 扁平文件两种形态(Claude Code 布局);显式排除 README.md/MEMORY.md。
112
+ * @param skillsDir - 技能根目录。
113
+ * @returns 按名称排序的技能条目;目录不存在返回空数组。
114
+ */
115
+ export async function scanSkills(skillsDir) {
116
+ let entries
117
+ try {
118
+ entries = await readdir(skillsDir, { withFileTypes: true })
119
+ } catch {
120
+ return []
121
+ }
122
+ const NON_SKILL_FILES = /^(memory|readme)\.md$/i
123
+ const skills = []
124
+ for (const entry of entries) {
125
+ if (entry.isDirectory()) {
126
+ const skill = await readSkillFile(path.join(skillsDir, entry.name, 'SKILL.md'))
127
+ if (skill) skills.push(skill)
128
+ } else if (entry.isFile() && entry.name.endsWith('.md') && !NON_SKILL_FILES.test(entry.name)) {
129
+ const skill = await readSkillFile(path.join(skillsDir, entry.name))
130
+ if (skill) skills.push(skill)
131
+ }
132
+ }
133
+ return skills.sort((a, b) => a.name.localeCompare(b.name))
134
+ }
135
+
136
+ /**
137
+ * 读取一个 memory 条目(名称/类型/路径,不载入正文——注入阶段再读)。
138
+ * @param file - memory/*.md 的绝对路径。
139
+ * @returns 条目,或 null(不可读/空体)。
140
+ */
141
+ export async function readMemoryEntry(file) {
142
+ try {
143
+ const content = await readFile(file, 'utf8')
144
+ const { meta, body } = parseFrontmatter(content)
145
+ if (body.trim().length === 0) return null
146
+ return {
147
+ name: meta.name || path.basename(file).replace(/\.md$/, ''),
148
+ type: extractMetadataType(meta),
149
+ path: file,
150
+ }
151
+ } catch {
152
+ return null
153
+ }
154
+ }
155
+
156
+ /**
157
+ * 探测一个文件的存在与大小;不存在返回 null。
158
+ * @param file - 绝对路径。
159
+ * @returns `{ path, sizeBytes }` 或 null。
160
+ */
161
+ export async function maybeFile(file) {
162
+ try {
163
+ const s = await stat(file)
164
+ if (s.isFile()) return { path: file, sizeBytes: s.size }
165
+ } catch {
166
+ // ENOENT/不可读:返回 null,由调用方表达「不存在」。
167
+ }
168
+ return null
169
+ }
170
+
171
+ /**
172
+ * git 仓库状态(分支 + 未提交变更行数)。目录没有 .git 标记时不启动 git。
173
+ * `knownBranch`(transcript 自带的 gitBranch 字段,C2)已给出分支时跳过
174
+ * `rev-parse`,只跑一次 `status --porcelain` 算脏行。
175
+ * @param cwd - 项目目录。
176
+ * @param exec - 可注入的子进程执行器(测试用)。
177
+ * @param timeoutMs - 子进程超时(默认 DEFAULT_GIT_TIMEOUT_MS)。
178
+ * @param knownBranch - 已知分支名(可选)。
179
+ * @returns `{ isRepo: true, branch, dirtyCount }` 或 null(非 git 仓库)。
180
+ */
181
+ export async function gitStatus(cwd, { exec = execFileAsync, timeoutMs = DEFAULT_GIT_TIMEOUT_MS, knownBranch } = {}) {
182
+ if (!existsSync(path.join(cwd, '.git'))) return null
183
+ try {
184
+ let branch = typeof knownBranch === 'string' && knownBranch.length > 0 ? knownBranch : null
185
+ if (branch === null) {
186
+ const branchResult = await exec('git', ['-C', cwd, 'rev-parse', '--abbrev-ref', 'HEAD'], {
187
+ timeout: timeoutMs, windowsHide: true, maxBuffer: 1 << 20,
188
+ })
189
+ branch = branchResult.stdout.trim() || null
190
+ }
191
+ const dirtyResult = await exec('git', ['-C', cwd, 'status', '--porcelain', '--untracked-files=no'], {
192
+ timeout: timeoutMs, windowsHide: true, maxBuffer: 1 << 20,
193
+ })
194
+ const dirtyCount = dirtyResult.stdout.split('\n').filter((l) => l.trim().length > 0).length
195
+ return { isRepo: true, branch, dirtyCount }
196
+ } catch {
197
+ // git 不可用/超时/非仓库:isRepo 但状态未知。
198
+ return { isRepo: true, branch: typeof knownBranch === 'string' ? knownBranch : null, dirtyCount: null }
199
+ }
200
+ }
201
+
202
+ /**
203
+ * 流式扫描单个 transcript(F2.b):逐行解析头部元数据与计数,不整读内存。
204
+ * 未知字段宽容跳过;畸形行计数。
205
+ * @param file - transcript 绝对路径。
206
+ * @param maxBytes - oversized 判定阈值。
207
+ * @param signal - 可选 AbortSignal;中止时抛出 signal.reason。
208
+ * @returns 会话头记录;文件不可读时返回 `{ file, error }`。
209
+ */
210
+ export async function scanTranscriptFile(file, { maxBytes = DEFAULT_MAX_TRANSCRIPT_BYTES, signal } = {}) {
211
+ signal?.throwIfAborted()
212
+ let statResult
213
+ try {
214
+ statResult = await stat(file)
215
+ } catch (err) {
216
+ if (signal?.aborted) throw signal.reason ?? err
217
+ return { file, error: String((err && err.message) || err) }
218
+ }
219
+ const sizeBytes = statResult.size
220
+
221
+ const head = {
222
+ file,
223
+ sizeBytes,
224
+ mtimeMs: statResult.mtimeMs,
225
+ ctimeMs: statResult.ctimeMs,
226
+ oversized: sizeBytes > maxBytes,
227
+ messages: 0,
228
+ toolCalls: 0,
229
+ turns: 0,
230
+ malformed: 0,
231
+ typeCounts: {},
232
+ }
233
+ let sessionId = null
234
+ let cwd = null
235
+ let model = null
236
+ let gitBranch = null
237
+ let aiTitle = null
238
+ let customTitle = null
239
+ let firstPrompt = null
240
+ let firstTime = null
241
+ let lastTime = null
242
+
243
+ const stream = createReadStream(file, { encoding: 'utf8', ...(signal ? { signal } : {}) })
244
+ const rl = createInterface({ input: stream, crlfDelay: Infinity })
245
+ for await (const line of rl) {
246
+ signal?.throwIfAborted()
247
+ const trimmed = line.trim()
248
+ if (!trimmed) continue
249
+ let rec = null
250
+ try {
251
+ rec = JSON.parse(trimmed)
252
+ } catch {
253
+ head.malformed++
254
+ continue
255
+ }
256
+ if (rec === null || typeof rec !== 'object' || Array.isArray(rec)) {
257
+ head.malformed++
258
+ continue
259
+ }
260
+
261
+ if (typeof rec.sessionId === 'string' && sessionId === null) sessionId = rec.sessionId
262
+ if (typeof rec.cwd === 'string' && cwd === null) cwd = rec.cwd
263
+ if (typeof rec.message?.model === 'string' && model === null) model = rec.message.model
264
+ if (typeof rec.gitBranch === 'string' && gitBranch === null) gitBranch = rec.gitBranch
265
+ const ts = parseTimestamp(rec.timestamp)
266
+ if (ts !== null) {
267
+ if (firstTime === null || ts < firstTime) firstTime = ts
268
+ if (lastTime === null || ts > lastTime) lastTime = ts
269
+ }
270
+
271
+ const type = typeof rec.type === 'string' ? rec.type : 'unknown'
272
+ head.typeCounts[type] = (head.typeCounts[type] ?? 0) + 1
273
+
274
+ if (type === 'custom-title' && customTitle === null && typeof rec.customTitle === 'string') {
275
+ customTitle = rec.customTitle
276
+ } else if (type === 'ai-title' && aiTitle === null && typeof rec.aiTitle === 'string') {
277
+ aiTitle = rec.aiTitle
278
+ }
279
+ if (type === 'user') {
280
+ head.messages++
281
+ if (typeof rec.message?.content === 'string') head.turns++
282
+ if (firstPrompt === null && typeof rec.message?.content === 'string') {
283
+ firstPrompt = rec.message.content
284
+ }
285
+ } else if (type === 'assistant') {
286
+ head.messages++
287
+ const blocks = Array.isArray(rec.message?.content) ? rec.message.content : []
288
+ head.toolCalls += blocks.filter((b) => b && b.type === 'tool_use').length
289
+ }
290
+ }
291
+ stream.destroy()
292
+
293
+ // 标题优先级:custom-title > ai-title > 首条用户提问(与 resume-plugin 一致)。
294
+ const titleFallback = normalizeTitle(customTitle ?? aiTitle ?? firstPrompt)
295
+ return {
296
+ ...head,
297
+ file,
298
+ sessionId: sessionId ?? path.basename(file).replace(/\.jsonl$/i, ''),
299
+ ...(cwd !== null ? { cwd } : {}),
300
+ ...(model !== null ? { model } : {}),
301
+ ...(gitBranch !== null ? { gitBranch } : {}),
302
+ ...(titleFallback.length > 0 ? { title: titleFallback } : {}),
303
+ createdAt: firstTime,
304
+ lastActivity: lastTime,
305
+ }
306
+ }
307
+
308
+ /**
309
+ * 扫描一个项目目录:全部 transcript + memory + 项目级 .claude 上下文 + git。
310
+ * @param projectDir - `projects/<slug>` 绝对路径。
311
+ * @param opts - `scanFile`/`gitExec`/`scanGit`/`maxBytes`/`gitTimeoutMs`/`signal`。
312
+ * @returns 项目索引条目。
313
+ */
314
+ export async function scanProjectDir(projectDir, opts = {}) {
315
+ const slug = path.basename(projectDir)
316
+ const scanFile = opts.scanFile ?? scanTranscriptFile
317
+ const maxBytes = opts.maxBytes ?? DEFAULT_MAX_TRANSCRIPT_BYTES
318
+ const signal = opts.signal
319
+
320
+ let entries
321
+ try {
322
+ signal?.throwIfAborted()
323
+ entries = await readdir(projectDir, { recursive: true, withFileTypes: true })
324
+ } catch (err) {
325
+ if (signal?.aborted) throw signal.reason ?? err
326
+ return { slug, dir: projectDir, error: String((err && err.message) || err), sessions: [] }
327
+ }
328
+
329
+ const files = entries
330
+ .filter((e) => e.isFile() && /\.jsonl$/i.test(e.name))
331
+ .map((e) => path.join(e.parentPath ?? projectDir, e.name))
332
+ .sort()
333
+
334
+ const sessions = []
335
+ for (const file of files) {
336
+ signal?.throwIfAborted()
337
+ sessions.push(await scanFile(file, { maxBytes, signal }))
338
+ }
339
+ sessions.sort((a, b) => (b.lastActivity ?? 0) - (a.lastActivity ?? 0))
340
+
341
+ const cwd = sessions.find((s) => s.cwd && typeof s.cwd === 'string')?.cwd ?? null
342
+ const dirExists = cwd !== null && existsSync(cwd)
343
+ const transcriptBranch = sessions.find((s) => typeof s.gitBranch === 'string')?.gitBranch ?? null
344
+
345
+ // git 三级开关(C2):false 关;'branch' 只用 transcript 自带 gitBranch 字段
346
+ // (零 git 子进程);true(默认)复用它跳过 rev-parse,只跑 status --porcelain。
347
+ let gitProbe = null
348
+ if (opts.scanGit !== false && dirExists) {
349
+ if (opts.scanGit === 'branch') {
350
+ const hasRepo = transcriptBranch !== null || existsSync(path.join(cwd, '.git'))
351
+ if (hasRepo) gitProbe = { isRepo: true, branch: transcriptBranch, dirtyCount: null }
352
+ } else {
353
+ gitProbe = gitStatus(cwd, {
354
+ exec: opts.gitExec ?? execFileAsync,
355
+ timeoutMs: opts.gitTimeoutMs ?? DEFAULT_GIT_TIMEOUT_MS,
356
+ ...(transcriptBranch !== null ? { knownBranch: transcriptBranch } : {}),
357
+ })
358
+ }
359
+ }
360
+
361
+ const memories = []
362
+ try {
363
+ signal?.throwIfAborted()
364
+ const memoryDir = path.join(projectDir, 'memory')
365
+ const memEntries = await readdir(memoryDir, { withFileTypes: true })
366
+ for (const entry of memEntries) {
367
+ if (!entry.isFile() || !entry.name.endsWith('.md') || entry.name === 'MEMORY.md') continue
368
+ const mem = await readMemoryEntry(path.join(memoryDir, entry.name))
369
+ if (mem) memories.push(mem)
370
+ }
371
+ } catch {
372
+ // 无 memory 目录:留空。
373
+ }
374
+ memories.sort((a, b) => a.name.localeCompare(b.name))
375
+
376
+ const projectClaudeMd = dirExists ? await maybeFile(path.join(cwd, '.claude', 'CLAUDE.md')) : null
377
+ const projectSettings = dirExists ? await maybeFile(path.join(cwd, '.claude', 'settings.json')) : null
378
+
379
+ return {
380
+ slug,
381
+ dir: projectDir,
382
+ ...(cwd !== null ? { cwd } : {}),
383
+ dirExists,
384
+ ...(gitProbe ? { git: await gitProbe } : {}),
385
+ sessions,
386
+ memories,
387
+ ...(projectClaudeMd ? { projectClaudeMd } : {}),
388
+ ...(projectSettings ? { projectSettings } : {}),
389
+ }
390
+ }
391
+
392
+ /**
393
+ * 扫描个人上下文(F2.c):全局 CLAUDE.md、settings.json、skills 目录。
394
+ * @param claudeHome - Claude 数据根目录。
395
+ * @returns personal 索引段。
396
+ */
397
+ export async function scanPersonal(claudeHome) {
398
+ return {
399
+ globalClaudeMd: await maybeFile(path.join(claudeHome, 'CLAUDE.md')),
400
+ settings: await maybeFile(path.join(claudeHome, 'settings.json')),
401
+ skills: await scanSkills(path.join(claudeHome, 'skills')),
402
+ }
403
+ }
404
+
405
+ /**
406
+ * 全量/增量扫描 Claude 数据根目录(F2/F4)。
407
+ * @param claudeHome - Claude 数据根目录。
408
+ * @param opts - `cache`(上次 {version, files})、`scanFile`、`gitExec`、
409
+ * `scanGit`(true | 'branch' | false)、`maxBytes`、`excludeProjects`(slug
410
+ * 或子串数组)、`gitTimeoutMs`、`concurrency`(项目并发上限,C1)、`signal`。
411
+ * @returns `{ index, files }`:files 为本次书签(mtime+size+会话头),供下次增量。
412
+ */
413
+ export async function scanClaudeHome(claudeHome, opts = {}) {
414
+ const cached = opts.cache && opts.cache.version === INDEX_VERSION ? opts.cache.files ?? {} : {}
415
+ const exclude = opts.excludeProjects ?? []
416
+ const signal = opts.signal
417
+ const excluded = (slug) => exclude.some((x) => typeof x === 'string' && slug.includes(x))
418
+ signal?.throwIfAborted()
419
+
420
+ // 增量复用(F4):mtime+size 未变的文件直接复用缓存会话头,不重读内容。
421
+ const baseScanFile = opts.scanFile ?? scanTranscriptFile
422
+ const scanFileCached = async (file, scanOpts) => {
423
+ let st
424
+ try {
425
+ st = await stat(file)
426
+ } catch (err) {
427
+ if (opts.signal?.aborted) throw opts.signal.reason ?? err
428
+ return { file, error: String((err && err.message) || err) }
429
+ }
430
+ const prev = cached[file]
431
+ if (prev && !prev.error && prev.mtimeMs === st.mtimeMs && prev.ctimeMs === st.ctimeMs && prev.sizeBytes === st.size) {
432
+ return prev
433
+ }
434
+ return baseScanFile(file, scanOpts)
435
+ }
436
+
437
+ const projectsDir = path.join(claudeHome, 'projects')
438
+ let slugs = []
439
+ try {
440
+ signal?.throwIfAborted()
441
+ slugs = (await readdir(projectsDir, { withFileTypes: true }))
442
+ .filter((e) => e.isDirectory())
443
+ .map((e) => e.name)
444
+ .sort()
445
+ } catch (err) {
446
+ if (signal?.aborted) throw signal.reason ?? err
447
+ // 无 projects 目录:索引为空项目列表。
448
+ }
449
+
450
+ const files = {}
451
+ const targets = []
452
+ for (const slug of slugs) {
453
+ if (excluded(slug)) continue
454
+ signal?.throwIfAborted()
455
+ targets.push(slug)
456
+ }
457
+
458
+ // 并行项目扫描(C1):并发上限 scanConcurrency(默认 8),书签/索引按
459
+ // target 顺序写入槽位,输出确定性不变;中止时任一 worker 抛出 signal.reason。
460
+ const concurrency = Number.isInteger(opts.concurrency) && opts.concurrency > 0
461
+ ? opts.concurrency
462
+ : DEFAULT_SCAN_CONCURRENCY
463
+ const projects = new Array(targets.length)
464
+ let cursor = 0
465
+ const workers = []
466
+ for (let w = 0; w < Math.min(concurrency, targets.length); w++) {
467
+ workers.push((async () => {
468
+ for (;;) {
469
+ const i = cursor++
470
+ if (i >= targets.length) return
471
+ signal?.throwIfAborted()
472
+ const projectDir = path.join(projectsDir, targets[i])
473
+ let project
474
+ try {
475
+ project = await scanProjectDir(projectDir, { ...opts, scanFile: scanFileCached })
476
+ } catch (err) {
477
+ if (signal?.aborted) throw signal.reason ?? err
478
+ project = { slug: targets[i], dir: projectDir, error: String((err && err.message) || err), sessions: [] }
479
+ }
480
+ // 增量书签:仅当会话头是本次扫描产物时记录(跳过 error 会话)。
481
+ for (const session of project.sessions) {
482
+ if (!session.error) files[session.file] = session
483
+ }
484
+ projects[i] = project
485
+ }
486
+ })())
487
+ }
488
+ await Promise.all(workers)
489
+ projects.sort((a, b) => latestOf(b) - latestOf(a))
490
+
491
+ // 删除文件书签报告(C5):上次缓存里有、本次未再出现的源文件计数。
492
+ const cachedKeys = Object.keys(cached)
493
+ const removedCount = cachedKeys.length > 0
494
+ ? cachedKeys.filter((f) => !(f in files)).length
495
+ : 0
496
+
497
+ const personal = await scanPersonal(claudeHome)
498
+ return {
499
+ index: {
500
+ version: INDEX_VERSION,
501
+ claudeHome,
502
+ scannedAt: new Date().toISOString(),
503
+ projects,
504
+ personal,
505
+ ...(removedCount > 0 ? { removedBookmarks: removedCount } : {}),
506
+ },
507
+ files,
508
+ }
509
+ }
510
+
511
+ function latestOf(project) {
512
+ for (const session of project.sessions) {
513
+ if (typeof session.lastActivity === 'number') return session.lastActivity
514
+ }
515
+ return 0
516
+ }
517
+
518
+ // ── 增量缓存 ────────────────────────────────────────────────────────────────
519
+
520
+ /**
521
+ * 本插件缓存目录:`$DSH_HOME/claude-move`,缺省 `~/.dsh/claude-move`。
522
+ * @param env - 环境对象,缺省 process.env。
523
+ * @returns 缓存目录绝对路径。
524
+ */
525
+ export function resolveCacheDir(env = process.env) {
526
+ const base = env.DSH_HOME || path.join(homedir(), '.dsh')
527
+ return path.join(base, 'claude-move')
528
+ }
529
+
530
+ /**
531
+ * 读取索引缓存;缺失/损坏返回 null(下次全量)。
532
+ * @param cacheDir - resolveCacheDir 的结果。
533
+ * @returns `{ version, claudeHome, files }` 或 null。
534
+ */
535
+ export async function loadCache(cacheDir) {
536
+ try {
537
+ const parsed = JSON.parse(await readFile(path.join(cacheDir, 'index.json'), 'utf8'))
538
+ if (parsed && typeof parsed === 'object' && Number.isInteger(parsed.version)) return parsed
539
+ } catch {
540
+ // 缺失或损坏:null 表示无缓存。
541
+ }
542
+ return null
543
+ }
544
+
545
+ /**
546
+ * 原子写一个 JSON 文件:先写 `<file>.tmp` 再 rename 覆盖(同卷原子替换)。
547
+ * 并发读方要么看到完整旧文件、要么看到完整新文件,绝不读到半截 JSON。
548
+ * @param file - 目标绝对路径。
549
+ * @param value - JSON 可序列化值。
550
+ */
551
+ async function writeJsonAtomic(file, value) {
552
+ await mkdir(path.dirname(file), { recursive: true })
553
+ const tmp = file + '.tmp'
554
+ await writeFile(tmp, JSON.stringify(value), 'utf8')
555
+ await rename(tmp, file)
556
+ }
557
+
558
+ /**
559
+ * 写索引缓存。
560
+ * @param cacheDir - resolveCacheDir 的结果。
561
+ * @param cache - `{ version, claudeHome, files }`。
562
+ */
563
+ export async function saveCache(cacheDir, cache) {
564
+ await writeJsonAtomic(path.join(cacheDir, 'index.json'), cache)
565
+ }
566
+
567
+ /**
568
+ * 读取源 sessionId → DSH 会话 id 的导入映射(导入阶段写入)。
569
+ * @param cacheDir - resolveCacheDir 的结果。
570
+ * @returns 映射对象,缺失返回空对象。
571
+ */
572
+ export async function loadImports(cacheDir) {
573
+ try {
574
+ const parsed = JSON.parse(await readFile(path.join(cacheDir, 'imports.json'), 'utf8'))
575
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed
576
+ } catch {
577
+ // 缺失或损坏:空映射。
578
+ }
579
+ return {}
580
+ }
581
+
582
+ /**
583
+ * loadImports 的同步变体(供 systemPrompt 同步提供者在提示词组装期读取)。
584
+ * @param cacheDir - resolveCacheDir 的结果。
585
+ * @returns 映射对象,缺失/损坏返回空对象。
586
+ */
587
+ export function loadImportsSync(cacheDir) {
588
+ try {
589
+ const parsed = JSON.parse(readFileSync(path.join(cacheDir, 'imports.json'), 'utf8'))
590
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed
591
+ } catch {
592
+ // 缺失或损坏:空映射。
593
+ }
594
+ return {}
595
+ }
596
+
597
+ /**
598
+ * 写导入映射。
599
+ * @param cacheDir - resolveCacheDir 的结果。
600
+ * @param imports - 源 sessionId → DSH 会话 id。
601
+ */
602
+ export async function saveImports(cacheDir, imports) {
603
+ await writeJsonAtomic(path.join(cacheDir, 'imports.json'), imports)
604
+ }
605
+
606
+ /**
607
+ * 重置本插件缓存(D5):只删除 index.json/imports.json 两个数据文件及其
608
+ * 临时文件,绝不递归删除目录。已导入的 DSH 会话数据不受影响。
609
+ * @param cacheDir - resolveCacheDir 的结果。
610
+ */
611
+ export async function resetCacheFiles(cacheDir) {
612
+ for (const name of ['index.json', 'index.json.tmp', 'imports.json', 'imports.json.tmp']) {
613
+ try {
614
+ await unlink(path.join(cacheDir, name))
615
+ } catch {
616
+ // 文件不存在即视为已清。
617
+ }
618
+ }
619
+ }
@@ -0,0 +1,58 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // lib/frontmatter.mjs — Claude Code Markdown frontmatter 解析。
3
+ //
4
+ // Parser shape vendored from YYTbit/dsh-plugin-claude-bridge (MIT, see
5
+ // THIRD_PARTY_NOTICES.md): key: value top-level fields plus indented
6
+ // continuation lines; `metadata:\n type: …` flattened into `metadata` text.
7
+
8
+ /**
9
+ * 解析 `---\n…\n---` 形式的 YAML frontmatter。
10
+ * 标量值去掉首尾空白与成对的单/双引号(不实现完整 YAML,够技能/memory
11
+ * 元数据用);`description: " "` 这类引号内纯空白的值解引后即为空。
12
+ * @param content - Markdown 全文。
13
+ * @returns `{ meta, body }`;无 frontmatter 时 meta 为空对象、body 为原文。
14
+ */
15
+ export function parseFrontmatter(content) {
16
+ const match = content.match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n?([\s\S]*)$/)
17
+ if (!match) return { meta: {}, body: content }
18
+
19
+ const unquote = (value) => {
20
+ const s = value.trim()
21
+ const quoted = s.match(/^"([\s\S]*)"$|^'([\s\S]*)'$/)
22
+ return quoted ? (quoted[1] ?? quoted[2]) : s
23
+ }
24
+
25
+ const meta = {}
26
+ let currentKey = ''
27
+ let currentValue = ''
28
+
29
+ for (const line of match[1].split(/\r?\n/)) {
30
+ const kv = line.match(/^([A-Za-z][\w-]*):\s*(.*)$/)
31
+ if (kv) {
32
+ if (currentKey) meta[currentKey] = unquote(currentValue)
33
+ currentKey = kv[1]
34
+ currentValue = kv[2]
35
+ } else if (line.match(/^\s+/) && currentKey) {
36
+ // 缩进续行(简化多行值)。
37
+ currentValue += ' ' + line.trim()
38
+ }
39
+ }
40
+ if (currentKey) meta[currentKey] = unquote(currentValue)
41
+
42
+ return { meta, body: match[2].trim() }
43
+ }
44
+
45
+ /**
46
+ * 提取 memory 元数据类型:优先直接 `type` 字段,其次扁平化后的
47
+ * `metadata: type: xxx` 文本。
48
+ * @param meta - `parseFrontmatter` 返回的 meta。
49
+ * @returns `feedback` | `project` | `reference` | `user` | `unknown`。
50
+ */
51
+ export function extractMetadataType(meta) {
52
+ if (meta.type) return meta.type
53
+ if (meta.metadata) {
54
+ const typeMatch = meta.metadata.match(/type:\s*(\w+)/)
55
+ if (typeMatch) return typeMatch[1]
56
+ }
57
+ return 'unknown'
58
+ }