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 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
- const vault = new Vault(config.vaultPath, config.typeFolders ?? {})
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. Matches are
30
- * case-insensitive substrings scored by where they hit: title 5, tags 4,
31
- * headings 2, body 1 per occurrence.
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: matchLines(page.content, needle).slice(0, 2),
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 { mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
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 {Record<string, string>} [typeFolders] - per-type folder overrides over {@link TYPE_FOLDERS}
83
- * (e.g. `{ domain: 'wiki/areas' }` for a vault whose top-level topics live in `areas/`).
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.9.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",