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 +32 -1
- package/lib/bm25.js +107 -0
- package/lib/search.js +24 -25
- package/lib/vault.js +35 -0
- package/package.json +1 -1
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
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
const
|
|
65
|
-
|
|
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
|
-
*
|
|
105
|
-
* @param {string}
|
|
106
|
-
* @
|
|
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
|
|
110
|
-
|
|
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.
|
|
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",
|