dsh-plugin-wiki-tools 0.9.0 → 0.10.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 +16 -1
- package/lib/search.js +67 -10
- package/lib/vault.js +93 -11
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* @module dsh-plugin-wiki-tools
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { existsSync } from 'node:fs'
|
|
15
|
+
import { join } from 'node:path'
|
|
14
16
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
15
17
|
import z from '@deepseek-ai/schemastery'
|
|
16
18
|
import { Vault } from './lib/vault.js'
|
|
@@ -27,6 +29,10 @@ export const Config = z.object({
|
|
|
27
29
|
vaultPath: z.string().required(),
|
|
28
30
|
/** Maximum pages returned by one wiki_query standard-mode call. */
|
|
29
31
|
maxQueryResults: z.number().default(10),
|
|
32
|
+
/** Commit each vault mutation when the vault is a git repository. */
|
|
33
|
+
gitAutoCommit: z.boolean().default(false),
|
|
34
|
+
/** Age in seconds at which a held cross-process lock is treated as crashed. */
|
|
35
|
+
lockStaleSeconds: z.number().default(60),
|
|
30
36
|
/** Per-type folder overrides over the default routing, e.g. `{ domain: "wiki/areas" }`. */
|
|
31
37
|
typeFolders: z.object({
|
|
32
38
|
source: z.string(),
|
|
@@ -403,7 +409,16 @@ export async function apply(ctx, config) {
|
|
|
403
409
|
+ 'The vault is the directory holding wiki/ and .raw/ (scaffold it with the wiki skill first).',
|
|
404
410
|
)
|
|
405
411
|
}
|
|
406
|
-
|
|
412
|
+
if (config.gitAutoCommit === true && !existsSync(join(config.vaultPath, '.git'))) {
|
|
413
|
+
throw new Error(
|
|
414
|
+
'wiki-tools: gitAutoCommit is enabled but the vault is not a git repository; run git init in it first',
|
|
415
|
+
)
|
|
416
|
+
}
|
|
417
|
+
const vault = new Vault(config.vaultPath, {
|
|
418
|
+
typeFolders: config.typeFolders ?? {},
|
|
419
|
+
gitAutoCommit: config.gitAutoCommit ?? false,
|
|
420
|
+
lockStaleSeconds: config.lockStaleSeconds ?? 60,
|
|
421
|
+
})
|
|
407
422
|
await vault.assertRoot()
|
|
408
423
|
for (const tool of createTools(vault, config)) {
|
|
409
424
|
ctx.tools.register(tool)
|
package/lib/search.js
CHANGED
|
@@ -26,9 +26,12 @@ export async function quickView(root) {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
|
-
* Full-text search over the wiki tree with link-graph context.
|
|
30
|
-
*
|
|
31
|
-
*
|
|
29
|
+
* Full-text search over the wiki tree with link-graph context. BM25 ranks
|
|
30
|
+
* term overlap (latin words; CJK unigram/bigram) at two granularities: whole
|
|
31
|
+
* pages for the ranking core, and heading/paragraph chunks (the wiki-retrieve
|
|
32
|
+
* design) so each hit's snippet is its best-matching passage. Substring
|
|
33
|
+
* bonuses keep exact title/alias/tag phrases and partial words ahead, and a
|
|
34
|
+
* substring-only hit still surfaces when tokenization misses it.
|
|
32
35
|
* @param {string} root - absolute vault root.
|
|
33
36
|
* @param {object} options - search options.
|
|
34
37
|
* @param {string} options.query - the search text.
|
|
@@ -55,31 +58,48 @@ export async function searchVault(root, { query, limit = 10 }) {
|
|
|
55
58
|
if (resolved !== undefined) inbound.get(resolved)?.push(page.name)
|
|
56
59
|
}
|
|
57
60
|
}
|
|
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
61
|
const documents = new Map(searchable.map(page => [
|
|
62
62
|
page.name,
|
|
63
63
|
`${page.name} ${(page.aliases ?? []).join(' ')} ${tagsOf(page)} ${page.content}`,
|
|
64
64
|
]))
|
|
65
65
|
const bm25Scores = rank(buildIndex(documents), query)
|
|
66
66
|
|
|
67
|
+
const chunksByPage = new Map(searchable.map(page => [page.name, chunkPage(page.content)]))
|
|
68
|
+
const chunkDocs = new Map()
|
|
69
|
+
for (const [name, chunks] of chunksByPage) {
|
|
70
|
+
chunks.forEach((chunk, index) => chunkDocs.set(`${name}\u0000${index}`, chunk))
|
|
71
|
+
}
|
|
72
|
+
/** Best chunk (index + score) per page from chunk-level BM25. */
|
|
73
|
+
const bestChunk = new Map()
|
|
74
|
+
for (const [key, score] of rank(buildIndex(chunkDocs), query)) {
|
|
75
|
+
const name = key.slice(0, key.indexOf('\u0000'))
|
|
76
|
+
if (score > (bestChunk.get(name)?.score ?? 0)) {
|
|
77
|
+
bestChunk.set(name, { score, index: Number(key.slice(key.indexOf('\u0000') + 1)) })
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
67
81
|
const results = []
|
|
68
82
|
let totalMatches = 0
|
|
69
83
|
for (const page of searchable) {
|
|
70
84
|
const bm25 = bm25Scores.get(page.name) ?? 0
|
|
85
|
+
const chunk = bestChunk.get(page.name)
|
|
71
86
|
const titleHit = page.name.toLowerCase().includes(needle)
|
|
72
87
|
|| (page.aliases ?? []).some(alias => alias.toLowerCase().includes(needle))
|
|
73
88
|
const tagHit = tagsOf(page).includes(needle)
|
|
74
89
|
const bodyHit = page.content.toLowerCase().includes(needle)
|
|
75
|
-
if (bm25 === 0 && !titleHit && !tagHit && !bodyHit) continue
|
|
90
|
+
if (bm25 === 0 && chunk === undefined && !titleHit && !tagHit && !bodyHit) continue
|
|
76
91
|
totalMatches += 1
|
|
77
|
-
const score = bm25 + (titleHit ? 5 : 0) + (tagHit ? 4 : 0) + (bodyHit ? 1 : 0)
|
|
92
|
+
const score = bm25 + (chunk?.score ?? 0) + (titleHit ? 5 : 0) + (tagHit ? 4 : 0) + (bodyHit ? 1 : 0)
|
|
93
|
+
const chunks = chunksByPage.get(page.name) ?? []
|
|
94
|
+
const snippetChunk = chunk !== undefined ? chunks[chunk.index] : undefined
|
|
95
|
+
const snippets = snippetChunk !== undefined && snippetChunk.trim().length > 0
|
|
96
|
+
? [trimForSnippet(snippetChunk)]
|
|
97
|
+
: matchLines(page.content, needle).slice(0, 2)
|
|
78
98
|
results.push({
|
|
79
99
|
name: page.name,
|
|
80
100
|
path: page.path,
|
|
81
101
|
score: Math.round(score * 100) / 100,
|
|
82
|
-
snippets
|
|
102
|
+
snippets,
|
|
83
103
|
inbound: inbound.get(page.name) ?? [],
|
|
84
104
|
outbound: new Set(page.links).size,
|
|
85
105
|
})
|
|
@@ -88,6 +108,44 @@ export async function searchVault(root, { query, limit = 10 }) {
|
|
|
88
108
|
return { results: results.slice(0, limit), totalMatches: results.length }
|
|
89
109
|
}
|
|
90
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Split a page body into retrieval chunks: blocks separated by blank lines,
|
|
113
|
+
* accumulated up to ~800 characters per chunk without splitting a block.
|
|
114
|
+
* @param {string} content - page body.
|
|
115
|
+
* @returns {string[]} non-empty chunks.
|
|
116
|
+
*/
|
|
117
|
+
function chunkPage(content) {
|
|
118
|
+
const chunks = []
|
|
119
|
+
let current = ''
|
|
120
|
+
for (const block of content.split(/\n\s*\n/)) {
|
|
121
|
+
const trimmed = block.trim()
|
|
122
|
+
if (trimmed.length === 0) continue
|
|
123
|
+
if (current.length === 0) {
|
|
124
|
+
current = trimmed
|
|
125
|
+
} else if (current.length + trimmed.length + 2 <= 800) {
|
|
126
|
+
current += `\n\n${trimmed}`
|
|
127
|
+
} else {
|
|
128
|
+
chunks.push(current)
|
|
129
|
+
current = trimmed
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (current.length > 0) chunks.push(current)
|
|
133
|
+
return chunks
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Trim one chunk for snippet display, cutting at a word boundary.
|
|
138
|
+
* @param {string} chunk - the chunk text.
|
|
139
|
+
* @returns {string} at most ~300 characters with an ellipsis marker.
|
|
140
|
+
*/
|
|
141
|
+
function trimForSnippet(chunk) {
|
|
142
|
+
const text = chunk.replace(/\n+/g, ' ').trim()
|
|
143
|
+
if (text.length <= 300) return text
|
|
144
|
+
const cut = text.slice(0, 300)
|
|
145
|
+
const lastSpace = cut.lastIndexOf(' ')
|
|
146
|
+
return `${cut.slice(0, lastSpace > 200 ? lastSpace : 300)}...`
|
|
147
|
+
}
|
|
148
|
+
|
|
91
149
|
/**
|
|
92
150
|
* Case-insensitive matching lines, trimmed for a result snippet.
|
|
93
151
|
* @param {string} content - page body.
|
|
@@ -106,7 +164,6 @@ function matchLines(content, needle) {
|
|
|
106
164
|
return lines
|
|
107
165
|
}
|
|
108
166
|
|
|
109
|
-
|
|
110
167
|
/**
|
|
111
168
|
* A page's tags as one lowercase string for matching.
|
|
112
169
|
* @param {{ fields?: Record<string, unknown> }} page - collected page.
|
package/lib/vault.js
CHANGED
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { createHash } from 'node:crypto'
|
|
15
|
-
import {
|
|
15
|
+
import { spawnSync } from 'node:child_process'
|
|
16
|
+
import { mkdir, open, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
|
|
16
17
|
import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path'
|
|
17
18
|
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
|
|
18
19
|
|
|
@@ -74,19 +75,31 @@ export function today() {
|
|
|
74
75
|
/**
|
|
75
76
|
* One vault root. All bookkeeping mutations go through {@link Vault.writePage},
|
|
76
77
|
* which completes frontmatter, updates the master index, and prepends a log
|
|
77
|
-
* entry in one serialized write per file
|
|
78
|
+
* entry in one serialized write per file, guarded by a cross-process advisory
|
|
79
|
+
* lock (the wiki-lock contract) and optionally followed by a git commit.
|
|
78
80
|
*/
|
|
79
81
|
export class Vault {
|
|
80
82
|
/**
|
|
81
83
|
* @param {string} root - absolute path to the vault root (the directory holding `wiki/` and `.raw/`).
|
|
82
|
-
* @param {
|
|
83
|
-
*
|
|
84
|
+
* @param {object} [options] - vault options.
|
|
85
|
+
* @param {Record<string, string>} [options.typeFolders] - per-type folder overrides over
|
|
86
|
+
* {@link TYPE_FOLDERS} (e.g. `{ domain: 'wiki/areas' }` for a vault whose topics live in `areas/`).
|
|
87
|
+
* @param {boolean} [options.gitAutoCommit] - commit each mutation when the vault is a git repository.
|
|
88
|
+
* @param {number} [options.lockStaleSeconds] - age at which a held advisory lock is considered
|
|
89
|
+
* crashed and may be broken; default 60.
|
|
84
90
|
*/
|
|
85
|
-
constructor(root, typeFolders = {}) {
|
|
91
|
+
constructor(root, { typeFolders = {}, gitAutoCommit = false, lockStaleSeconds = 60 } = {}) {
|
|
86
92
|
if (typeof root !== 'string' || root.length === 0 || !isAbsolute(root)) {
|
|
87
93
|
throw new Error(`wiki-tools: vaultPath must be an absolute directory path (got ${JSON.stringify(root)})`)
|
|
88
94
|
}
|
|
95
|
+
for (const key of Object.keys(arguments[1] ?? {})) {
|
|
96
|
+
if (!['typeFolders', 'gitAutoCommit', 'lockStaleSeconds'].includes(key)) {
|
|
97
|
+
throw new Error(`wiki-tools: unknown Vault option "${key}"`)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
89
100
|
this.root = root
|
|
101
|
+
this.gitAutoCommit = gitAutoCommit
|
|
102
|
+
this.lockStaleMs = lockStaleSeconds * 1000
|
|
90
103
|
this.typeFolders = { ...TYPE_FOLDERS }
|
|
91
104
|
for (const [type, folder] of Object.entries(typeFolders)) {
|
|
92
105
|
if (!(type in this.typeFolders)) {
|
|
@@ -159,6 +172,70 @@ export class Vault {
|
|
|
159
172
|
return next
|
|
160
173
|
}
|
|
161
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Run one mutation under a cross-process advisory lock (the wiki-lock
|
|
177
|
+
* contract): writers in other processes or sessions serialize on the same
|
|
178
|
+
* lock file under `.vault-meta/locks/`. A held lock is retried once after
|
|
179
|
+
* 2s and then reported; a lock older than lockStaleSeconds is treated as
|
|
180
|
+
* crashed and broken.
|
|
181
|
+
* @param {string} key - lock key; vault-relative page path, or `__vault__`
|
|
182
|
+
* for vault-wide mutations (rename, archive) and `__manifest__` for the
|
|
183
|
+
* ingest manifest.
|
|
184
|
+
* @param {() => Promise<T>} operation - the mutation to guard.
|
|
185
|
+
* @returns {Promise<T>}
|
|
186
|
+
* @template T
|
|
187
|
+
*/
|
|
188
|
+
async withFileLock(key, operation) {
|
|
189
|
+
const locksDir = join(this.root, '.vault-meta', 'locks')
|
|
190
|
+
await mkdir(locksDir, { recursive: true })
|
|
191
|
+
const lockPath = join(locksDir, `${createHash('sha1').update(key).digest('hex')}.lock`)
|
|
192
|
+
const acquire = async () => {
|
|
193
|
+
try {
|
|
194
|
+
const handle = await open(lockPath, 'wx')
|
|
195
|
+
await handle.writeFile(`${JSON.stringify({ key, at: Date.now() })}\n`)
|
|
196
|
+
await handle.close()
|
|
197
|
+
return true
|
|
198
|
+
} catch (error) {
|
|
199
|
+
if (error.code !== 'EEXIST') throw error
|
|
200
|
+
const info = await stat(lockPath).catch(() => undefined)
|
|
201
|
+
if (info !== undefined && Date.now() - info.mtimeMs > this.lockStaleMs) {
|
|
202
|
+
await rm(lockPath, { force: true })
|
|
203
|
+
return await acquire()
|
|
204
|
+
}
|
|
205
|
+
return false
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (!(await acquire())) {
|
|
209
|
+
await new Promise(resolve => setTimeout(resolve, 2000))
|
|
210
|
+
if (!(await acquire())) {
|
|
211
|
+
throw new Error(`wiki-tools: ${key} is locked by another writer; skipped (retry once it releases)`)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
try {
|
|
215
|
+
return await operation()
|
|
216
|
+
} finally {
|
|
217
|
+
await rm(lockPath, { force: true })
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Stage and commit vault changes when git auto-commit is enabled (the
|
|
223
|
+
* upstream PostToolUse behavior). Commits only when something is staged.
|
|
224
|
+
* @param {string} message - the conventional commit message.
|
|
225
|
+
* @returns {void} throws with git's stderr when git itself fails.
|
|
226
|
+
*/
|
|
227
|
+
gitCommit(message) {
|
|
228
|
+
if (!this.gitAutoCommit) return
|
|
229
|
+
const run = args => spawnSync('git', ['-C', this.root, ...args], { encoding: 'utf8' })
|
|
230
|
+
const add = run(['add', '-A'])
|
|
231
|
+
if (add.status !== 0) throw new Error(`wiki-tools: git add failed in the vault: ${String(add.stderr).trim()}`)
|
|
232
|
+
const pending = run(['diff', '--cached', '--quiet'])
|
|
233
|
+
if (pending.status === 1) {
|
|
234
|
+
const commit = run(['commit', '-m', message])
|
|
235
|
+
if (commit.status !== 0) throw new Error(`wiki-tools: git commit failed in the vault: ${String(commit.stderr).trim()}`)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
162
239
|
/**
|
|
163
240
|
* Write one wiki page with complete bookkeeping: frontmatter completion,
|
|
164
241
|
* filename-uniqueness guard, master-index entry, folder `_index.md` entry,
|
|
@@ -178,7 +255,7 @@ export class Vault {
|
|
|
178
255
|
*/
|
|
179
256
|
async writePage({ type, title, content, tags, status, summary, extraFrontmatter }) {
|
|
180
257
|
const path = this.pagePath(type, title)
|
|
181
|
-
return await this.enqueue(path, async () => {
|
|
258
|
+
return await this.enqueue(path, async () => await this.withFileLock(relative(this.root, path).split(sep).join('/'), async () => {
|
|
182
259
|
await this.assertRoot()
|
|
183
260
|
const cleanTitle = title.endsWith('.md') ? title.slice(0, -3) : title
|
|
184
261
|
const existing = await this.readPage(path)
|
|
@@ -203,8 +280,9 @@ export class Vault {
|
|
|
203
280
|
await this.prependLog(`## [${date}] ${existing === undefined ? 'create' : 'update'} | ${cleanTitle}`, [
|
|
204
281
|
`- ${existing === undefined ? 'Created' : 'Updated'}: [[${cleanTitle}]]`,
|
|
205
282
|
])
|
|
283
|
+
this.gitCommit(`wiki: ${existing === undefined ? 'create' : 'update'} ${cleanTitle}`)
|
|
206
284
|
return { path, created: existing === undefined, title: cleanTitle }
|
|
207
|
-
})
|
|
285
|
+
}))
|
|
208
286
|
}
|
|
209
287
|
|
|
210
288
|
/**
|
|
@@ -253,7 +331,7 @@ export class Vault {
|
|
|
253
331
|
* @returns {Promise<{ from: string, to: string, path: string, linksRewritten: number, filesRewritten: string[] }>}
|
|
254
332
|
*/
|
|
255
333
|
async renamePage({ title, newTitle }) {
|
|
256
|
-
return await this.enqueue(join(this.root, 'wiki'), async () => {
|
|
334
|
+
return await this.enqueue(join(this.root, 'wiki'), async () => await this.withFileLock('__vault__', async () => {
|
|
257
335
|
await this.assertRoot()
|
|
258
336
|
const cleanNew = newTitle.endsWith('.md') ? newTitle.slice(0, -3) : newTitle
|
|
259
337
|
if (!/^[^/\\]+(\.md)?$/.test(newTitle) || newTitle.includes('\n')) {
|
|
@@ -300,8 +378,9 @@ export class Vault {
|
|
|
300
378
|
await this.prependLog(`## [${today()}] rename | ${title}`, [
|
|
301
379
|
`- Renamed: [[${title}]] → [[${cleanNew}]] (${rewritten.length} files' links rewritten)`,
|
|
302
380
|
])
|
|
381
|
+
this.gitCommit(`wiki: rename ${title} -> ${cleanNew}`)
|
|
303
382
|
return { from: title, to: cleanNew, path: newPath, linksRewritten: rewritten.length, filesRewritten: rewritten }
|
|
304
|
-
})
|
|
383
|
+
}))
|
|
305
384
|
}
|
|
306
385
|
|
|
307
386
|
/**
|
|
@@ -313,7 +392,7 @@ export class Vault {
|
|
|
313
392
|
* @returns {Promise<{ archivedFrom: string, archivedTo: string }>}
|
|
314
393
|
*/
|
|
315
394
|
async archiveSource({ sourcePath }) {
|
|
316
|
-
return await this.enqueue(join(this.root, '.archive'), async () => {
|
|
395
|
+
return await this.enqueue(join(this.root, '.archive'), async () => await this.withFileLock('__vault__', async () => {
|
|
317
396
|
await this.assertRoot()
|
|
318
397
|
const absolute = isAbsolute(sourcePath) ? sourcePath : join(this.root, sourcePath)
|
|
319
398
|
const rel = relative(this.root, absolute).split(sep).join('/')
|
|
@@ -335,8 +414,9 @@ export class Vault {
|
|
|
335
414
|
await this.prependLog(`## [${today()}] archive | ${rel}`, [
|
|
336
415
|
`- Archived: \`${rel}\` → \`.archive/${rel.slice('.raw/'.length)}\``,
|
|
337
416
|
])
|
|
417
|
+
this.gitCommit(`wiki: archive ${rel}`)
|
|
338
418
|
return { archivedFrom: rel, archivedTo: `.archive/${rel.slice('.raw/'.length)}` }
|
|
339
|
-
})
|
|
419
|
+
}))
|
|
340
420
|
}
|
|
341
421
|
|
|
342
422
|
/**
|
|
@@ -435,6 +515,7 @@ export class Vault {
|
|
|
435
515
|
* @returns {Promise<{ hash: string, alreadyIngested: boolean }>}
|
|
436
516
|
*/
|
|
437
517
|
async trackSource({ sourcePath, pagesCreated = [], pagesUpdated = [] }) {
|
|
518
|
+
return await this.withFileLock('__manifest__', async () => {
|
|
438
519
|
const absolute = isAbsolute(sourcePath) ? sourcePath : join(this.root, sourcePath)
|
|
439
520
|
const rel = relative(this.root, absolute).split(sep).join('/')
|
|
440
521
|
const raw = await readFile(absolute).catch(error => {
|
|
@@ -455,6 +536,7 @@ export class Vault {
|
|
|
455
536
|
await mkdir(join(manifestPath, '..'), { recursive: true })
|
|
456
537
|
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
|
|
457
538
|
return { hash, alreadyIngested }
|
|
539
|
+
})
|
|
458
540
|
}
|
|
459
541
|
}
|
|
460
542
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-wiki-tools",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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",
|