dsh-plugin-wiki-tools 0.8.0 → 0.9.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
@@ -257,6 +257,37 @@ export function createTools(vault, options = {}) {
257
257
  presentCall: args => ({ card: 'generic', title: `Scaffold wiki vault (${args.mode})`, kind: 'other', rawInput: { mode: args.mode } }),
258
258
  })
259
259
 
260
+ const wikiArchive = defineTool({
261
+ name: 'wiki_archive',
262
+ description:
263
+ 'Archive one cold raw source: move it from .raw/ to .archive/ (same subpath, file kept on disk) '
264
+ + 'and drop its manifest entry so future ingests treat it as new. Use when a source is no longer '
265
+ + 'active but should not be deleted. Pages derived from it are untouched.',
266
+ parameters: {
267
+ source_path: {
268
+ type: 'string',
269
+ required: true,
270
+ description: 'Vault-relative .raw/ source path, e.g. .raw/articles/note.md.',
271
+ },
272
+ },
273
+ output: {
274
+ schema: {
275
+ type: 'object',
276
+ additionalProperties: true,
277
+ },
278
+ render: (_args, value) => [{
279
+ type: 'text',
280
+ text: typeof value === 'object' && value !== null && 'archivedTo' in value
281
+ ? 'wiki_archive: ' + value.archivedFrom + ' → ' + value.archivedTo + ' (manifest entry dropped)'
282
+ : 'wiki_archive: failed',
283
+ }],
284
+ },
285
+ async execute(args) {
286
+ return await vault.archiveSource({ sourcePath: args.source_path })
287
+ },
288
+ presentCall: args => ({ card: 'generic', title: 'Archive source: ' + args.source_path, kind: 'other', rawInput: { source: args.source_path } }),
289
+ })
290
+
260
291
  const wikiLint = defineTool({
261
292
  name: 'wiki_lint',
262
293
  description:
@@ -280,7 +311,7 @@ export function createTools(vault, options = {}) {
280
311
  presentCall: () => ({ card: 'generic', title: 'Lint wiki vault', kind: 'other' }),
281
312
  })
282
313
 
283
- return [wikiQuery, wikiWrite, wikiRename, wikiScaffold, wikiLint]
314
+ return [wikiQuery, wikiWrite, wikiRename, wikiScaffold, wikiArchive, wikiLint]
284
315
  }
285
316
 
