dsh-plugin-wiki-tools 0.1.0 → 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.
- package/README.md +5 -1
- package/index.js +11 -1
- package/lib/lint.js +13 -5
- package/lib/search.js +3 -2
- package/lib/vault.js +74 -30
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -30,9 +30,13 @@ dsh plugin --profile web add dsh-plugin-wiki-tools
|
|
|
30
30
|
- id: wiki-tools
|
|
31
31
|
config:
|
|
32
32
|
vaultPath: /absolute/path/to/vault
|
|
33
|
+
# optional: reroute page types to your vault's folders; index section
|
|
34
|
+
# headings follow the mapped folder name (e.g. "## Areas")
|
|
35
|
+
typeFolders:
|
|
36
|
+
domain: wiki/areas
|
|
33
37
|
```
|
|
34
38
|
|
|
35
|
-
The vault is the directory holding `wiki/` and `.raw/` — scaffold it first with the `wiki` skill's SCAFFOLD operation. `maxQueryResults` (default 10) is optional.
|
|
39
|
+
The vault is the directory holding `wiki/` and `.raw/` — scaffold it first with the `wiki` skill's SCAFFOLD operation. `maxQueryResults` (default 10) is optional. `wiki_write` refreshes existing index entries in the section's own style (`: ` or ` — `), so em-dash vaults stay consistent.
|
|
36
40
|
|
|
37
41
|
## Design notes
|
|
38
42
|
|
package/index.js
CHANGED
|
@@ -26,6 +26,16 @@ export const Config = z.object({
|
|
|
26
26
|
vaultPath: z.string().required(),
|
|
27
27
|
/** Maximum pages returned by one wiki_query standard-mode call. */
|
|
28
28
|
maxQueryResults: z.number().default(10),
|
|
29
|
+
/** Per-type folder overrides over the default routing, e.g. `{ domain: "wiki/areas" }`. */
|
|
30
|
+
typeFolders: z.object({
|
|
31
|
+
source: z.string(),
|
|
32
|
+
entity: z.string(),
|
|
33
|
+
concept: z.string(),
|
|
34
|
+
domain: z.string(),
|
|
35
|
+
question: z.string(),
|
|
36
|
+
comparison: z.string(),
|
|
37
|
+
meta: z.string(),
|
|
38
|
+
}).default({}),
|
|
29
39
|
})
|
|
30
40
|
|
|
31
41
|
const PAGE_TYPES = ['source', 'entity', 'concept', 'domain', 'question', 'comparison', 'meta']
|
|
@@ -201,7 +211,7 @@ export async function apply(ctx, config) {
|
|
|
201
211
|
+ 'The vault is the directory holding wiki/ and .raw/ (scaffold it with the wiki skill first).',
|
|
202
212
|
)
|
|
203
213
|
}
|
|
204
|
-
const vault = new Vault(config.vaultPath)
|
|
214
|
+
const vault = new Vault(config.vaultPath, config.typeFolders ?? {})
|
|
205
215
|
await vault.assertRoot()
|
|
206
216
|
for (const tool of createTools(vault, config)) {
|
|
207
217
|
ctx.tools.register(tool)
|
package/lib/lint.js
CHANGED
|
@@ -53,8 +53,10 @@ export async function lintVault(root) {
|
|
|
53
53
|
* @param {Add} add - issue recorder.
|
|
54
54
|
*/
|
|
55
55
|
function checkDuplicateFilenames(pages, add) {
|
|
56
|
+
const isMachinery = page => META_FILENAMES.has(page.name.toLowerCase()) || page.name.startsWith('_')
|
|
56
57
|
const seen = new Map()
|
|
57
58
|
for (const page of pages) {
|
|
59
|
+
if (isMachinery(page)) continue
|
|
58
60
|
const key = page.name.toLowerCase()
|
|
59
61
|
if (seen.has(key)) {
|
|
60
62
|
add('duplicate-filename', 'error', page.name,
|
|
@@ -160,18 +162,24 @@ function checkFrontmatterGaps(pages, add) {
|
|
|
160
162
|
*/
|
|
161
163
|
function checkEmptySections(pages, add) {
|
|
162
164
|
for (const page of pages) {
|
|
163
|
-
if (META_FILENAMES.has(page.name.toLowerCase())) continue
|
|
164
|
-
const lines = [...page.content.split('\n'), '# __eof__']
|
|
165
|
+
if (META_FILENAMES.has(page.name.toLowerCase()) || page.name.startsWith('_')) continue
|
|
166
|
+
const lines = [...page.content.split('\n'), '# __eof__ sentinel']
|
|
165
167
|
let heading
|
|
168
|
+
let headingLevel = 0
|
|
166
169
|
let hasContent = false
|
|
167
170
|
for (const line of lines) {
|
|
168
|
-
|
|
169
|
-
|
|
171
|
+
const match = /^(#{1,6}) (.+)$/.exec(line)
|
|
172
|
+
if (match !== null) {
|
|
173
|
+
const nextLevel = match[1].length
|
|
174
|
+
// A heading followed directly by a deeper heading is a container, not
|
|
175
|
+
// an empty section; only same-or-shallow succession or EOF is empty.
|
|
176
|
+
if (heading !== undefined && !hasContent && nextLevel <= headingLevel) {
|
|
170
177
|
add('empty-section', 'info', page.name,
|
|
171
178
|
`section "${heading}" has no content`,
|
|
172
179
|
'Fill it, or remove the heading')
|
|
173
180
|
}
|
|
174
|
-
heading =
|
|
181
|
+
heading = match[2]
|
|
182
|
+
headingLevel = nextLevel
|
|
175
183
|
hasContent = false
|
|
176
184
|
} else if (line.trim().length > 0) {
|
|
177
185
|
hasContent = true
|
package/lib/search.js
CHANGED
|
@@ -45,7 +45,8 @@ export async function searchVault(root, { query, limit = 10 }) {
|
|
|
45
45
|
!META_FILENAMES.has(page.name.toLowerCase()) && !page.name.startsWith('_'))
|
|
46
46
|
const inbound = new Map(pages.map(page => [page.name, []]))
|
|
47
47
|
for (const page of searchable) {
|
|
48
|
-
|
|
48
|
+
// One inbound record per source page, however many times it links.
|
|
49
|
+
for (const target of new Set(page.links)) {
|
|
49
50
|
inbound.get(target)?.push(page.name)
|
|
50
51
|
}
|
|
51
52
|
}
|
|
@@ -68,7 +69,7 @@ export async function searchVault(root, { query, limit = 10 }) {
|
|
|
68
69
|
score,
|
|
69
70
|
snippets: matchLines(page.content, needle).slice(0, 2),
|
|
70
71
|
inbound: inbound.get(page.name) ?? [],
|
|
71
|
-
outbound: page.links.
|
|
72
|
+
outbound: new Set(page.links).size,
|
|
72
73
|
})
|
|
73
74
|
}
|
|
74
75
|
results.sort((left, right) => right.score - left.score || left.name.localeCompare(right.name))
|
package/lib/vault.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { createHash } from 'node:crypto'
|
|
15
15
|
import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises'
|
|
16
|
-
import { isAbsolute, join, relative, sep } from 'node:path'
|
|
16
|
+
import { basename, isAbsolute, join, relative, sep } from 'node:path'
|
|
17
17
|
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
|
|
18
18
|
|
|
19
19
|
/** Page-type to vault folder routing (the suite's generic mode). */
|
|
@@ -27,14 +27,15 @@ export const TYPE_FOLDERS = {
|
|
|
27
27
|
meta: 'wiki/meta',
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
/** Master-index section
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
30
|
+
/** Master-index section headings follow the mapped folder basename, computed per vault. */
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Capitalize a folder basename for an index section heading.
|
|
34
|
+
* @param {string} value - lowercase folder basename.
|
|
35
|
+
* @returns {string} the capitalized heading title.
|
|
36
|
+
*/
|
|
37
|
+
function capitalize(value) {
|
|
38
|
+
return value.length === 0 ? value : value[0].toUpperCase() + value.slice(1)
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
/** Pages that are vault machinery, never linted as content (basenames, no extension). */
|
|
@@ -73,19 +74,37 @@ export function today() {
|
|
|
73
74
|
* entry in one serialized write per file.
|
|
74
75
|
*/
|
|
75
76
|
export class Vault {
|
|
76
|
-
/**
|
|
77
|
-
|
|
77
|
+
/**
|
|
78
|
+
* @param {string} root - absolute path to the vault root (the directory holding `wiki/` and `.raw/`).
|
|
79
|
+
* @param {Record<string, string>} [typeFolders] - per-type folder overrides over {@link TYPE_FOLDERS}
|
|
80
|
+
* (e.g. `{ domain: 'wiki/areas' }` for a vault whose top-level topics live in `areas/`).
|
|
81
|
+
*/
|
|
82
|
+
constructor(root, typeFolders = {}) {
|
|
78
83
|
if (typeof root !== 'string' || root.length === 0 || !isAbsolute(root)) {
|
|
79
84
|
throw new Error(`wiki-tools: vaultPath must be an absolute directory path (got ${JSON.stringify(root)})`)
|
|
80
85
|
}
|
|
81
86
|
this.root = root
|
|
87
|
+
this.typeFolders = { ...TYPE_FOLDERS }
|
|
88
|
+
for (const [type, folder] of Object.entries(typeFolders)) {
|
|
89
|
+
if (!(type in this.typeFolders)) {
|
|
90
|
+
throw new Error(`wiki-tools: typeFolders config names unknown type "${type}"`)
|
|
91
|
+
}
|
|
92
|
+
if (typeof folder !== 'string' || folder.length === 0 || folder.includes('..')) {
|
|
93
|
+
throw new Error(`wiki-tools: typeFolders.${type} must be a non-empty vault-relative folder path`)
|
|
94
|
+
}
|
|
95
|
+
this.typeFolders[type] = folder
|
|
96
|
+
}
|
|
97
|
+
/** Index section heading per type, following the effective folder layout. */
|
|
98
|
+
this.indexSections = Object.fromEntries(
|
|
99
|
+
Object.keys(TYPE_FOLDERS).map(type => [type, `## ${capitalize(basename(this.typeFolders[type]))}`]),
|
|
100
|
+
)
|
|
82
101
|
/** Per-file write chains so concurrent tool calls serialize per target. */
|
|
83
102
|
this.writeChains = new Map()
|
|
84
103
|
}
|
|
85
104
|
|
|
86
105
|
/** Absolute path of one routed page. @param {string} type - page type. @param {string} title - page title (also the filename). */
|
|
87
106
|
pagePath(type, title) {
|
|
88
|
-
const folder =
|
|
107
|
+
const folder = this.typeFolders[type]
|
|
89
108
|
if (folder === undefined) throw new Error(`wiki-tools: unknown page type "${type}"`)
|
|
90
109
|
if (!/^[^/\\]+(\.md)?$/.test(title) || title.includes('\n')) {
|
|
91
110
|
throw new Error(`wiki-tools: title must be a plain filename without path separators (got ${JSON.stringify(title)})`)
|
|
@@ -195,42 +214,44 @@ export class Vault {
|
|
|
195
214
|
}
|
|
196
215
|
|
|
197
216
|
/**
|
|
198
|
-
* Add or refresh one
|
|
199
|
-
*
|
|
217
|
+
* Add or refresh one `[[Title]]` entry in the master index. Missing sections
|
|
218
|
+
* and files are created on first use. Existing entries are matched regardless
|
|
219
|
+
* of separator (`: ` or ` — `) and rewritten in the section's dominant style,
|
|
220
|
+
* so vaults using em-dash indexes stay consistent.
|
|
200
221
|
* @param {string} type - page type selecting the index section.
|
|
201
222
|
* @param {string} title - page title.
|
|
202
223
|
* @param {string} summary - one-line description.
|
|
203
224
|
*/
|
|
204
225
|
async updateIndex(type, title, summary) {
|
|
205
|
-
const heading =
|
|
226
|
+
const heading = this.indexSections[type]
|
|
206
227
|
if (heading === undefined) return
|
|
207
228
|
const indexPath = join(this.root, 'wiki', 'index.md')
|
|
208
229
|
let raw = await readFile(indexPath, 'utf8').catch(() => undefined)
|
|
209
230
|
if (raw === undefined) {
|
|
210
231
|
raw = '# Wiki Index\n\n'
|
|
211
|
-
for (const section of Object.values(
|
|
232
|
+
for (const section of Object.values(this.indexSections)) raw += `${section}\n\n`
|
|
212
233
|
await mkdir(join(indexPath, '..'), { recursive: true })
|
|
213
234
|
}
|
|
214
|
-
const entry = `- [[${title}]]: ${summary.replace(/\n/g, ' ')}`
|
|
215
235
|
const lines = raw.split('\n')
|
|
216
236
|
let headingLine = lines.findIndex(line => line === heading)
|
|
217
237
|
if (headingLine < 0) {
|
|
218
|
-
lines.push('', heading,
|
|
219
|
-
await writeFile(indexPath, lines.join('\n')
|
|
238
|
+
lines.push('', heading, `- [[${title}]]: ${summary.replace(/\n/g, ' ')}`)
|
|
239
|
+
await writeFile(indexPath, `${lines.join('\n')}\n`, 'utf8')
|
|
220
240
|
return
|
|
221
241
|
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
242
|
+
let end = headingLine + 1
|
|
243
|
+
while (end < lines.length && lines[end] !== '' && !lines[end].startsWith('## ')) end += 1
|
|
244
|
+
const section = lines.slice(headingLine + 1, end)
|
|
245
|
+
const entryPattern = new RegExp(`^\\s*-?\\s*\\[\\[${escapeRegExp(title)}\\]\\]`)
|
|
246
|
+
const at = section.findIndex(line => entryPattern.test(line))
|
|
247
|
+
const separator = indexSeparator(section, at >= 0 ? section[at] : undefined)
|
|
248
|
+
const entry = `- [[${title}]]${separator} ${summary.replace(/\n/g, ' ')}`
|
|
249
|
+
if (at >= 0) {
|
|
250
|
+
lines[headingLine + 1 + at] = entry
|
|
251
|
+
} else {
|
|
252
|
+
lines.splice(end, 0, entry)
|
|
231
253
|
}
|
|
232
|
-
lines.
|
|
233
|
-
await writeFile(indexPath, lines.join('\n'), 'utf8')
|
|
254
|
+
await writeFile(indexPath, `${lines.join('\n')}\n`, 'utf8')
|
|
234
255
|
}
|
|
235
256
|
|
|
236
257
|
/**
|
|
@@ -295,6 +316,29 @@ function firstContentLine(content) {
|
|
|
295
316
|
return '(no summary)'
|
|
296
317
|
}
|
|
297
318
|
|
|
319
|
+
/**
|
|
320
|
+
* Escape regex metacharacters so a title containing `(`, `)`, `+`, etc. matches literally.
|
|
321
|
+
* @param {string} value - raw title.
|
|
322
|
+
* @returns {string} regex-safe text.
|
|
323
|
+
*/
|
|
324
|
+
function escapeRegExp(value) {
|
|
325
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Pick the index separator for one entry: reuse the matched line's style, else
|
|
330
|
+
* the section's dominant style (`: ` or ` — `), else the canonical colon.
|
|
331
|
+
* @param {string[]} section - existing entry lines of the index section.
|
|
332
|
+
* @param {string | undefined} matched - the line being refreshed, when present.
|
|
333
|
+
* @returns {string} the separator text before the summary.
|
|
334
|
+
*/
|
|
335
|
+
function indexSeparator(section, matched) {
|
|
336
|
+
if (matched !== undefined && matched.includes(' — ')) return ' —'
|
|
337
|
+
const dashes = section.filter(line => line.includes(' — ')).length
|
|
338
|
+
const colons = section.filter(line => /: /.test(line)).length
|
|
339
|
+
return dashes > colons ? ' —' : ':'
|
|
340
|
+
}
|
|
341
|
+
|
|
298
342
|
/**
|
|
299
343
|
* Read a JSON file, returning the fallback when absent.
|
|
300
344
|
* @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.1
|
|
3
|
+
"version": "0.2.1",
|
|
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",
|