dsh-plugin-wiki-tools 0.2.1 → 0.4.0

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/index.js CHANGED
@@ -76,9 +76,7 @@ export function createTools(vault, options = {}) {
76
76
  },
77
77
  render: (_args, value) => [{
78
78
  type: 'text',
79
- text: typeof value === 'object' && value !== null && 'results' in value
80
- ? `wiki_query: ${value.results.length} of ${value.totalMatches} matching pages`
81
- : 'wiki_query: returned hot cache and master index',
79
+ text: renderQueryResult(value),
82
80
  }],
83
81
  },
84
82
  async execute(args) {
@@ -181,9 +179,7 @@ export function createTools(vault, options = {}) {
181
179
  },
182
180
  render: (_args, value) => [{
183
181
  type: 'text',
184
- text: typeof value === 'object' && value !== null && 'summary' in value
185
- ? `wiki_lint: ${value.summary.issues} issues across ${value.summary.pagesScanned} pages; report at ${value.reportPath ?? '(not written)'}`
186
- : 'wiki_lint: failed',
182
+ text: renderLintResult(value),
187
183
  }],
188
184
  },
189
185
  async execute() {
@@ -195,6 +191,68 @@ export function createTools(vault, options = {}) {
195
191
  return [wikiQuery, wikiWrite, wikiLint]
196
192
  }
197
193
 
194
+ /**
195
+ * Render a wiki_query result as the model-facing text. The render output is the
196
+ * ONLY content the model sees, so it carries the full payload — hot cache and
197
+ * index verbatim in quick mode, every result with path, snippets, and link
198
+ * context in standard mode — not a summary line.
199
+ * @param {unknown} value - the execute return value.
200
+ * @returns {string} the model-facing result text.
201
+ */
202
+ function renderQueryResult(value) {
203
+ if (typeof value !== 'object' || value === null) return 'wiki_query: no result'
204
+ const result = value
205
+ if (result.mode === 'quick') {
206
+ const parts = []
207
+ if (typeof result.hot === 'string' && result.hot.length > 0) {
208
+ parts.push(`--- wiki/hot.md (recent context cache) ---\n${capText(result.hot, 6000)}`)
209
+ }
210
+ if (typeof result.index === 'string' && result.index.length > 0) {
211
+ parts.push(`--- wiki/index.md (master catalog) ---\n${capText(result.index, 8000)}`)
212
+ }
213
+ if (parts.length === 0) return 'wiki_query (quick): the vault has no hot.md or index.md yet'
214
+ return `wiki_query (quick): hot cache and master index follow. Read these before any page.\n\n${parts.join('\n\n')}`
215
+ }
216
+ if (!Array.isArray(result.results)) return 'wiki_query: no result'
217
+ const lines = [`wiki_query: ${result.results.length} of ${result.totalMatches} matching pages. Open a page with the fs read tool for full content.`]
218
+ for (const hit of result.results) {
219
+ lines.push(`\n### ${hit.name}`)
220
+ lines.push(`path: ${hit.path} · score ${hit.score} · inbound ${hit.inbound.length}${hit.inbound.length > 0 ? ` (${hit.inbound.slice(0, 5).join(', ')})` : ''} · outbound ${hit.outbound}`)
221
+ for (const snippet of hit.snippets) lines.push(`> ${snippet}`)
222
+ }
223
+ return capText(lines.join('\n'), 12000)
224
+ }
225
+
226
+ /**
227
+ * Render a wiki_lint result: every issue with page, severity, and suggestion.
228
+ * @param {unknown} value - the execute return value.
229
+ * @returns {string} the model-facing result text.
230
+ */
231
+ function renderLintResult(value) {
232
+ if (typeof value !== 'object' || value === null || !('summary' in value)) return 'wiki_lint: failed'
233
+ const result = value
234
+ const lines = [
235
+ `wiki_lint: ${result.summary.issues} issues across ${result.summary.pagesScanned} pages.`,
236
+ `checks: ${Object.entries(result.summary.byCheck ?? {}).map(([check, count]) => `${check}×${count}`).join(', ') || 'none'}`,
237
+ `full report: ${typeof result.reportPath === 'string' ? result.reportPath : '(not written)'}`,
238
+ ]
239
+ for (const issue of (result.issues ?? []).slice(0, 60)) {
240
+ lines.push(`- [${issue.severity}] ${issue.check} · ${issue.page}: ${issue.detail} → ${issue.suggestion}`)
241
+ }
242
+ if ((result.issues ?? []).length > 60) lines.push(`… and ${result.issues.length - 60} more (see the report file)`)
243
+ return lines.join('\n')
244
+ }
245
+
246
+ /**
247
+ * Cap one text block, marking the cut.
248
+ * @param {string} text - full text.
249
+ * @param {number} max - maximum characters kept.
250
+ * @returns {string} the capped text.
251
+ */
252
+ function capText(text, max) {
253
+ return text.length <= max ? text : `${text.slice(0, max)}\n… (truncated at ${max} characters)`
254
+ }
255
+
198
256
  /**
199
257
  * Validate the deployment config and register the three wiki tools.
200
258
  * @param {import('@deepseek-ai/cordis').Context} ctx - registrant context carrying the tool registry.
package/lib/lint.js CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { mkdir, readFile, writeFile } from 'node:fs/promises'
10
10
  import { join } from 'node:path'
11
- import { collectMarkdown, META_FILENAMES, splitFrontmatter, today } from './vault.js'
11
+ import { collectMarkdown, isMachineryPage, splitFrontmatter, today } from './vault.js'
12
12
 
13
13
  /** Frontmatter fields every content page must carry. */
14
14
  const REQUIRED_FIELDS = ['type', 'status', 'created', 'updated', 'tags']
@@ -53,10 +53,9 @@ 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('_')
57
56
  const seen = new Map()
58
57
  for (const page of pages) {
59
- if (isMachinery(page)) continue
58
+ if (isMachineryPage(page.name)) continue
60
59
  const key = page.name.toLowerCase()
61
60
  if (seen.has(key)) {
62
61
  add('duplicate-filename', 'error', page.name,
@@ -103,6 +102,7 @@ function checkDeadLinks(pages, add) {
103
102
  const names = new Set(pages.map(page => page.name))
104
103
  const inbound = new Map(pages.map(page => [page.name, []]))
105
104
  for (const page of pages) {
105
+ if (isMachineryPage(page.name)) continue
106
106
  for (const target of page.links) {
107
107
  if (names.has(target)) {
108
108
  inbound.get(target)?.push(page.name)
@@ -126,7 +126,7 @@ function checkDeadLinks(pages, add) {
126
126
  function checkOrphans(pages, inbound, indexed, add) {
127
127
  for (const page of pages) {
128
128
  const base = page.name.toLowerCase()
129
- if (META_FILENAMES.has(base) || page.name.startsWith('_')) continue
129
+ if (isMachineryPage(page.name)) continue
130
130
  const links = inbound.get(page.name) ?? []
131
131
  const isIndexed = indexed.has(page.name)
132
132
  if (links.length === 0 && !isIndexed) {
@@ -145,7 +145,7 @@ function checkOrphans(pages, inbound, indexed, add) {
145
145
  function checkFrontmatterGaps(pages, add) {
146
146
  for (const page of pages) {
147
147
  const base = page.name.toLowerCase()
148
- if (META_FILENAMES.has(base) || page.name.startsWith('_')) continue
148
+ if (isMachineryPage(page.name)) continue
149
149
  const missing = REQUIRED_FIELDS.filter(field => page.fields?.[field] === undefined)
150
150
  if (missing.length > 0) {
151
151
  add('frontmatter-gap', 'warn', page.name,
@@ -162,12 +162,25 @@ function checkFrontmatterGaps(pages, add) {
162
162
  */
163
163
  function checkEmptySections(pages, add) {
164
164
  for (const page of pages) {
165
- if (META_FILENAMES.has(page.name.toLowerCase()) || page.name.startsWith('_')) continue
165
+ if (isMachineryPage(page.name)) continue
166
+ // Fence-aware scan: lines inside code fences are content (a section whose
167
+ // body is only code is not empty) and heading-like comment lines inside
168
+ // fences never open sections.
166
169
  const lines = [...page.content.split('\n'), '# __eof__ sentinel']
167
170
  let heading
168
171
  let headingLevel = 0
169
172
  let hasContent = false
173
+ let inFence = false
170
174
  for (const line of lines) {
175
+ if (line.startsWith('```')) {
176
+ inFence = !inFence
177
+ hasContent = true
178
+ continue
179
+ }
180
+ if (inFence) {
181
+ hasContent = true
182
+ continue
183
+ }
171
184
  const match = /^(#{1,6}) (.+)$/.exec(line)
172
185
  if (match !== null) {
173
186
  const nextLevel = match[1].length
package/lib/search.js CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { readFile } from 'node:fs/promises'
10
10
  import { join } from 'node:path'
11
- import { collectMarkdown, META_FILENAMES } from './vault.js'
11
+ import { collectMarkdown, isMachineryPage } from './vault.js'
12
12
 
13
13
  /**
14
14
  * Answer the quick mode: the hot cache and master index verbatim. The caller
@@ -40,9 +40,9 @@ export async function searchVault(root, { query, limit = 10 }) {
40
40
  const pages = await collectMarkdown(join(root, 'wiki'))
41
41
  // The link graph covers every file, but results exclude vault machinery:
42
42
  // the index matches nearly every term by construction and is already the
43
- // quick-mode payload.
44
- const searchable = pages.filter(page =>
45
- !META_FILENAMES.has(page.name.toLowerCase()) && !page.name.startsWith('_'))
43
+ // quick-mode payload, and dated lint reports quote past wikilinks as records,
44
+ // not graph edges — so they neither appear as hits nor count as inbound.
45
+ const searchable = pages.filter(page => !isMachineryPage(page.name))
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.
package/lib/vault.js CHANGED
@@ -390,15 +390,33 @@ export async function collectMarkdown(directory) {
390
390
 
391
391
  /**
392
392
  * Extract wikilink targets from markdown, dropping aliases and heading anchors.
393
+ * Fenced code blocks and inline code spans are not links: examples written as
394
+ * `` `[[Target]]` `` must not join the graph.
393
395
  * @param {string} content - markdown body.
394
396
  * @returns {string[]} link targets in order of appearance.
395
397
  */
396
398
  export function extractWikilinks(content) {
397
399
  const links = []
398
- const withoutFences = content.replace(/```[\s\S]*?```/g, '')
399
- for (const match of withoutFences.matchAll(/\[\[([^\]]+)\]\]/g)) {
400
+ const withoutCode = content
401
+ .replace(/```[\s\S]*?```/g, '')
402
+ .replace(/`[^`\n]*`/g, '')
403
+ for (const match of withoutCode.matchAll(/\[\[([^\]]+)\]\]/g)) {
400
404
  const target = match[1].split('|')[0].split('#')[0].trim()
401
405
  if (target.length > 0) links.push(target)
402
406
  }
403
407
  return links
404
408
  }
409
+
410
+ /**
411
+ * Whether a page basename is vault machinery rather than content: the index,
412
+ * log, hot cache, overview, per-folder sub-indexes, and dated lint reports.
413
+ * Lint reports quote past issues verbatim, so their wikilinks are records, not
414
+ * graph edges.
415
+ * @param {string} name - page basename without extension.
416
+ * @returns {boolean} whether the page is machinery.
417
+ */
418
+ export function isMachineryPage(name) {
419
+ return META_FILENAMES.has(name.toLowerCase())
420
+ || name.startsWith('_')
421
+ || /^lint-report-/.test(name)
422
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-wiki-tools",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
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",