@dingpenghui/dsh-obsidian-sync 3.4.2

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/src/index.ts ADDED
@@ -0,0 +1,897 @@
1
+ /**
2
+ * DSH Obsidian Sync — 按需搜索 Obsidian 知识库 + 按 vault 既有 PARA 规则归档 DSH 会话。
3
+ *
4
+ * 设计理念(零 token / 高性能):
5
+ * - 纯模型 Tool 按需调用,不注入系统提示词(默认 token 成本 ≈ 两个 Tool 的 schema)
6
+ * - 进程内 fs 服务(resolve/stat/listDir/readText/writeText),不起子进程
7
+ * - 倒排索引 + 60s 增量重建(dirty 标记,跳过 .obsidian/.git)
8
+ * - 按 session 幂等 upsert:摘要未变则跳过写盘
9
+ * - 笔记写入 vault/04-Archive/,自动挂 DSH-会话归档-索引.md 的按日期段
10
+ * - 落款支持 raw_log 原始日志指针,贴合 vault 既有笔记惯例
11
+ *
12
+ * 使用前提(在插件详情页设置,保存后经 volatile HMR 即时生效,无需重启):
13
+ * 1. vaultPath —— 必须指向一个已存在的 Obsidian vault 目录(PARA 结构:
14
+ * 02-Projects / 03-Areas / 04-Archive / 05-Resources 与 DSH-会话归档-索引.md)。
15
+ * 路径错则搜索与归档全部失败。默认 E:/dsh-workspace/obsidian-vault。
16
+ * 2. searchEnabled —— 是否注册倒排索引搜索(obsidian.search / obsidian.brief)。
17
+ * 关闭后仅保留 obsidian.read_note 与 obsidian.sync_session。默认 true。
18
+ * 3. indexRefreshMs —— 索引增量重建 / vault 外部变更感知间隔(≥5000ms)。默认 60000ms。
19
+ * 4. git 自动推送(隐含前提,无需设置):vault 是 git 仓库且配置了 remote 时,
20
+ * 归档笔记会自动 git commit + push;不满足时笔记仍正常写入,git 步骤在结果中降级提示。
21
+ *
22
+ * @module dsh-obsidian-sync
23
+ */
24
+
25
+ import type { Context } from '@deepseek-ai/cordis'
26
+ import z from '@deepseek-ai/schemastery'
27
+ // 宿主 Service 由运行时组合(宿主包由宿主自己安装);插件包声明为普通
28
+ // 依赖只会与宿主的版本产生 peer 漂移(如 0.2.0-rc.2 vs 0.2.0-rc.1),
29
+ // 导致启动时整组插件卡在“等待服务”。因此这里仅做类型导入,不产生运行时依赖。
30
+ import { defineTool } from '@deepseek-ai/dsh-tools'
31
+ import type { FileSystem, FsDirEntry, FsTarget } from '@deepseek-ai/dsh-fs'
32
+ import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox'
33
+ import type { JsonValue } from '@deepseek-ai/dsh-util-values'
34
+
35
+ /** 把工具返回值断言为 JsonValue(0.2.0-rc.1 的 execute 要求返回 InferValue,
36
+ * 即 output.schema 推断出的 JsonValue;对象 union 的 undefined 分支在此收敛)。 */
37
+ function asJsonValue(v: unknown): JsonValue {
38
+ return v as JsonValue
39
+ }
40
+
41
+ /** Cordis 插件名。 */
42
+ export const name = 'obsidian-sync'
43
+
44
+ /** 本插件需要的 Service(`tools`/`fs` 为硬依赖,缺失时等待 Cordis 重激活)。
45
+ * 仅保留真实消费的 Service:settings 是可选增强(未组合时降级为默认值)。 */
46
+ export const inject = ['tools', 'fs']
47
+
48
+ /** 插件配置。schema 字段标记 `.volatile()`:宿主 settings 框架投影到浏览器
49
+ * 设置页,详情页保存后经 profile Cordis patch + volatile HMR 即时生效,无需重启。
50
+ * 接口本身保留普通类型(与已验证的 conversation-language 插件一致),
51
+ * volatile 语义由 schema 与 Loader 承担。 */
52
+ export interface Config {
53
+ /** Obsidian vault 绝对路径;缺省 `E:/dsh-workspace/obsidian-vault`。 */
54
+ vaultPath: string
55
+ /** 是否注册 obsidian.search(倒排索引 + 定时增量重建)。 */
56
+ searchEnabled: boolean
57
+ /** 索引增量重建间隔(毫秒,下限 5000)。 */
58
+ indexRefreshMs: number
59
+ }
60
+
61
+ /**
62
+ * Schemastery 配置 schema:加载器用它解析行 `config` 并补默认值。
63
+ *
64
+ * 注意 Schemastery 的 API 与 zod 不同:没有 `.optional()` / `.int()`。
65
+ * 对象属性默认即可选(不调用 `.required()` 时,缺失键不会写入解析结果),
66
+ * 整数约束通过 `.step(1)` 表达。
67
+ */
68
+ /** 运行时 schema:volatile 标记 + 默认值。Loader 据此解析行 config 并补默认。 */
69
+ export const Config = z.object({
70
+ vaultPath: z.string().volatile().default('E:/dsh-workspace/obsidian-vault'),
71
+ searchEnabled: z.boolean().default(true).volatile(),
72
+ indexRefreshMs: z.number().step(1).min(5000).default(60000).volatile(),
73
+ })
74
+
75
+ export function apply(ctx: Context): void {
76
+ // 0.2.0-rc.1 settings API:describe() 返回按 profile entry id 排序的行,
77
+ // 本插件的 entry id 是 dsh-obsidian-sync(cordis.yml 中的 id)。
78
+ // 行 value 由 Loader 按本文件导出的 Config schema 解析并补默认值,
79
+ // volatile HMR 保存后经 settings 框架即时刷新,读到的就是当前生效值。
80
+ /** settings 服务是可选增强:未组合时降级为默认值。
81
+ * 按 SettingsDescriptor 契约读命名空间行(ns + value 由 Loader 按
82
+ * 本文件导出的 Config schema 解析并补默认值;volatile HMR 保存后即时刷新)。 */
83
+ const settings = ctx.get('settings') as {
84
+ describe(): Array<{ ns: string; value?: unknown }>
85
+ } | undefined
86
+
87
+ /** 读命名空间行 value。 */
88
+ const readSection = (): Record<string, unknown> | undefined => {
89
+ if (settings === undefined) return undefined
90
+ try {
91
+ const row = settings.describe().find((r) => r.ns === 'dsh-obsidian-sync')
92
+ const v = row?.value
93
+ return (v !== null && typeof v === 'object') ? (v as Record<string, unknown>) : undefined
94
+ } catch (_e) {
95
+ return undefined
96
+ }
97
+ }
98
+
99
+ // vault 路径是插件核心前提:缺省 E:/dsh-workspace/obsidian-vault。
100
+ //
101
+ // 0.2.0-rc.2 保存行为:宿主把行 value 写回 cordis.patch.yml 并更新 settings
102
+ // 镜像,但不会自动重激活本插件 fiber(volatile HMR 只在整应用重启或手动
103
+ // 卸载/重载时发生)。因此这里不能像旧实现那样在 apply() 时把路径求值成
104
+ // const 冻结 —— 保存新路径后运行中的实例仍读旧值。改为惰性读取:
105
+ // currentVaultPath() 每次构建/感知索引时重读 settings,保存即对后续
106
+ // 索引周期生效(无需重启)。
107
+ const DEFAULT_VAULT_PATH = 'E:/dsh-workspace/obsidian-vault'
108
+ const currentVaultPath = (): string => {
109
+ const v = readSection()?.vaultPath
110
+ return typeof v === 'string' && v.length > 0 ? v : DEFAULT_VAULT_PATH
111
+ }
112
+ const readSearchEnabled = (): boolean => {
113
+ const v = readSection()?.searchEnabled
114
+ return typeof v === 'boolean' ? v : true
115
+ }
116
+ const readIndexRefreshMs = (): number => {
117
+ const v = readSection()?.indexRefreshMs
118
+ return typeof v === 'number' && v >= 5000 ? v : 60000
119
+ }
120
+
121
+ let searchEnabled = readSearchEnabled()
122
+ const refreshMs = Math.max(readIndexRefreshMs(), 5000)
123
+
124
+ const fs = ctx.fs as unknown as FileSystem
125
+ // 0.2.0-rc.1 不再提供 ctx.timer 服务;改用 Node 原生 setInterval,
126
+ // 通过 ctx.effect 的清理函数保证插件卸载时定时器被清除。
127
+ const index = new Map<string, { displayPath: string; tokens: Set<string>; snippet: string; size: number; version: string }>()
128
+ let dirty = true
129
+ let refreshing = false
130
+
131
+ // vault 路径变更感知:保存新路径后宿主不会重激活本实例,靠这里的
132
+ // 变更检测把 dirty 置位 —— 下一次定时器 tick / 工具调用(ensureIndex)
133
+ // 即按新路径全量重建,无需重启。
134
+ let observedVaultPath = currentVaultPath()
135
+ function markDirtyOnVaultChange(): void {
136
+ const p = currentVaultPath()
137
+ if (p !== observedVaultPath) {
138
+ observedVaultPath = p
139
+ dirty = true
140
+ }
141
+ }
142
+
143
+ /**
144
+ * vault 外部修改感知(stat 比对 version token):
145
+ * 快速 stat 每篇已索引文件的 version(不透明新鲜度令牌),发现外部变更
146
+ * (用户手动编辑 / 其他工具写入 / git pull)即置 dirty。
147
+ * 0.2.0-rc.1 的 FsInfo 不再暴露 mtimeMs,改以 opaque version token 比对;
148
+ * 同内容文件的 version 相同,任何写入/删除都会改变它。stat 代价极小(~60 篇 × ms 级)。
149
+ */
150
+ async function detectExternalChanges(): Promise<void> {
151
+ if (index.size === 0) return
152
+ for (const entry of index.values()) {
153
+ try {
154
+ const target = await fs.resolve(entry.displayPath)
155
+ const stat = await fs.stat(target)
156
+ if (stat !== undefined) {
157
+ if (String(stat.version) !== String(entry.version)) {
158
+ dirty = true
159
+ return
160
+ }
161
+ } else {
162
+ // 目标已不存在 → 视为变更
163
+ dirty = true
164
+ return
165
+ }
166
+ } catch (_e) {
167
+ // 文件被外部删除 → 也视为变更
168
+ dirty = true
169
+ return
170
+ }
171
+ }
172
+ // 新文件检测:listDir 顶层 04-Archive / 02-Projects / 03-Areas,出现未知前缀即 dirty
173
+ for (const sub of ['04-Archive', '02-Projects', '03-Areas', '05-Resources']) {
174
+ try {
175
+ const dir = await fs.resolve(currentVaultPath() + '/' + sub)
176
+ const entries = await fs.listDir(dir)
177
+ for (const e of entries) {
178
+ if (e.type !== 'file' || e.name === undefined || !e.name.endsWith('.md')) continue
179
+ const known = [...index.keys()].some(k => k.endsWith('/' + e.name) || k === sub + '/' + e.name)
180
+ if (!known) {
181
+ dirty = true
182
+ return
183
+ }
184
+ }
185
+ } catch (_e) { /* 目录不存在,跳过 */ }
186
+ }
187
+ }
188
+
189
+ /**
190
+ * 分词:ASCII 重叠三元组 + 中文滑动二元组(bigram)。
191
+ *
192
+ * - ASCII 字母数字:长度 ≥2 直接加入;长度 ≥4 额外加 i+=1 重叠三元组
193
+ * - 中文:按 Unicode CJK 字符切分,长度 1 直接加入,≥2 加滑动 bigram
194
+ * 这样「知识库」/「知识」可以互相命中,「连不上」/「连接不上」同理
195
+ */
196
+ function tokenize(text: string): Set<string> {
197
+ const lower = String(text).toLowerCase()
198
+ const seen = new Set<string>()
199
+ // 连续 ASCII 字母数字段
200
+ const asciiRe = /[a-z0-9]+/g
201
+ let m: RegExpExecArray | null
202
+ while ((m = asciiRe.exec(lower)) !== null) {
203
+ const p = m[0]
204
+ if (p.length < 2) continue
205
+ seen.add(p)
206
+ if (p.length >= 4) {
207
+ for (let i = 0; i + 3 <= p.length; i++) seen.add(p.slice(i, i + 3))
208
+ }
209
+ }
210
+ // 连续 CJK 段
211
+ const cjkRe = /[\u4e00-\u9fff]+/g
212
+ while ((m = cjkRe.exec(lower)) !== null) {
213
+ const p = m[0]
214
+ if (p.length === 1) {
215
+ seen.add(p)
216
+ } else {
217
+ for (let i = 0; i + 2 <= p.length; i++) seen.add(p.slice(i, i + 2))
218
+ if (p.length >= 3) seen.add(p.slice(p.length - 3))
219
+ }
220
+ }
221
+ return seen
222
+ }
223
+
224
+ /** 为索引 entry 计算 IDF 权重表(token → 1/log(1+df))。 */
225
+ function buildIdf(entries: Array<{ tokens: Set<string> }>): Map<string, number> {
226
+ const df = new Map<string, number>()
227
+ for (const entry of entries) {
228
+ for (const t of entry.tokens) {
229
+ df.set(t, (df.get(t) ?? 0) + 1)
230
+ }
231
+ }
232
+ const total = entries.length || 1
233
+ const idf = new Map<string, number>()
234
+ for (const [t, count] of df) {
235
+ idf.set(t, Math.log(total / (1 + count)))
236
+ }
237
+ return idf
238
+ }
239
+
240
+ async function rebuildIndex(): Promise<void> {
241
+ refreshing = true
242
+ try {
243
+ const next = new Map(index)
244
+ const idfEntries: Array<{ tokens: Set<string> }> = []
245
+ try {
246
+ const root = await fs.resolve(currentVaultPath())
247
+ const statRoot = await fs.stat(root)
248
+ if (statRoot !== undefined && statRoot.type === 'directory') {
249
+ const queue: FsTarget[] = [root]
250
+ while (queue.length > 0) {
251
+ const dir = queue.shift()!
252
+ let entries: FsDirEntry[]
253
+ try { entries = await fs.listDir(dir) } catch (_e) { continue }
254
+ for (const entry of entries) {
255
+ if (entry.type === 'directory') {
256
+ const entryName = entry.name || ''
257
+ if (entryName === '.obsidian' || entryName === '.git') continue
258
+ queue.push(entry.target)
259
+ continue
260
+ }
261
+ if (entry.type !== 'file' || entry.name === undefined || !entry.name.endsWith('.md') || entry.name.startsWith('.')) continue
262
+ let text: string
263
+ try { text = await fs.readText(entry.target) } catch (_e) { continue }
264
+ // 索引吃全文(~46 篇 × 几 KB,内存无压力),不再只取前 600 字
265
+ const tokens = tokenize('\n' + entry.name + '\n' + text)
266
+ const displayPath = entry.target && typeof entry.target === 'object' ? entry.target.displayPath : String(entry.target ?? '')
267
+ const stat = await fs.stat(entry.target)
268
+ next.set(displayPath, {
269
+ displayPath,
270
+ tokens,
271
+ snippet: text.slice(0, 240),
272
+ size: entry.size ?? text.length,
273
+ version: stat !== undefined ? String(stat.version) : '',
274
+ })
275
+ idfEntries.push({ tokens })
276
+ }
277
+ }
278
+ }
279
+ } catch (_e) { /* vault 不可达:保留旧索引 */ }
280
+ index.clear()
281
+ for (const kv of next) index.set(kv[0], kv[1])
282
+ // 重建 IDF 权重表
283
+ idfTable = buildIdf(idfEntries)
284
+ dirty = false
285
+ } finally {
286
+ refreshing = false
287
+ }
288
+ }
289
+
290
+ let idfTable = new Map<string, number>()
291
+
292
+ async function ensureIndex(): Promise<void> {
293
+ markDirtyOnVaultChange()
294
+ if (dirty) await rebuildIndex()
295
+ else while (refreshing) await new Promise(resolve => setTimeout(resolve, 20))
296
+ }
297
+
298
+ if (searchEnabled) {
299
+ ctx.effect(() => {
300
+ const handle = setInterval(() => {
301
+ markDirtyOnVaultChange()
302
+ if (dirty) {
303
+ void rebuildIndex()
304
+ return
305
+ }
306
+ // 非 dirty 时做外部修改感知:stat 比对 version token,发现外部变更才置 dirty 下轮重建
307
+ void detectExternalChanges().then(() => {
308
+ if (dirty) void rebuildIndex()
309
+ })
310
+ }, refreshMs)
311
+ return () => { clearInterval(handle) }
312
+ }, 'obsidian-sync: index refresh')
313
+ }
314
+
315
+ function searchMatches(topic: string, limit: number): Array<{ file: string; score: number; snippet: string; size: number; matchReason: string }> {
316
+ const topicTokens = tokenize(topic)
317
+ const results: Array<{ file: string; score: number; snippet: string; size: number; matchReason: string }> = []
318
+ for (const entry of index.values()) {
319
+ let score = 0
320
+ const matchedKeywords: string[] = []
321
+ for (const t of topicTokens) {
322
+ if (entry.tokens.has(t)) {
323
+ score += idfTable.get(t) ?? 1
324
+ matchedKeywords.push(t)
325
+ }
326
+ }
327
+ if (score === 0) continue
328
+ results.push({
329
+ file: entry.displayPath,
330
+ score: Math.round(score * 100) / 100,
331
+ snippet: entry.snippet,
332
+ size: entry.size,
333
+ matchReason: matchedKeywords.slice(0, 5).join(' '),
334
+ })
335
+ }
336
+ results.sort((a, b) => b.score - a.score)
337
+ return results.slice(0, limit)
338
+ }
339
+
340
+ function sanitizeTitleForFilename(title: string): string {
341
+ const base = String(title || '')
342
+ .replace(/[\\/:*?"<>|()()]/g, ' ')
343
+ .replace(/\s+/g, ' ')
344
+ .trim()
345
+ return base.length > 0 ? base.slice(0, 40) : '会话'
346
+ }
347
+
348
+ /**
349
+ * 噪声会话判定:身份测试类("你是谁" / "你是什么模型" / "介绍一下自己" / 冒烟测试)
350
+ * 且摘要很短(≤ 摘要阈值字,近似单轮短会话)→ 不写笔记不进索引。
351
+ *
352
+ * 双重条件避免误杀:标题命中噪声模式 且 摘要长度 ≤ 阈值。
353
+ * 摘要长的"你是谁"类会话(用户有实质讨论)仍正常归档。
354
+ */
355
+ const NOISE_TITLE_PATTERNS: RegExp[] = [
356
+ /^你是谁$/,
357
+ /^你是什么(大)?模型$/,
358
+ /^介绍(一下)?自己$/,
359
+ /^自我(介绍|认识)?$/,
360
+ /^echo\s+\S+$/i,
361
+ /^测试$/,
362
+ ]
363
+ const NOISE_SUMMARY_MAX_CHARS = 200
364
+
365
+ function isNoiseSession(title: string, summary: string): boolean {
366
+ const t = String(title || '').trim()
367
+ const hit = NOISE_TITLE_PATTERNS.some(re => re.test(t))
368
+ if (!hit) return false
369
+ return String(summary || '').trim().length <= NOISE_SUMMARY_MAX_CHARS
370
+ }
371
+
372
+ /** 确保 shortId 恰好 8 位:不足时补 SHA-1 前缀 hash。 */
373
+ function normalizeShortId(sessionId: string): string {
374
+ let s = sessionId.replace(/[^A-Za-z0-9]/g, '')
375
+ if (s.length >= 8) return s.slice(0, 8)
376
+ // 不足 8 位:用原始 sessionId 的简单 hash 补齐
377
+ let hash = 0
378
+ for (let i = 0; i < sessionId.length; i++) {
379
+ hash = ((hash << 5) - hash + sessionId.charCodeAt(i)) | 0
380
+ }
381
+ s = s + Math.abs(hash).toString(16).slice(0, 8)
382
+ return s.slice(0, 8)
383
+ }
384
+
385
+ /** vault 在 workspace 之外,写入需按调用抬升到 danger-full-access。 */
386
+ const widePolicy: SandboxExecutionPolicy = { mode: 'danger-full-access', workspaceRoot: currentVaultPath() }
387
+
388
+ /**
389
+ * 每日 22:00 复盘沉淀节律:
390
+ * - 汇总当天 04-Archive 归档(按日期前缀统计笔记数与主题关键词)
391
+ * - 把当天要点追加到 02-Projects/ 下匹配项目的"近期演进"段(幂等:当天已追加则跳过)
392
+ * - git commit + push
393
+ *
394
+ * 实现:60s 轮询,发现跨过当天 22:00 且尚未执行过 → 执行一次。
395
+ */
396
+ let lastReviewDate = ''
397
+
398
+ async function dailyReview(): Promise<void> {
399
+ const now = new Date()
400
+ const dateStr = now.toISOString().slice(0, 10)
401
+ // 只在 22:00 之后(本地时区)且当天未执行时触发
402
+ const hour = now.getHours()
403
+ if (hour < 22 || lastReviewDate === dateStr) return
404
+ lastReviewDate = dateStr
405
+
406
+ try {
407
+ await ensureIndex()
408
+
409
+ // 1. 汇总当天归档
410
+ const todayFiles: Array<{ file: string; date: string; snippet: string }> = []
411
+ for (const entry of index.values()) {
412
+ if (!entry.displayPath.includes('04-Archive/')) continue
413
+ const m = entry.displayPath.match(new RegExp('^.*?(\\d{4}-\\d{2}-\\d{2})'))
414
+ if (m && m[1] === dateStr) {
415
+ todayFiles.push({ file: entry.displayPath, date: dateStr, snippet: entry.snippet })
416
+ }
417
+ }
418
+ if (todayFiles.length === 0) return
419
+
420
+ // 2. 对每个 02-Projects 页追加当天归档条目(幂等)
421
+ for (const proj of ['02-Projects/dsh-obsidian-sync.md', '02-Projects/dsh-tool-agnes.md', '02-Projects/dsh-plugin-manager.md', '02-Projects/dsh-conversation-language.md']) {
422
+ try {
423
+ const target = await fs.resolve(currentVaultPath() + '/' + proj)
424
+ let content = String(await fs.readText(target))
425
+ const marker = '## 近期演进'
426
+ const markerPos = content.indexOf(marker)
427
+ if (markerPos === -1) continue
428
+ // 检查当天是否已追加(幂等)
429
+ if (content.indexOf(dateStr) !== -1 && content.indexOf(dateStr) > markerPos) continue
430
+ const todayLines = todayFiles.map(f => '- [[04-Archive/' + f.file.replace(/^04-Archive\//, '') + ']] — ' + f.snippet.slice(0, 80).replace(/\n/g, ' ')).join('\n')
431
+ content = content.slice(0, content.length) + '\n' + dateStr + ' 归档:\n' + todayLines + '\n'
432
+ await fs.writeText(target, content, undefined, undefined, widePolicy)
433
+ } catch (_e) { /* 文件不存在或写入失败,跳过 */ }
434
+ }
435
+
436
+ dirty = true
437
+
438
+ // 3. git commit + push
439
+ try {
440
+ const { execSync } = await import('node:child_process')
441
+ const out = execSync('git add -A && git status --porcelain', {
442
+ cwd: currentVaultPath(), timeout: 15000, encoding: 'utf-8',
443
+ })
444
+ if (out.trim().length > 0) {
445
+ execSync('git commit -m "daily-review: ' + dateStr + ' 归档沉淀(' + todayFiles.length + ' 篇)" && git push', {
446
+ cwd: currentVaultPath(), timeout: 60000, encoding: 'utf-8',
447
+ })
448
+ }
449
+ } catch (_e) { /* git 失败不影响 */ }
450
+ } catch (_e) { /* 整体失败静默 */ }
451
+ }
452
+
453
+ ctx.effect(() => {
454
+ const handle = setInterval(() => {
455
+ void dailyReview()
456
+ }, 60000)
457
+ return () => { clearInterval(handle) }
458
+ }, 'obsidian-sync: daily review at 22:00')
459
+
460
+ /**
461
+ * 每周复盘沉淀节律(补齐决策习惯页"周复盘尚未建立"的开放问题):
462
+ * - 每周日 22:30 触发(本地时区),汇总本 ISO 周(周一~周日)04-Archive 归档
463
+ * - 写入/更新 03-Areas/周复盘-<ISO 周>.md(主题分布 + 归档清单 + 待收敛项)
464
+ * - 幂等:同 ISO 周已生成则跳过;git commit + push
465
+ */
466
+ let lastWeeklyKey = ''
467
+
468
+ function isoWeekKey(d: Date): string {
469
+ // ISO 周:周四归属所在周;周一为周首
470
+ const t = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()))
471
+ const day = t.getUTCDay() || 7
472
+ t.setUTCDate(t.getUTCDate() + 4 - day)
473
+ const yearStart = new Date(Date.UTC(t.getUTCFullYear(), 0, 1))
474
+ const week = Math.ceil((((+t - +yearStart) / 86400000) + 1) / 7)
475
+ return t.getUTCFullYear() + '-W' + String(week).padStart(2, '0')
476
+ }
477
+
478
+ async function weeklyReview(): Promise<void> {
479
+ const now = new Date()
480
+ const dow = now.getDay() // 0=Sun
481
+ if (dow !== 0 || now.getHours() < 22) return
482
+ const weekKey = isoWeekKey(now)
483
+ if (lastWeeklyKey === weekKey) return
484
+ lastWeeklyKey = weekKey
485
+
486
+ try {
487
+ await ensureIndex()
488
+ const weekDate = new Date(now)
489
+ weekDate.setDate(now.getDate() - 6) // 周一
490
+ const monday = weekDate.toISOString().slice(0, 10)
491
+
492
+ // 汇总本周归档(文件名日期 >= 周一 且 <= 今天)
493
+ const weekFiles: Array<{ file: string; date: string; snippet: string }> = []
494
+ for (const entry of index.values()) {
495
+ if (!entry.displayPath.includes('04-Archive/')) continue
496
+ const m = entry.displayPath.match(/(\d{4}-\d{2}-\d{2})/)
497
+ if (m && m[1] >= monday && m[1] <= now.toISOString().slice(0, 10)) {
498
+ weekFiles.push({ file: entry.displayPath, date: m[1], snippet: entry.snippet })
499
+ }
500
+ }
501
+ weekFiles.sort((a, b) => a.date.localeCompare(b.date))
502
+ if (weekFiles.length === 0) return
503
+
504
+ // 主题分布(按 04-Archive 笔记 frontmatter tags 简化:从 snippet 首行提取)
505
+ const lines = weekFiles.map(f => '- [[04-Archive/' + f.file.replace(/^04-Archive\//, '') + ']](' + f.date + ')— ' + f.snippet.slice(0, 60).replace(/\n/g, ' ')).join('\n')
506
+ const note = '---\ndate: ' + now.toISOString().slice(0, 10) + '\nstatus: active\ntags: [area, 周复盘]\n---\n\n# 周复盘 ' + weekKey + '\n\n> 自动汇总 ' + monday + ' ~ ' + now.toISOString().slice(0, 10) + ' 的 04-Archive 归档(' + weekFiles.length + ' 篇)。\n\n## 本周归档\n\n' + lines + '\n\n## 待收敛\n\n- [ ] 人工审阅:识别本周新增的长期 Area / Resource 沉淀点\n\n'
507
+ const target = await fs.resolve(currentVaultPath() + '/03-Areas/周复盘-' + weekKey + '.md')
508
+ let skipped = false
509
+ try {
510
+ const prev = String(await fs.readText(target))
511
+ if (prev.includes(weekKey)) skipped = true
512
+ } catch (_e) { /* 尚不存在 */ }
513
+ if (!skipped) await fs.writeText(target, note, undefined, undefined, widePolicy)
514
+
515
+ dirty = true
516
+ try {
517
+ const { execSync } = await import('node:child_process')
518
+ const out = execSync('git add -A && git status --porcelain', { cwd: currentVaultPath(), timeout: 15000, encoding: 'utf-8' })
519
+ if (out.trim().length > 0) {
520
+ execSync('git commit -m "weekly-review: ' + weekKey + ' 归档沉淀(' + weekFiles.length + ' 篇)" && git push', { cwd: currentVaultPath(), timeout: 60000, encoding: 'utf-8' })
521
+ }
522
+ } catch (_e) { /* git 失败不影响 */ }
523
+ } catch (_e) { /* 整体失败静默 */ }
524
+ }
525
+
526
+ ctx.effect(() => {
527
+ const handle = setInterval(() => {
528
+ void weeklyReview()
529
+ }, 60000)
530
+ return () => { clearInterval(handle) }
531
+ }, 'obsidian-sync: weekly review at Sunday 22:30')
532
+
533
+ /**
534
+ * 更新索引文件(DSH-会话归档-索引.md)的按日期段。
535
+ * - 找到当天段落(### YYYY-MM-DD)
536
+ * - 边界:下一个 `### ` 或 `## `(两个井号终止条件)
537
+ * - 同 shortId 已有条目 → 替换该行(幂等去重)
538
+ * - 否则追加到当天段落末尾
539
+ */
540
+ async function updateIndexFile(entryRelPath: string, title: string, sessionId: string, dateStr: string): Promise<boolean> {
541
+ try {
542
+ const idxTarget = await fs.resolve(currentVaultPath() + '/DSH-会话归档-索引.md')
543
+ const idxContent = String(await fs.readText(idxTarget))
544
+ const dayAnchor = '### ' + dateStr
545
+ const line = '\n- [[' + entryRelPath + '|' + title + ']] (' + dateStr + ', ' + sessionId + ')\n'
546
+ const dayPos = idxContent.indexOf(dayAnchor)
547
+ if (dayPos === -1) {
548
+ // 当天段落不存在:在 "## 主题分类" 之前插入
549
+ const dayHeader = '\n### ' + dateStr + '\n' + line
550
+ const tailPos = idxContent.indexOf('\n## 主题分类')
551
+ const updated = tailPos === -1 ? idxContent + dayHeader : idxContent.slice(0, tailPos) + dayHeader + idxContent.slice(tailPos)
552
+ await fs.writeText(idxTarget, updated, undefined, undefined, widePolicy)
553
+ return true
554
+ }
555
+ // 找当天段落的结尾:下一个 `### ` 或 `## `(双井号终止)
556
+ const afterAnchor = dayPos + dayAnchor.length
557
+ const nextH3 = idxContent.indexOf('\n### ', afterAnchor)
558
+ const nextH2 = idxContent.indexOf('\n## ', afterAnchor)
559
+ let dayEnd = -1
560
+ if (nextH3 !== -1 && nextH2 !== -1) dayEnd = Math.min(nextH3, nextH2)
561
+ else if (nextH3 !== -1) dayEnd = nextH3
562
+ else if (nextH2 !== -1) dayEnd = nextH2
563
+ else dayEnd = idxContent.length
564
+
565
+ // 去重:当天段落内是否已有相同 shortId 的条目
566
+ const daySection = idxContent.slice(afterAnchor, dayEnd)
567
+ const shortId = normalizeShortId(sessionId)
568
+ const dedupePattern = new RegExp('^- \\[\\[[^\\]]*' + dateStr + '-' + shortId + '-[^\\]]*\\]\\]\\s*\\([^\\)]*\\)$', 'm')
569
+ const existingMatch = daySection.match(dedupePattern)
570
+ let updated: string
571
+ if (existingMatch !== null) {
572
+ // 替换已有行(幂等)
573
+ const absPos = afterAnchor + daySection.indexOf(existingMatch[0])
574
+ updated = idxContent.slice(0, absPos) + line.trimStart() + '\n' + idxContent.slice(absPos + existingMatch[0].length)
575
+ } else {
576
+ // 追加到段落末尾(dayEnd 前)
577
+ updated = idxContent.slice(0, dayEnd) + line + idxContent.slice(dayEnd)
578
+ }
579
+ await fs.writeText(idxTarget, updated, undefined, undefined, widePolicy)
580
+ return true
581
+ } catch (_e) {
582
+ return false
583
+ }
584
+ }
585
+
586
+ /** 从索引中按目录前缀过滤 + 按 task 相关性取 top N 提炼页。 */
587
+ function topDistilled(task: string, limit: number): Array<{ file: string; title: string; score: number; snippet: string }> {
588
+ const distilledDirs = ['02-Projects/', '03-Areas/', '05-Resources/']
589
+ const taskTokens = task.trim().length > 0 ? tokenize(task) : new Set<string>()
590
+ const results: Array<{ file: string; title: string; score: number; snippet: string }> = []
591
+ for (const entry of index.values()) {
592
+ if (!distilledDirs.some(d => entry.displayPath.includes(d))) continue
593
+ let score = 0
594
+ for (const t of taskTokens) {
595
+ if (entry.tokens.has(t)) score += idfTable.get(t) ?? 1
596
+ }
597
+ const title = entry.displayPath.split('/').pop() ?? entry.displayPath
598
+ results.push({ file: entry.displayPath, title, score: taskTokens.size > 0 ? Math.round(score * 100) / 100 : 0, snippet: entry.snippet })
599
+ }
600
+ results.sort((a, b) => b.score - a.score || a.title.localeCompare(b.title))
601
+ return results.slice(0, limit)
602
+ }
603
+
604
+ /** 从 04-Archive/ 取最近 N 篇(按文件名日期倒序)。 */
605
+ function recentArchives(limit: number): Array<{ file: string; date: string; snippet: string }> {
606
+ const results: Array<{ file: string; date: string; snippet: string }> = []
607
+ for (const entry of index.values()) {
608
+ if (!entry.displayPath.includes('04-Archive/')) continue
609
+ const dateMatch = entry.displayPath.match(/(\d{4}-\d{2}-\d{2})/)
610
+ results.push({ file: entry.displayPath, date: dateMatch ? dateMatch[1] : 'unknown', snippet: entry.snippet })
611
+ }
612
+ results.sort((a, b) => b.date.localeCompare(a.date))
613
+ return results.slice(0, limit)
614
+ }
615
+
616
+ /** 读取指定相对路径的文件内容。 */
617
+ async function readFileContent(relPath: string): Promise<string> {
618
+ try {
619
+ const target = await fs.resolve(currentVaultPath() + '/' + relPath)
620
+ return String(await fs.readText(target))
621
+ } catch (_e) {
622
+ return ''
623
+ }
624
+ }
625
+
626
+ if (searchEnabled) {
627
+ ctx.tools.register(defineTool({
628
+ name: 'obsidian.search',
629
+ description: '在 Obsidian 知识库中按关键词搜索相关笔记,返回最多 5 条匹配(文件相对路径、命中片段、命中关键词)。按需调用,平时不产生任何 token 成本。',
630
+ parameters: {
631
+ topic: { type: 'string', required: true, description: '搜索关键词,可含多个词(中英文均可,自动做 CJK bigram 分词)' },
632
+ limit: { type: 'number', description: '返回结果上限,默认 3,最大 5' },
633
+ },
634
+ output: {
635
+ schema: { type: 'json' },
636
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
637
+ },
638
+ isConcurrencySafe: () => true,
639
+ async execute(args) {
640
+ const topic = String(args.topic ?? '')
641
+ if (topic.trim().length === 0) return asJsonValue({ ok: false, error: 'topic must not be empty' })
642
+ const limit = Math.min(Math.max(1, Number(args.limit) || 3), 5)
643
+ await ensureIndex()
644
+ const matches = searchMatches(topic, limit)
645
+ return asJsonValue({ ok: true, matches, count: matches.length, vaultPath: currentVaultPath() })
646
+ },
647
+ }))
648
+
649
+ ctx.tools.register(defineTool({
650
+ name: 'obsidian.brief',
651
+ description: '每日简报:返回与当前任务最相关的提炼页(02-Projects/03-Areas/05-Resources)top 3 + 最近 3 条归档摘要 + 用户决策习惯与偏好页内容。低成本"开机记忆"入口,建议任务开始前调用一次。',
652
+ parameters: {
653
+ task: { type: 'string', description: '当前任务描述(可选),用于相关性排序;省略则返回全部提炼页按名称排序的 top 3' },
654
+ },
655
+ output: {
656
+ schema: { type: 'json' },
657
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
658
+ },
659
+ isConcurrencySafe: () => true,
660
+ async execute(args) {
661
+ const task = String(args.task ?? '').trim()
662
+ await ensureIndex()
663
+
664
+ // 1. 提炼页 top 3
665
+ const distilled = topDistilled(task, 3)
666
+
667
+ // 2. 最近归档 3 条
668
+ const recent = recentArchives(3)
669
+
670
+ // 3. 用户决策习惯与偏好页(03-Areas 下,如果存在)
671
+ let userProfile = ''
672
+ const profileRelPath = '03-Areas/决策习惯与偏好.md'
673
+ const hasProfile = [...index.values()].some(e => e.displayPath === profileRelPath)
674
+ if (hasProfile) {
675
+ userProfile = await readFileContent(profileRelPath)
676
+ }
677
+
678
+ return asJsonValue({
679
+ ok: true,
680
+ task: task || '(未指定)',
681
+ distilled,
682
+ recent,
683
+ userProfile: userProfile.length > 0 ? userProfile.slice(0, 2000) : '',
684
+ vaultPath: currentVaultPath(),
685
+ })
686
+ },
687
+ }))
688
+
689
+ ctx.tools.register(defineTool({
690
+ name: 'obsidian.read_note',
691
+ description: '按相对路径读取 Obsidian 笔记的 frontmatter 与正文,补全 obsidian.search 的"搜索→阅读"闭环。路径相对 vault 根(如 02-Projects/dsh-obsidian-sync.md),自动解析 YAML frontmatter(key: value / [list] / 数值 / 布尔)。按需调用,零 token 成本。',
692
+ parameters: {
693
+ path: { type: 'string', required: true, description: '笔记相对 vault 的路径(如 02-Projects/dsh-obsidian-sync.md 或 04-Archive/xxx.md)' },
694
+ },
695
+ output: {
696
+ schema: { type: 'json' },
697
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
698
+ },
699
+ isConcurrencySafe: () => true,
700
+ async execute(args) {
701
+ const rel = String(args.path ?? '').replace(/^\/+/, '').replace(/\\/g, '/').trim()
702
+ if (rel.length === 0) return asJsonValue({ ok: false, error: 'path must not be empty', code: 'EMPTY_PATH' })
703
+ const content = await readFileContent(rel)
704
+ if (content.length === 0) return asJsonValue({ ok: false, error: 'note not found or unreadable: ' + rel, code: 'NOT_FOUND' })
705
+
706
+ // 解析 YAML frontmatter(简单 key: value / key: [a, b] / key: "str")
707
+ const frontmatter: Record<string, unknown> = {}
708
+ let body = content
709
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n?/)
710
+ if (fmMatch !== null) {
711
+ body = content.slice(fmMatch[0].length)
712
+ for (const line of fmMatch[1].split('\n')) {
713
+ const kv = line.match(/^([A-Za-z0-9_\-]+):\s*(.*)$/)
714
+ if (kv === null) continue
715
+ const key = kv[1]
716
+ let raw = kv[2].trim()
717
+ let val: unknown
718
+ if (raw.startsWith('[') && raw.endsWith(']')) {
719
+ const inner = raw.slice(1, -1).trim()
720
+ val = inner.length === 0 ? [] : inner.split(',').map((s: string) => s.trim().replace(/^["']|["']$/g, ''))
721
+ } else if (raw === 'null') {
722
+ val = null
723
+ } else if (raw === 'true') {
724
+ val = true
725
+ } else if (raw === 'false') {
726
+ val = false
727
+ } else if (/^\d+$/.test(raw)) {
728
+ val = Number(raw)
729
+ } else {
730
+ val = raw.replace(/^["']|["']$/g, '')
731
+ }
732
+ frontmatter[key] = val
733
+ }
734
+ }
735
+
736
+ const titleLine = body.match(/^#\s+(.+)$/m)
737
+ return asJsonValue({
738
+ ok: true,
739
+ path: rel,
740
+ frontmatter,
741
+ title: titleLine ? titleLine[1].trim() : (rel.split('/').pop() ?? rel),
742
+ body,
743
+ })
744
+ },
745
+ }))
746
+ }
747
+
748
+ ctx.tools.register(defineTool({
749
+ name: 'obsidian.sync_session',
750
+ description: '把当前 DSH 会话按 Obsidian vault 的 PARA 结构与用户既有命名/索引规则,以 Markdown 笔记形式幂等写入 vault/04-Archive/,并自动把新条目挂到 DSH-会话归档-索引.md 的按日期段。摘要未变则跳过。幂等键 = date + shortId(标题变更不影响去重)。',
751
+ parameters: {
752
+ session_id: { type: 'string', required: true, description: '当前会话的完整 SessionId(UUID 或短 ID),原样存入 frontmatter 与正文' },
753
+ title: { type: 'string', required: true, description: '会话主题标题(写入 frontmatter 与正文 # 标题;文件名仅取前 40 字符做安全截断)' },
754
+ summary: { type: 'string', required: true, description: '会话结论/摘要(纯文本,建议 200-800 字,包含用户需求、过程要点、交付物)' },
755
+ tags: { type: 'array', items: { type: 'string' }, description: '主题标签数组,最多 10 个,将写入 frontmatter tags 字段(Obsidian 原生标签)' },
756
+ related: { type: 'array', items: { type: 'string' }, description: '关联笔记完整文件名(含 .md 扩展名,如 2026-09-11-a9095879-xxx.md),将生成为 [[全路径双链]]' },
757
+ raw_log: { type: 'string', description: '原始会话日志绝对路径,格式 ~/.dsh/sessions/<project-dir>/session-<uuid>/session.v3.jsonl.zstd,将写入落款以便追溯' },
758
+ },
759
+ output: {
760
+ schema: { type: 'json' },
761
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
762
+ },
763
+ async execute(args) {
764
+ const sessionId = String(args.session_id ?? 'unknown')
765
+ const title = String(args.title ?? '')
766
+ const safeTitle = sanitizeTitleForFilename(title)
767
+ const summary = String(args.summary ?? '')
768
+ const tags = Array.isArray(args.tags) ? args.tags.map(String).slice(0, 10) : []
769
+ const related = Array.isArray(args.related) ? args.related.map(String).slice(0, 10) : []
770
+ const rawLog = String(args.raw_log ?? '')
771
+
772
+ // 噪声会话过滤:身份测试类 + 摘要极短 → 不写笔记不进索引
773
+ if (isNoiseSession(title, summary)) {
774
+ return asJsonValue({ ok: true, skipped: true, noise: true, reason: 'identity-test noise session filtered out (short title + short summary)', index: 'unchanged' })
775
+ }
776
+
777
+ const dateStr = new Date().toISOString().slice(0, 10)
778
+ const shortId = normalizeShortId(sessionId)
779
+
780
+ // 幂等键 = date + shortId(标题不参与路径),写入前按前缀探测已存在文件并原地覆盖
781
+ const entryPrefix = dateStr + '-' + shortId + '-'
782
+ let entryRelPath: string
783
+
784
+ // 探测 04-Archive/ 下是否已有同前缀文件(同会话多次同步)
785
+ try {
786
+ const archiveDir = await fs.resolve(currentVaultPath() + '/04-Archive')
787
+ const archiveEntries = await fs.listDir(archiveDir)
788
+ const existing = archiveEntries.find(e => e.type === 'file' && e.name?.startsWith(entryPrefix))
789
+ if (existing !== undefined && existing.name !== undefined) {
790
+ // 已有文件:原地覆盖(标题可能变了但幂等键不变)
791
+ entryRelPath = '04-Archive/' + existing.name
792
+ } else {
793
+ entryRelPath = '04-Archive/' + entryPrefix + safeTitle + '.md'
794
+ }
795
+ } catch (_e) {
796
+ entryRelPath = '04-Archive/' + entryPrefix + safeTitle + '.md'
797
+ }
798
+
799
+ // related 双链:生成全路径 [[04-Archive/xxx.md|显示名]],写入前校验目标存在
800
+ const relatedLines: string[] = []
801
+ for (const r of related) {
802
+ const rFull = r.endsWith('.md') ? r : r + '.md'
803
+ const rRel = '04-Archive/' + rFull
804
+ // 校验目标存在(全路径双链)
805
+ try {
806
+ await fs.stat(await fs.resolve(currentVaultPath() + '/' + rRel))
807
+ relatedLines.push('- [[' + rRel + '|' + rFull.replace(/^.*\//, '').replace(/\.md$/, '') + ']]')
808
+ } catch (_e) {
809
+ relatedLines.push('- ' + rFull + '(未找到,降级为文字引用)')
810
+ }
811
+ }
812
+
813
+ // 生成 YAML frontmatter(Obsidian 原生标签 / Dataview 可查)
814
+ const fmDate = dateStr
815
+ const fmSessionId = sessionId.replace(/[^A-Za-z0-9-]/g, '').slice(0, 8)
816
+ const fmTags = tags.length > 0 ? tags : []
817
+ const frontmatter = [
818
+ '---',
819
+ 'session_id: ' + fmSessionId,
820
+ 'full_session_id: ' + sessionId,
821
+ 'date: ' + fmDate,
822
+ 'tags: [' + fmTags.join(', ') + ']',
823
+ '---',
824
+ '',
825
+ ].join('\n')
826
+
827
+ const logLine = rawLog.trim().length > 0
828
+ ? '> 📎 原始日志: `' + rawLog.trim() + '`\n\n> 由 DSH Obsidian Sync 自动同步\n'
829
+ : '> 由 DSH Obsidian Sync 自动同步\n'
830
+
831
+ const content = frontmatter
832
+ + '# DSH 会话: ' + title + '\n\n'
833
+ + '## 基本信息\n\n'
834
+ + '| 属性 | 值 |\n|------|-----|\n'
835
+ + '| **日期** | ' + dateStr + ' |\n'
836
+ + '| **会话ID** | `' + sessionId + '` |\n'
837
+ + '| **状态** | ✅ 已归档 |\n'
838
+ + '| **分类** | ' + (tags.length > 0 ? tags.join(' ') : '通用') + ' |\n\n'
839
+ + '---\n\n'
840
+ + '## 摘要\n\n' + summary + '\n\n'
841
+ + (relatedLines.length > 0 ? '## 关联\n\n' + relatedLines.join('\n') + '\n\n---\n' : '')
842
+ + logLine
843
+
844
+ let target: FsTarget
845
+ try {
846
+ target = await fs.resolve(currentVaultPath() + '/' + entryRelPath)
847
+ } catch (_e) {
848
+ const winPath = currentVaultPath() + '\\' + entryRelPath.split('/').join('\\')
849
+ try { target = await fs.resolve(winPath) } catch (_e2) { return asJsonValue({ ok: false, error: 'cannot resolve vault entry path', code: 'RESOLVE_FAILED' }) }
850
+ }
851
+
852
+ let skipped = false
853
+ try {
854
+ const prev = String(await fs.readText(target))
855
+ const marker = '## 摘要'
856
+ const pos = prev.indexOf(marker)
857
+ if (pos >= 0 && prev.slice(pos + marker.length).trim() === summary.trim()) skipped = true
858
+ } catch (_e) { /* 尚不存在 */ }
859
+ if (skipped) return asJsonValue({ ok: true, skipped: true, file: entryRelPath, reason: 'summary unchanged', index: 'unchanged' })
860
+
861
+ try {
862
+ await fs.writeText(target, content, undefined, undefined, widePolicy)
863
+ } catch (writeErr) {
864
+ return asJsonValue({ ok: false, error: 'writeText failed: ' + String(writeErr instanceof Error ? writeErr.message : writeErr), code: 'WRITE_FAILED' })
865
+ }
866
+
867
+ const indexOk = await updateIndexFile(entryRelPath, title, sessionId, dateStr)
868
+ dirty = true
869
+
870
+ // 写入成功后自动 git commit + push(vault 有 remote 时才生效)
871
+ let gitStatus = 'skipped'
872
+ try {
873
+ const { execSync } = await import('node:child_process')
874
+ const out = execSync('git add -A && git status --porcelain', {
875
+ cwd: currentVaultPath(),
876
+ timeout: 15000,
877
+ encoding: 'utf-8',
878
+ })
879
+ if (out.trim().length > 0) {
880
+ const msg = 'archive: ' + entryRelPath.replace(/^04-Archive\//, '')
881
+ execSync('git commit -m "' + msg.replace(/"/g, '\\"') + '" && git push', {
882
+ cwd: currentVaultPath(),
883
+ timeout: 60000,
884
+ encoding: 'utf-8',
885
+ })
886
+ gitStatus = 'pushed'
887
+ } else {
888
+ gitStatus = 'no changes'
889
+ }
890
+ } catch (gitErr) {
891
+ gitStatus = 'failed: ' + String(gitErr instanceof Error ? gitErr.message : gitErr)
892
+ }
893
+
894
+ return asJsonValue({ ok: true, skipped: false, file: entryRelPath, index: indexOk ? 'updated' : 'skipped', git: gitStatus })
895
+ },
896
+ }))
897
+ }