286
317
  /**
package/lib/bm25.js ADDED
@@ -0,0 +1,107 @@
1
+ /**
2
+ * BM25 ranking over wiki pages, the retrieval core the wiki-retrieve skill
3
+ * describes. Latin text tokenizes on word boundaries; CJK runs tokenize into
4
+ * character bigrams (plus unigrams) so Chinese queries rank without a
5
+ * segmenter. Exact-phrase matching stays in search.js as a complementary
6
+ * signal: BM25 ranks term overlap, not contiguous text.
7
+ *
8
+ * @module dsh-plugin-wiki-tools/lib/bm25
9
+ */
10
+
11
+ const K1 = 1.2
12
+ const B = 0.75
13
+
14
+ /**
15
+ * Tokenize text into ranking terms: latin words lowercased, CJK unigrams plus
16
+ * bigrams.
17
+ * @param {string} text - any text.
18
+ * @returns {string[]} terms in order.
19
+ */
20
+ export function tokenize(text) {
21
+ const source = typeof text === 'string' ? text : ''
22
+ const terms = []
23
+ let buffer = ''
24
+ const flush = () => {
25
+ if (buffer.length > 0) {
26
+ terms.push(buffer.toLowerCase())
27
+ buffer = ''
28
+ }
29
+ }
30
+ for (let index = 0; index < source.length; index += 1) {
31
+ const code = source.charCodeAt(index)
32
+ const isLatin = (code >= 0x41 && code <= 0x5a) || (code >= 0x61 && code <= 0x7a) || (code >= 0x30 && code <= 0x39)
33
+ const isCjk = code >= 0x4e00 && code <= 0x9fff
34
+ if (isLatin) {
35
+ buffer += source[index]
36
+ } else if (isCjk) {
37
+ flush()
38
+ terms.push(source[index])
39
+ const previous = source[index - 1]
40
+ if (previous !== undefined && previous.charCodeAt(0) >= 0x4e00 && previous.charCodeAt(0) <= 0x9fff) {
41
+ terms.push(previous + source[index])
42
+ }
43
+ } else {
44
+ flush()
45
+ }
46
+ }
47
+ flush()
48
+ return terms
49
+ }
50
+
51
+ /**
52
+ * Build a BM25 index over documents.
53
+ * @param {Map<string, string>} documents - docKey → searchable text.
54
+ * @returns {Bm25Index} the built index.
55
+ */
56
+ export function buildIndex(documents) {
57
+ /** @type {Map<string, Map<string, number>>} */
58
+ const termFreqs = new Map()
59
+ /** @type {Map<string, number>} */
60
+ const lengths = new Map()
61
+ /** @type {Map<string, number>} */
62
+ const docFreq = new Map()
63
+ for (const [key, text] of documents) {
64
+ const counts = new Map()
65
+ for (const term of tokenize(text)) counts.set(term, (counts.get(term) ?? 0) + 1)
66
+ termFreqs.set(key, counts)
67
+ lengths.set(key, [...counts.values()].reduce((sum, n) => sum + n, 0))
68
+ for (const term of counts.keys()) docFreq.set(term, (docFreq.get(term) ?? 0) + 1)
69
+ }
70
+ const avgLength = lengths.size === 0 ? 0 : [...lengths.values()].reduce((sum, n) => sum + n, 0) / lengths.size
71
+ return { termFreqs, lengths, docFreq, avgLength, total: lengths.size }
72
+ }
73
+
74
+ /**
75
+ * @typedef {object} Bm25Index
76
+ * @property {Map<string, Map<string, number>>} termFreqs
77
+ * @property {Map<string, number>} lengths
78
+ * @property {Map<string, number>} docFreq
79
+ * @property {number} avgLength
80
+ * @property {number} total
81
+ */
82
+
83
+ /**
84
+ * Score one query against every indexed document.
85
+ * @param {Bm25Index} index - built index.
86
+ * @param {string} query - raw query text.
87
+ * @returns {Map<string, number>} docKey → BM25 score; only nonzero scores included.
88
+ */
89
+ export function rank(index, query) {
90
+ const terms = tokenize(query)
91
+ const scores = new Map()
92
+ if (terms.length === 0 || index.total === 0) return scores
93
+ for (const [key, counts] of index.termFreqs) {
94
+ let score = 0
95
+ const length = index.lengths.get(key) ?? 0
96
+ const normalizer = K1 * (1 - B + B * (length / (index.avgLength || 1)))
97
+ for (const term of terms) {
98
+ const freq = counts.get(term)
99
+ if (freq === undefined) continue
100
+ const df = index.docFreq.get(term) ?? 0
101
+ const idf = Math.log(1 + (index.total - df + 0.5) / (df + 0.5))
102
+ score += idf * (freq * (K1 + 1)) / (freq + normalizer)
103
+ }
104
+ if (score > 0) scores.set(key, score)
105
+ }
106
+ return scores
107
+ }
package/lib/search.js CHANGED
@@ -9,6 +9,7 @@
9
9
  import { readFile } from 'node:fs/promises'
10
10
  import { join } from 'node:path'
11
11
  import { buildAliasMap, collectMarkdown, isMachineryPage, resolveLinkTarget } from './vault.js'
12
+ import { buildIndex, rank } from './bm25.js'
12
13
 
