dsh-plugin-wiki-tools 0.14.0 → 0.14.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/README.md CHANGED
@@ -1,11 +1,22 @@
1
1
  # dsh-plugin-wiki-tools
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/dsh-plugin-wiki-tools)](https://www.npmjs.com/package/dsh-plugin-wiki-tools)
4
+ [![npm weekly downloads](https://img.shields.io/npm/dw/dsh-plugin-wiki-tools)](https://www.npmjs.com/package/dsh-plugin-wiki-tools)
5
+ [![License: MIT](https://img.shields.io/npm/l/dsh-plugin-wiki-tools)](LICENSE)
6
+ [![Awesome DSH Plugin](https://img.shields.io/badge/listed-awesome--dsh--plugin-4a90d9)](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
7
+
3
8
  English | [中文](#中文)
4
9
 
5
10
  Native [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) tools for an Obsidian wiki vault: `wiki_query`, `wiki_write`, and `wiki_lint` implement the mechanical core of the wiki skill suite — path routing, frontmatter completion, index/log bookkeeping, source delta tracking, and health checks — so the model spends its turns on synthesis instead of filesystem chores.
6
11
 
12
+ ```sh
13
+ dsh plugin --profile web add dsh-plugin-wiki-tools
14
+ ```
15
+
7
16
  Pair with **[dsh-plugin-wiki-skills](https://github.com/Lion-1209/dsh-plugin-wiki-skills)** for the prompt-level skills (`wiki`, `wiki-ingest`, `wiki-query`, `wiki-lint`, `save`).
8
17
 
18
+ *If this toolchain saves you time maintaining a wiki vault, a ⭐ helps other dsh users find it. 如果这个工具帮到了你,欢迎点个 Star。*
19
+
9
20
  ## Attribution
10
21
 
11
22
  The vault layout and operation contracts follow the LLM Wiki pattern (Andrej Karpathy) as embodied by [claude-obsidian](https://github.com/AgriciDaniel/claude-obsidian) (MIT, © 2026 AgriciDaniel). This package is an independent plain-ESM implementation of the mechanical core; it contains no code or skill text from claude-obsidian.
@@ -16,7 +27,7 @@ The vault layout and operation contracts follow the LLM Wiki pattern (Andrej Kar
16
27
  | --- | --- |
17
28
  | `wiki_query` | Quick mode returns `hot.md` + `index.md` verbatim (the skill's read order); standard mode is full-text search over content pages with snippets and the inbound/outbound link graph |
18
29
  | `wiki_write` | Writes one page with complete bookkeeping: type→folder routing, frontmatter completion (keeps `created` and unknown fields on update), filename-uniqueness guard, master-index entry, log entry; with `source_path`, records the source hash and skips unchanged sources unless `force` |
19
- | `wiki_lint` | Health check: duplicate filenames, dead wikilinks, orphan pages, frontmatter gaps, empty sections, stale index entries, stale hot cache — report only, with suggestions, written to `wiki/meta/lint-report-<date>.md` |
30
+ | `wiki_lint` | Health check: duplicate filenames, dead wikilinks, orphan pages, frontmatter gaps, empty sections, stale index entries, stale hot cache — report only, with suggestions, written to `wiki/meta/Lint Report <date>.md` |
20
31
 
21
32
  ## Install and configure
22
33
 
package/lib/lint.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Vault health checks: the mechanical half of the wiki-lint skill. Report
3
3
  * only — auto-fixing is a human decision, so every issue carries a suggestion
4
- * and the report lands in `wiki/meta/lint-report-YYYY-MM-DD.md`.
4
+ * and the report lands in `wiki/meta/Lint Report YYYY-MM-DD.md`.
5
5
  *
6
6
  * @module dsh-plugin-wiki-tools/lib/lint
7
7
  */
@@ -289,7 +289,7 @@ async function checkHotCacheStaleness(root, pages, add) {
289
289
  */
290
290
  async function writeReport(root, issues, pagesScanned) {
291
291
  const date = today()
292
- const path = join(root, 'wiki', 'meta', `lint-report-${date}.md`)
292
+ const path = join(root, 'wiki', 'meta', `Lint Report ${date}.md`)
293
293
  const sections = new Map()
294
294
  for (const issue of issues) {
295
295
  if (!sections.has(issue.check)) sections.set(issue.check, [])
package/lib/vault.js CHANGED
@@ -132,9 +132,32 @@ export class Vault {
132
132
  )
133
133
  /** Per-file write chains so concurrent tool calls serialize per target. */
134
134
  this.writeChains = new Map()
135
+ /** collectMarkdown result cache: { pages, ts }. Invalidated on every mutation. */
136
+ this._collectCache = null
135
137
  }
136
138
 
137
- /** Absolute path of one routed page. @param {string} type - page type. @param {string} title - page title (also the filename). */
139
+ /**
140
+ * Cached wrapper around the module-level collectMarkdown. The cache lives
141
+ * for 30 s or until the next invalidateCollectCache call, whichever comes
142
+ * first. Writes always invalidate first, so a writer never sees stale data
143
+ * from its own session. Cross-process writes are visible on the next call
144
+ * after the cache expires.
145
+ * @returns {Promise<ReturnType<typeof collectMarkdown>>}
146
+ */
147
+ async collectPages() {
148
+ if (this._collectCache !== null) {
149
+ const { pages, ts } = this._collectCache
150
+ if (Date.now() - ts < 30000) return pages
151
+ }
152
+ const pages = await collectMarkdown(join(this.root, 'wiki'))
153
+ this._collectCache = { pages, ts: Date.now() }
154
+ return pages
155
+ }
156
+
157
+ /** Drop the collectMarkdown cache. Call after every vault mutation. */
158
+ invalidateCollectCache() { this._collectCache = null }
159
+
160
+ /** Absolute path of one routed page. @param {string} type - page type. @param {string} title - page title (also the filename). */
138
161
  pagePath(type, title) {
139
162
  const folder = this.typeFolders[type]
140
163
  if (folder === undefined) throw new Error(`wiki-tools: unknown page type "${type}"`)
@@ -191,17 +214,21 @@ export class Vault {
191
214
  /**
192
215
  * Run one mutation under a cross-process advisory lock (the wiki-lock
193
216
  * contract): writers in other processes or sessions serialize on the same
194
- * lock file under `.vault-meta/locks/`. A held lock is retried once after
195
- * 2s and then reported; a lock older than lockStaleSeconds is treated as
196
- * crashed and broken.
217
+ * lock file under `.vault-meta/locks/`. Default semantics retry once after
218
+ * 2s and then report; pass `{ waitMs }` for patient polling (used by
219
+ * internal bookkeeping, where failing the write after the page file is
220
+ * already on disk would be worse than waiting). A lock older than
221
+ * lockStaleSeconds is treated as crashed and broken either way.
197
222
  * @param {string} key - lock key; vault-relative page path, or `__vault__`
198
223
  * for vault-wide mutations (rename, archive) and `__manifest__` for the
199
224
  * ingest manifest.
200
225
  * @param {() => Promise<T>} operation - the mutation to guard.
226
+ * @param {{ waitMs?: number }} [options] - poll until waitMs elapsed
227
+ * instead of the legacy retry-once.
201
228
  * @returns {Promise<T>}
202
229
  * @template T
203
230
  */
204
- async withFileLock(key, operation) {
231
+ async withFileLock(key, operation, { waitMs = 0 } = {}) {
205
232
  const locksDir = join(this.root, '.vault-meta', 'locks')
206
233
  await mkdir(locksDir, { recursive: true })
207
234
  const lockPath = join(locksDir, `${createHash('sha1').update(key).digest('hex')}.lock`)
@@ -221,11 +248,14 @@ export class Vault {
221
248
  return false
222
249
  }
223
250
  }
224
- if (!(await acquire())) {
225
- await new Promise(resolve => setTimeout(resolve, 2000))
226
- if (!(await acquire())) {
251
+ const deadline = Date.now() + waitMs
252
+ let attempt = 0
253
+ while (!(await acquire())) {
254
+ attempt += 1
255
+ if (Date.now() >= deadline && !(waitMs === 0 && attempt < 2)) {
227
256
  throw new Error(`wiki-tools: ${key} is locked by another writer; skipped (retry once it releases)`)
228
257
  }
258
+ await new Promise((resolve) => setTimeout(resolve, waitMs > 0 ? 200 : 2000))
229
259
  }
230
260
  try {
231
261
  return await operation()
@@ -333,11 +363,20 @@ export class Vault {
333
363
  const file = `---\n${stringifyYaml(fields).trimEnd()}\n---\n\n${content.replace(/^\s*\n/, '')}\n`
334
364
  await mkdir(join(path, '..'), { recursive: true })
335
365
  await writeFile(path, file, 'utf8')
336
- await this.updateIndex(type, cleanTitle, summary ?? firstContentLine(content))
337
- await this.updateFolderIndex(type, cleanTitle, summary ?? firstContentLine(content))
338
- await this.prependLog(`## [${date}] ${existing === undefined ? 'create' : 'update'} | ${cleanTitle}`, [
339
- `- ${existing === undefined ? 'Created' : 'Updated'}: [[${cleanTitle}]]`,
340
- ])
366
+ this.invalidateCollectCache()
367
+ // Index and log are shared state across every writer in the vault: the
368
+ // per-page lock covers this page's file, but two concurrent writes to
369
+ // DIFFERENT pages would otherwise read-modify-write index.md and log.md
370
+ // against each other (entries visibly lost). Serialize the bookkeeping
371
+ // under the vault-wide lock; lock ordering is page → __vault__, and no
372
+ // other path acquires a page lock while holding __vault__, so no cycle.
373
+ await this.withFileLock('__vault__', async () => {
374
+ await this.updateIndex(type, cleanTitle, summary ?? firstContentLine(content))
375
+ await this.updateFolderIndex(type, cleanTitle, summary ?? firstContentLine(content))
376
+ await this.prependLog(`## [${date}] ${existing === undefined ? 'create' : 'update'} | ${cleanTitle}`, [
377
+ `- ${existing === undefined ? 'Created' : 'Updated'}: [[${cleanTitle}]]`,
378
+ ])
379
+ }, { waitMs: this.lockStaleMs * 1000 })
341
380
  this.gitCommit(`wiki: ${existing === undefined ? 'create' : 'update'} ${cleanTitle}`)
342
381
  if (sourcePath !== undefined) {
343
382
  await this.trackSource({
@@ -349,7 +388,11 @@ export class Vault {
349
388
  // Surface dead-on-arrival wikilinks now, not at the next lint: forward
350
389
  // links to pages about to be created are legitimate, so this is a note,
351
390
  // never a rejection.
352
- const pagesNow = await collectMarkdown(join(this.root, 'wiki'))
391
+ // All vault mutations above this point may have invalidated earlier
392
+ // cache entries; force a fresh scan so the unresolved-links report
393
+ // reflects every page the vault actually contains right now.
394
+ this.invalidateCollectCache()
395
+ const pagesNow = await this.collectPages()
353
396
  const names = new Set(pagesNow.map(page => page.name))
354
397
  const aliases = buildAliasMap(pagesNow)
355
398
  const unresolvedLinks = [...new Set(extractWikilinks(content))]
@@ -414,7 +457,7 @@ export class Vault {
414
457
  throw new Error(`wiki-tools: newTitle must be a plain filename without path separators (got ${JSON.stringify(newTitle)})`)
415
458
  }
416
459
  if (cleanNew === title) throw new Error('wiki-tools: newTitle equals the current title')
417
- const pages = await collectMarkdown(join(this.root, 'wiki'))
460
+ const pages = await this.collectPages()
418
461
  const target = pages.find(page => page.name === title)
419
462
  if (target === undefined) throw new Error(`wiki-tools: no page named "${title}" exists in the vault`)
420
463
  if (isMachineryPage(title)) {
@@ -430,7 +473,7 @@ export class Vault {
430
473
  // 1. Rewrite links everywhere except immutable records.
431
474
  const rewritten = []
432
475
  for (const page of pages) {
433
- if (page.name === title || page.name.toLowerCase() === 'log' || /^lint-report-/.test(page.name)) continue
476
+ if (page.name === title || page.name.toLowerCase() === 'log' || isMachineryPage(page.name)) continue
434
477
  const raw = await readFile(page.path, 'utf8').catch(() => undefined)
435
478
  if (raw === undefined) continue
436
479
  // Exact-target boundary: the title must end the link target (`]]`),
@@ -465,6 +508,7 @@ export class Vault {
465
508
  `- Renamed: [[${title}]] → [[${cleanNew}]] (${rewritten.length} files' links rewritten)`,
466
509
  ])
467
510
  this.gitCommit(`wiki: rename ${title} -> ${cleanNew}`)
511
+ this.invalidateCollectCache()
468
512
  return { from: title, to: cleanNew, path: newPath, linksRewritten: rewritten.length, filesRewritten: rewritten }
469
513
  }))
470
514
  }
@@ -503,6 +547,7 @@ export class Vault {
503
547
  `- Archived: \`${rel}\` → \`.archive/${rel.slice('.raw/'.length)}\``,
504
548
  ])
505
549
  this.gitCommit(`wiki: archive ${rel}`)
550
+ this.invalidateCollectCache()
506
551
  return { archivedFrom: rel, archivedTo: `.archive/${rel.slice('.raw/'.length)}` }
507
552
  }))
508
553
  }
@@ -530,7 +575,7 @@ export class Vault {
530
575
  * @param {string} path - the routed destination path.
531
576
  */
532
577
  async assertUniqueFilename(title, path) {
533
- const pages = await collectMarkdown(join(this.root, 'wiki'))
578
+ const pages = await this.collectPages()
534
579
  for (const page of pages) {
535
580
  if (page.name.toLowerCase() === title.toLowerCase() && page.path !== path) {
536
581
  throw new Error(`wiki-tools: filename "${title}.md" already exists at ${page.path}; wikilinks need unique filenames`)
@@ -560,7 +605,8 @@ export class Vault {
560
605
  const lines = raw.split('\n')
561
606
  let headingLine = lines.findIndex(line => line === heading)
562
607
  if (headingLine < 0) {
563
- lines.push('', heading, `- [[${title}]]: ${summary.replace(/\n/g, ' ')}`)
608
+ const separator = dominantSeparator(raw)
609
+ lines.push('', heading, `- [[${title}]]${separator} ${summary.replace(/\n/g, ' ')}`)
564
610
  await writeFile(indexPath, `${lines.join('\n')}\n`, 'utf8')
565
611
  return
566
612
  }
@@ -723,6 +769,20 @@ function indexSeparator(section, matched) {
723
769
  return dashes > colons ? ' —' : ':'
724
770
  }
725
771
 
772
+ /**
773
+ * Pick the dominant separator style across all index entry lines in one body
774
+ * of text: ` — ` (em-dash) if it appears in more entries than `: `, else colon.
775
+ * Used when a new section has no prior entries to copy style from.
776
+ * @param {string} text - the full index text.
777
+ * @returns {string} either ' —' or ':'.
778
+ */
779
+ function dominantSeparator(text) {
780
+ const entries = text.split('\n').filter(line => /^- \[\[/.test(line))
781
+ const dashCount = entries.filter(line => line.includes(' — ')).length
782
+ const colonCount = entries.filter(line => line.includes(': ')).length
783
+ return dashCount > colonCount ? ' —' : ':'
784
+ }
785
+
726
786
  /**
727
787
  * Read a JSON file, returning the fallback when absent.
728
788
  * @param {string} path - absolute file path.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-wiki-tools",
3
- "version": "0.14.0",
3
+ "version": "0.14.2",
4
4
  "description": "Native DeepSeek Harness tools for an Obsidian wiki vault: wiki_query, wiki_write, and wiki_lint implement the mechanical core (path routing, delta tracking, index/log bookkeeping, health checks) of the wiki skill suite.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -11,7 +11,8 @@
11
11
  "./lib/search.js": "./lib/search.js",
12
12
  "./lib/lint.js": "./lib/lint.js",
13
13
  "./cordis.patch.yml": "./cordis.patch.yml",
14
- "./package.json": "./package.json"
14
+ "./package.json": "./package.json",
15
+ "./lib/scaffold.js": "./lib/scaffold.js"
15
16
  },
16
17
  "files": [
17
18
  "index.js",