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 +12 -1
- package/lib/lint.js +2 -2
- package/lib/vault.js +78 -18
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
# dsh-plugin-wiki-tools
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/dsh-plugin-wiki-tools)
|
|
4
|
+
[](https://www.npmjs.com/package/dsh-plugin-wiki-tools)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](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/
|
|
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/
|
|
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', `
|
|
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
|
-
|
|
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/`.
|
|
195
|
-
* 2s and then
|
|
196
|
-
*
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
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
|
|
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' ||
|
|
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
|
|
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
|
-
|
|
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.
|
|
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",
|