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 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
- if (/^#{1,6} /.test(line)) {
169
- if (heading !== undefined && !hasContent) {
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 = line.replace(/^#+\s*/, '').replace('__eof__', '').trim()
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
- for (const target of page.links) {
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.length,
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 heading per page type, in catalog order. */
31
- const INDEX_SECTIONS = {
32
- domain: '## Domains',
33
- entity: '## Entities',
34
- concept: '## Concepts',
35
- source: '## Sources',
36
- question: '## Questions',
37
- comparison: '## Comparisons',
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
- /** @param {string} root - absolute path to the vault root (the directory holding `wiki/` and `.raw/`). */
77
- constructor(root) {
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 = TYPE_FOLDERS[type]
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 `- [[Title]]: summary` entry in the master index.
199
- * Missing sections and files are created on first use.
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 = INDEX_SECTIONS[type]
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(INDEX_SECTIONS)) raw += `${section}\n\n`
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, entry)
219
- await writeFile(indexPath, lines.join('\n').replace(/\n{3,}/g, '\n\n'), 'utf8')
238
+ lines.push('', heading, `- [[${title}]]: ${summary.replace(/\n/g, ' ')}`)
239
+ await writeFile(indexPath, `${lines.join('\n')}\n`, 'utf8')
220
240
  return
221
241
  }
222
- const linkPrefix = `- [[${title}]]`
223
- let cursor = headingLine + 1
224
- while (cursor < lines.length && lines[cursor] !== '' && !lines[cursor].startsWith('## ')) {
225
- if (lines[cursor].startsWith(linkPrefix)) {
226
- lines[cursor] = entry
227
- await writeFile(indexPath, lines.join('\n'), 'utf8')
228
- return
229
- }
230
- cursor += 1
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.splice(cursor, 0, entry)
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.0",
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",