13
14
  /**
14
15
  * Answer the quick mode: the hot cache and master index verbatim. The caller
@@ -54,25 +55,30 @@ export async function searchVault(root, { query, limit = 10 }) {
54
55
  if (resolved !== undefined) inbound.get(resolved)?.push(page.name)
55
56
  }
56
57
  }
58
+ // BM25 ranks term overlap (latin words; CJK unigram+bigram); the substring
59
+ // bonuses below keep exact title/alias/tag phrases and partial words ahead,
60
+ // and a substring-only hit still surfaces when tokenization misses it.
61
+ const documents = new Map(searchable.map(page => [
62
+ page.name,
63
+ `${page.name} ${(page.aliases ?? []).join(' ')} ${tagsOf(page)} ${page.content}`,
64
+ ]))
65
+ const bm25Scores = rank(buildIndex(documents), query)
66
+
57
67
  const results = []
58
68
  let totalMatches = 0
59
69
  for (const page of searchable) {
60
- const aliasText = (page.aliases ?? []).join(' ').toLowerCase()
61
- const titleHits = (countOccurrences(page.name.toLowerCase(), needle)
62
- + (aliasText.includes(needle) ? 1 : 0)) * 5
63
- const tags = Array.isArray(page.fields?.tags) ? page.fields.tags.join(' ').toLowerCase() : ''
64
- const tagHits = tags.includes(needle) ? 4 : 0
65
- const headings = page.content.split('\n').filter(line => line.startsWith('#')).join('\n').toLowerCase()
66
- const headingHits = countOccurrences(headings, needle) * 2
67
- const body = page.content.toLowerCase()
68
- const bodyHits = countOccurrences(body, needle)
69
- const score = titleHits + tagHits + headingHits + bodyHits
70
- if (score === 0) continue
70
+ const bm25 = bm25Scores.get(page.name) ?? 0
71
+ const titleHit = page.name.toLowerCase().includes(needle)
72
+ || (page.aliases ?? []).some(alias => alias.toLowerCase().includes(needle))
73
+ const tagHit = tagsOf(page).includes(needle)
74
+ const bodyHit = page.content.toLowerCase().includes(needle)
75
+ if (bm25 === 0 && !titleHit && !tagHit && !bodyHit) continue
71
76
  totalMatches += 1
77
+ const score = bm25 + (titleHit ? 5 : 0) + (tagHit ? 4 : 0) + (bodyHit ? 1 : 0)
72
78
  results.push({
73
79
  name: page.name,
74
80
  path: page.path,
75
- score,
81
+ score: Math.round(score * 100) / 100,
76
82
  snippets: matchLines(page.content, needle).slice(0, 2),
77
83
  inbound: inbound.get(page.name) ?? [],
78
84
  outbound: new Set(page.links).size,
@@ -100,19 +106,12 @@ function matchLines(content, needle) {
100
106
  return lines
101
107
  }
102
108
 
109
+
103
110
  /**
104
- * Count substring occurrences.
105
- * @param {string} haystack - lowercased text.
106
- * @param {string} needle - lowercased substring.
107
- * @returns {number} occurrence count.
111
+ * A page's tags as one lowercase string for matching.
112
+ * @param {{ fields?: Record<string, unknown> }} page - collected page.
113
+ * @returns {string} space-joined tags.
108
114
  */
109
- function countOccurrences(haystack, needle) {
110
- if (!haystack.includes(needle)) return 0
111
- let count = 0
112
- let index = haystack.indexOf(needle)
113
- while (index >= 0) {
114
- count += 1
115
- index = haystack.indexOf(needle, index + needle.length)
116
- }
117
- return count
115
+ function tagsOf(page) {
116
+ return Array.isArray(page.fields?.tags) ? page.fields.tags.join(' ').toLowerCase() : ''
118
117
  }
package/lib/vault.js CHANGED
@@ -304,6 +304,41 @@ export class Vault {
304
304
  })
305
305
  }
306
306
 
307
+ /**
308
+ * Archive one raw source: move it from `.raw/` to `.archive/` (same
309
+ * subpath) and drop its manifest entry, the wiki skill's cold-source
310
+ * hygiene rule. Archived files leave the ingest set but stay on disk.
311
+ * @param {object} input - the archive request.
312
+ * @param {string} input.sourcePath - vault-relative `.raw/` path.
313
+ * @returns {Promise<{ archivedFrom: string, archivedTo: string }>}
314
+ */
315
+ async archiveSource({ sourcePath }) {
316
+ return await this.enqueue(join(this.root, '.archive'), async () => {
317
+ await this.assertRoot()
318
+ const absolute = isAbsolute(sourcePath) ? sourcePath : join(this.root, sourcePath)
319
+ const rel = relative(this.root, absolute).split(sep).join('/')
320
+ if (!rel.startsWith('.raw/')) {
321
+ throw new Error('wiki-tools: wiki_archive moves .raw/ sources only')
322
+ }
323
+ const raw = await readFile(absolute).catch(error => {
324
+ if (error.code === 'ENOENT') throw new Error(`wiki-tools: source ${sourcePath} not found under the vault`)
325
+ throw error
326
+ })
327
+ const destination = join(this.root, '.archive', rel.slice('.raw/'.length))
328
+ await mkdir(join(destination, '..'), { recursive: true })
329
+ await writeFile(destination, raw)
330
+ await rm(absolute)
331
+ const manifestPath = join(this.root, '.raw', '.manifest.json')
332
+ const manifest = await readJson(manifestPath, { sources: {} })
333
+ delete manifest.sources[rel]
334
+ await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
335
+ await this.prependLog(`## [${today()}] archive | ${rel}`, [
336
+ `- Archived: \`${rel}\` → \`.archive/${rel.slice('.raw/'.length)}\``,
337
+ ])
338
+ return { archivedFrom: rel, archivedTo: `.archive/${rel.slice('.raw/'.length)}` }
339
+ })
340
+ }
341
+
307
342
  /**
308
343
  * Remove one page's entry lines from the master index and its folder
309
344
  * `_index.md`, used when a rename replaces rather than refreshes.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-wiki-tools",
3
- "version": "0.8.0",
3
+ "version": "0.9.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",