dsh-plugin-wiki-tools 0.9.0 → 0.10.1

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,9 @@
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 { appendFileSync, readFileSync } from 'node:fs'
17
+ import { mkdir, open, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
16
18
  import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path'
17
19
  import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
18
20
 
@@ -74,19 +76,31 @@ export function today() {
74
76
  /**
75
77
  * One vault root. All bookkeeping mutations go through {@link Vault.writePage},
76
78
  * which completes frontmatter, updates the master index, and prepends a log
77
- * entry in one serialized write per file.
79
+ * entry in one serialized write per file, guarded by a cross-process advisory
80
+ * lock (the wiki-lock contract) and optionally followed by a git commit.
78
81
  */
79
82
  export class Vault {
80
83
  /**
81
84
  * @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/`).
85
+ * @param {object} [options] - vault options.
86
+ * @param {Record<string, string>} [options.typeFolders] - per-type folder overrides over
87
+ * {@link TYPE_FOLDERS} (e.g. `{ domain: 'wiki/areas' }` for a vault whose topics live in `areas/`).
88
+ * @param {boolean} [options.gitAutoCommit] - commit each mutation when the vault is a git repository.
89
+ * @param {number} [options.lockStaleSeconds] - age at which a held advisory lock is considered
90
+ * crashed and may be broken; default 60.
84
91
  */
85
- constructor(root, typeFolders = {}) {
92
+ constructor(root, { typeFolders = {}, gitAutoCommit = false, lockStaleSeconds = 60 } = {}) {
86
93
  if (typeof root !== 'string' || root.length === 0 || !isAbsolute(root)) {
87
94
  throw new Error(`wiki-tools: vaultPath must be an absolute directory path (got ${JSON.stringify(root)})`)
88
95
  }
96
+ for (const key of Object.keys(arguments[1] ?? {})) {
97
+ if (!['typeFolders', 'gitAutoCommit', 'lockStaleSeconds'].includes(key)) {
98
+ throw new Error(`wiki-tools: unknown Vault option "${key}"`)
99
+ }
100
+ }
89
101
  this.root = root
102
+ this.gitAutoCommit = gitAutoCommit
103
+ this.lockStaleMs = lockStaleSeconds * 1000
90
104
  this.typeFolders = { ...TYPE_FOLDERS }
91
105
  for (const [type, folder] of Object.entries(typeFolders)) {
92
106
  if (!(type in this.typeFolders)) {
@@ -159,6 +173,92 @@ export class Vault {
159
173
  return next
160
174
  }
161
175
 
176
+ /**
177
+ * Run one mutation under a cross-process advisory lock (the wiki-lock
178
+ * contract): writers in other processes or sessions serialize on the same
179
+ * lock file under `.vault-meta/locks/`. A held lock is retried once after
180
+ * 2s and then reported; a lock older than lockStaleSeconds is treated as
181
+ * crashed and broken.
182
+ * @param {string} key - lock key; vault-relative page path, or `__vault__`
183
+ * for vault-wide mutations (rename, archive) and `__manifest__` for the
184
+ * ingest manifest.
185
+ * @param {() => Promise<T>} operation - the mutation to guard.
186
+ * @returns {Promise<T>}
187
+ * @template T
188
+ */
189
+ async withFileLock(key, operation) {
190
+ const locksDir = join(this.root, '.vault-meta', 'locks')
191
+ await mkdir(locksDir, { recursive: true })
192
+ const lockPath = join(locksDir, `${createHash('sha1').update(key).digest('hex')}.lock`)
193
+ const acquire = async () => {
194
+ try {
195
+ const handle = await open(lockPath, 'wx')
196
+ await handle.writeFile(`${JSON.stringify({ key, at: Date.now() })}\n`)
197
+ await handle.close()
198
+ return true
199
+ } catch (error) {
200
+ if (error.code !== 'EEXIST') throw error
201
+ const info = await stat(lockPath).catch(() => undefined)
202
+ if (info !== undefined && Date.now() - info.mtimeMs > this.lockStaleMs) {
203
+ await rm(lockPath, { force: true })
204
+ return await acquire()
205
+ }
206
+ return false
207
+ }
208
+ }
209
+ if (!(await acquire())) {
210
+ await new Promise(resolve => setTimeout(resolve, 2000))
211
+ if (!(await acquire())) {
212
+ throw new Error(`wiki-tools: ${key} is locked by another writer; skipped (retry once it releases)`)
213
+ }
214
+ }
215
+ try {
216
+ return await operation()
217
+ } finally {
218
+ await rm(lockPath, { force: true })
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Stage and commit vault changes when git auto-commit is enabled (the
224
+ * upstream PostToolUse behavior). Commits only when something is staged.
225
+ * @param {string} message - the conventional commit message.
226
+ * @returns {void} throws with git's stderr when git itself fails.
227
+ */
228
+ gitCommit(message) {
229
+ if (!this.gitAutoCommit) return
230
+ const run = args => spawnSync('git', ['-C', this.root, ...args], { encoding: 'utf8' })
231
+ this.ignoreLockFiles()
232
+ const add = run(['add', '-A'])
233
+ if (add.status !== 0) throw new Error(`wiki-tools: git add failed in the vault: ${String(add.stderr).trim()}`)
234
+ const pending = run(['diff', '--cached', '--quiet'])
235
+ if (pending.status === 1) {
236
+ const commit = run(['commit', '-m', message])
237
+ if (commit.status !== 0) throw new Error(`wiki-tools: git commit failed in the vault: ${String(commit.stderr).trim()}`)
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Keep advisory lock files out of vault history: cross-process locks under
243
+ * `.vault-meta/locks/` are pure runtime coordination, never vault state.
244
+ * Idempotent; creates or appends the vault's `.gitignore` as needed.
245
+ * @returns {void}
246
+ */
247
+ ignoreLockFiles() {
248
+ const marker = '.vault-meta/locks/'
249
+ const gitignore = join(this.root, '.gitignore')
250
+ let current = ''
251
+ try {
252
+ current = readFileSync(gitignore, 'utf8')
253
+ } catch (error) {
254
+ if (error.code !== 'ENOENT') throw error
255
+ }
256
+ if (!current.split('\n').some(line => line.trim() === marker)) {
257
+ const prefix = current.length === 0 || current.endsWith('\n') ? '' : '\n'
258
+ appendFileSync(gitignore, `${prefix}# wiki-tools runtime locks\n${marker}\n`, 'utf8')
259
+ }
260
+ }
261
+
162
262
  /**
163
263
  * Write one wiki page with complete bookkeeping: frontmatter completion,
164
264
  * filename-uniqueness guard, master-index entry, folder `_index.md` entry,
@@ -178,7 +278,7 @@ export class Vault {
178
278
  */
179
279
  async writePage({ type, title, content, tags, status, summary, extraFrontmatter }) {
180
280
  const path = this.pagePath(type, title)
181
- return await this.enqueue(path, async () => {
281
+ return await this.enqueue(path, async () => await this.withFileLock(relative(this.root, path).split(sep).join('/'), async () => {
182
282
  await this.assertRoot()
183
283
  const cleanTitle = title.endsWith('.md') ? title.slice(0, -3) : title
184
284
  const existing = await this.readPage(path)
@@ -203,8 +303,9 @@ export class Vault {
203
303
  await this.prependLog(`## [${date}] ${existing === undefined ? 'create' : 'update'} | ${cleanTitle}`, [
204
304
  `- ${existing === undefined ? 'Created' : 'Updated'}: [[${cleanTitle}]]`,
205
305
  ])
306
+ this.gitCommit(`wiki: ${existing === undefined ? 'create' : 'update'} ${cleanTitle}`)
206
307
  return { path, created: existing === undefined, title: cleanTitle }
207
- })
308
+ }))
208
309
  }
209
310
 
210
311
  /**
@@ -253,7 +354,7 @@ export class Vault {
253
354
  * @returns {Promise<{ from: string, to: string, path: string, linksRewritten: number, filesRewritten: string[] }>}
254
355
  */
255
356
  async renamePage({ title, newTitle }) {
256
- return await this.enqueue(join(this.root, 'wiki'), async () => {
357
+ return await this.enqueue(join(this.root, 'wiki'), async () => await this.withFileLock('__vault__', async () => {
257
358
  await this.assertRoot()
258
359
  const cleanNew = newTitle.endsWith('.md') ? newTitle.slice(0, -3) : newTitle
259
360
  if (!/^[^/\\]+(\.md)?$/.test(newTitle) || newTitle.includes('\n')) {
@@ -276,7 +377,12 @@ export class Vault {
276
377
  if (page.name === title || page.name.toLowerCase() === 'log' || /^lint-report-/.test(page.name)) continue
277
378
  const raw = await readFile(page.path, 'utf8').catch(() => undefined)
278
379
  if (raw === undefined) continue
279
- const pattern = new RegExp(`\\[\\[${escapeRegExp(title)}(\]\]|\||#)`, 'g')
380
+ // Exact-target boundary: the title must end the link target (`]]`),
381
+ // start an alias (`|`), or start an anchor (`#`). Escapes are doubled
382
+ // because this is a string-built RegExp, not a regex literal: single
383
+ // `\]` / `\|` would be eaten by the template literal and leave an
384
+ // empty alternation branch matching every `[[title` prefix.
385
+ const pattern = new RegExp(`\\[\\[${escapeRegExp(title)}(\\]\\]|\\||#)`, 'g')
280
386
  const updated = raw.replace(pattern, `[[${cleanNew}$1`)
281
387
  if (updated !== raw) {
282
388
  await writeFile(page.path, updated, 'utf8')
@@ -300,8 +406,9 @@ export class Vault {
300
406
  await this.prependLog(`## [${today()}] rename | ${title}`, [
301
407
  `- Renamed: [[${title}]] → [[${cleanNew}]] (${rewritten.length} files' links rewritten)`,
302
408
  ])
409
+ this.gitCommit(`wiki: rename ${title} -> ${cleanNew}`)
303
410
  return { from: title, to: cleanNew, path: newPath, linksRewritten: rewritten.length, filesRewritten: rewritten }
304
- })
411
+ }))
305
412
  }
306
413
 
307
414
  /**
@@ -313,7 +420,7 @@ export class Vault {
313
420
  * @returns {Promise<{ archivedFrom: string, archivedTo: string }>}
314
421
  */
315
422
  async archiveSource({ sourcePath }) {
316
- return await this.enqueue(join(this.root, '.archive'), async () => {
423
+ return await this.enqueue(join(this.root, '.archive'), async () => await this.withFileLock('__vault__', async () => {
317
424
  await this.assertRoot()
318
425
  const absolute = isAbsolute(sourcePath) ? sourcePath : join(this.root, sourcePath)
319
426
  const rel = relative(this.root, absolute).split(sep).join('/')
@@ -335,8 +442,9 @@ export class Vault {
335
442
  await this.prependLog(`## [${today()}] archive | ${rel}`, [
336
443
  `- Archived: \`${rel}\` → \`.archive/${rel.slice('.raw/'.length)}\``,
337
444
  ])
445
+ this.gitCommit(`wiki: archive ${rel}`)
338
446
  return { archivedFrom: rel, archivedTo: `.archive/${rel.slice('.raw/'.length)}` }
339
- })
447
+ }))
340
448
  }
341
449
 
342
450
  /**
@@ -435,6 +543,7 @@ export class Vault {
435
543
  * @returns {Promise<{ hash: string, alreadyIngested: boolean }>}
436
544
  */
437
545
  async trackSource({ sourcePath, pagesCreated = [], pagesUpdated = [] }) {
546
+ return await this.withFileLock('__manifest__', async () => {
438
547
  const absolute = isAbsolute(sourcePath) ? sourcePath : join(this.root, sourcePath)
439
548
  const rel = relative(this.root, absolute).split(sep).join('/')
440
549
  const raw = await readFile(absolute).catch(error => {
@@ -455,6 +564,7 @@ export class Vault {
455
564
  await mkdir(join(manifestPath, '..'), { recursive: true })
456
565
  await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
457
566
  return { hash, alreadyIngested }
567
+ })
458
568
  }
459
569
  }
460
570
 
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.1",
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",