dsh-plugin-wiki-tools 0.8.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 +48 -2
- package/lib/bm25.js +107 -0
- package/lib/search.js +85 -29
- package/lib/vault.js +126 -9
- 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(),
|
|
@@ -257,6 +263,37 @@ export function createTools(vault, options = {}) {
|
|
|
257
263
|
presentCall: args => ({ card: 'generic', title: `Scaffold wiki vault (${args.mode})`, kind: 'other', rawInput: { mode: args.mode } }),
|
|
258
264
|
})
|
|
259
265
|
|
|
266
|
+
const wikiArchive = defineTool({
|
|
267
|
+
name: 'wiki_archive',
|
|
268
|
+
description:
|
|
269
|
+
'Archive one cold raw source: move it from .raw/ to .archive/ (same subpath, file kept on disk) '
|
|
270
|
+
+ 'and drop its manifest entry so future ingests treat it as new. Use when a source is no longer '
|
|
271
|
+
+ 'active but should not be deleted. Pages derived from it are untouched.',
|
|
272
|
+
parameters: {
|
|
273
|
+
source_path: {
|
|
274
|
+
type: 'string',
|
|
275
|
+
required: true,
|
|
276
|
+
description: 'Vault-relative .raw/ source path, e.g. .raw/articles/note.md.',
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
output: {
|
|
280
|
+
schema: {
|
|
281
|
+
type: 'object',
|
|
282
|
+
additionalProperties: true,
|
|
283
|
+
},
|
|
284
|
+
render: (_args, value) => [{
|
|
285
|
+
type: 'text',
|
|
286
|
+
text: typeof value === 'object' && value !== null && 'archivedTo' in value
|
|
287
|
+
? 'wiki_archive: ' + value.archivedFrom + ' → ' + value.archivedTo + ' (manifest entry dropped)'
|
|
288
|
+
: 'wiki_archive: failed',
|
|
289
|
+
}],
|
|
290
|
+
},
|
|
291
|
+
async execute(args) {
|
|
292
|
+
return await vault.archiveSource({ sourcePath: args.source_path })
|
|
293
|
+
},
|
|
294
|
+
presentCall: args => ({ card: 'generic', title: 'Archive source: ' + args.source_path, kind: 'other', rawInput: { source: args.source_path } }),
|
|
295
|
+
})
|
|
296
|
+
|
|
260
297
|
const wikiLint = defineTool({
|
|
261
298
|
name: 'wiki_lint',
|
|
262
299
|
description:
|
|
@@ -280,7 +317,7 @@ export function createTools(vault, options = {}) {
|
|
|
280
317
|
presentCall: () => ({ card: 'generic', title: 'Lint wiki vault', kind: 'other' }),
|
|
281
318
|
})
|
|
282
319
|
|
|
283
|
-
return [wikiQuery, wikiWrite, wikiRename, wikiScaffold, wikiLint]
|
|
320
|
+
return [wikiQuery, wikiWrite, wikiRename, wikiScaffold, wikiArchive, wikiLint]
|
|
284
321
|
}
|
|
285
322
|
|
|
286
323
|
/**
|
|
@@ -372,7 +409,16 @@ export async function apply(ctx, config) {
|
|
|
372
409
|
+ 'The vault is the directory holding wiki/ and .raw/ (scaffold it with the wiki skill first).',
|
|
373
410
|
)
|
|
374
411
|
}
|
|
375
|
-
|
|
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
|
+
})
|
|
376
422
|
await vault.assertRoot()
|
|
377
423
|
for (const tool of createTools(vault, config)) {
|
|
378
424
|
ctx.tools.register(tool)
|
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
|
|
@@ -25,9 +26,12 @@ export async function quickView(root) {
|
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
|
-
* Full-text search over the wiki tree with link-graph context.
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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.
|
|
31
35
|
* @param {string} root - absolute vault root.
|
|
32
36
|
* @param {object} options - search options.
|
|
33
37
|
* @param {string} options.query - the search text.
|
|
@@ -54,26 +58,48 @@ export async function searchVault(root, { query, limit = 10 }) {
|
|
|
54
58
|
if (resolved !== undefined) inbound.get(resolved)?.push(page.name)
|
|
55
59
|
}
|
|
56
60
|
}
|
|
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
|
+
|
|
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
|
+
|
|
57
81
|
const results = []
|
|
58
82
|
let totalMatches = 0
|
|
59
83
|
for (const page of searchable) {
|
|
60
|
-
const
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
const body = page.content.toLowerCase()
|
|
68
|
-
const bodyHits = countOccurrences(body, needle)
|
|
69
|
-
const score = titleHits + tagHits + headingHits + bodyHits
|
|
70
|
-
if (score === 0) continue
|
|
84
|
+
const bm25 = bm25Scores.get(page.name) ?? 0
|
|
85
|
+
const chunk = bestChunk.get(page.name)
|
|
86
|
+
const titleHit = page.name.toLowerCase().includes(needle)
|
|
87
|
+
|| (page.aliases ?? []).some(alias => alias.toLowerCase().includes(needle))
|
|
88
|
+
const tagHit = tagsOf(page).includes(needle)
|
|
89
|
+
const bodyHit = page.content.toLowerCase().includes(needle)
|
|
90
|
+
if (bm25 === 0 && chunk === undefined && !titleHit && !tagHit && !bodyHit) continue
|
|
71
91
|
totalMatches += 1
|
|
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)
|
|
72
98
|
results.push({
|
|
73
99
|
name: page.name,
|
|
74
100
|
path: page.path,
|
|
75
|
-
score,
|
|
76
|
-
snippets
|
|
101
|
+
score: Math.round(score * 100) / 100,
|
|
102
|
+
snippets,
|
|
77
103
|
inbound: inbound.get(page.name) ?? [],
|
|
78
104
|
outbound: new Set(page.links).size,
|
|
79
105
|
})
|
|
@@ -82,6 +108,44 @@ export async function searchVault(root, { query, limit = 10 }) {
|
|
|
82
108
|
return { results: results.slice(0, limit), totalMatches: results.length }
|
|
83
109
|
}
|
|
84
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
|
+
|
|
85
149
|
/**
|
|
86
150
|
* Case-insensitive matching lines, trimmed for a result snippet.
|
|
87
151
|
* @param {string} content - page body.
|
|
@@ -101,18 +165,10 @@ function matchLines(content, needle) {
|
|
|
101
165
|
}
|
|
102
166
|
|
|
103
167
|
/**
|
|
104
|
-
*
|
|
105
|
-
* @param {string}
|
|
106
|
-
* @
|
|
107
|
-
* @returns {number} occurrence count.
|
|
168
|
+
* A page's tags as one lowercase string for matching.
|
|
169
|
+
* @param {{ fields?: Record<string, unknown> }} page - collected page.
|
|
170
|
+
* @returns {string} space-joined tags.
|
|
108
171
|
*/
|
|
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
|
|
172
|
+
function tagsOf(page) {
|
|
173
|
+
return Array.isArray(page.fields?.tags) ? page.fields.tags.join(' ').toLowerCase() : ''
|
|
118
174
|
}
|
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,45 @@ 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
|
+
}))
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Archive one raw source: move it from `.raw/` to `.archive/` (same
|
|
388
|
+
* subpath) and drop its manifest entry, the wiki skill's cold-source
|
|
389
|
+
* hygiene rule. Archived files leave the ingest set but stay on disk.
|
|
390
|
+
* @param {object} input - the archive request.
|
|
391
|
+
* @param {string} input.sourcePath - vault-relative `.raw/` path.
|
|
392
|
+
* @returns {Promise<{ archivedFrom: string, archivedTo: string }>}
|
|
393
|
+
*/
|
|
394
|
+
async archiveSource({ sourcePath }) {
|
|
395
|
+
return await this.enqueue(join(this.root, '.archive'), async () => await this.withFileLock('__vault__', async () => {
|
|
396
|
+
await this.assertRoot()
|
|
397
|
+
const absolute = isAbsolute(sourcePath) ? sourcePath : join(this.root, sourcePath)
|
|
398
|
+
const rel = relative(this.root, absolute).split(sep).join('/')
|
|
399
|
+
if (!rel.startsWith('.raw/')) {
|
|
400
|
+
throw new Error('wiki-tools: wiki_archive moves .raw/ sources only')
|
|
401
|
+
}
|
|
402
|
+
const raw = await readFile(absolute).catch(error => {
|
|
403
|
+
if (error.code === 'ENOENT') throw new Error(`wiki-tools: source ${sourcePath} not found under the vault`)
|
|
404
|
+
throw error
|
|
405
|
+
})
|
|
406
|
+
const destination = join(this.root, '.archive', rel.slice('.raw/'.length))
|
|
407
|
+
await mkdir(join(destination, '..'), { recursive: true })
|
|
408
|
+
await writeFile(destination, raw)
|
|
409
|
+
await rm(absolute)
|
|
410
|
+
const manifestPath = join(this.root, '.raw', '.manifest.json')
|
|
411
|
+
const manifest = await readJson(manifestPath, { sources: {} })
|
|
412
|
+
delete manifest.sources[rel]
|
|
413
|
+
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
|
|
414
|
+
await this.prependLog(`## [${today()}] archive | ${rel}`, [
|
|
415
|
+
`- Archived: \`${rel}\` → \`.archive/${rel.slice('.raw/'.length)}\``,
|
|
416
|
+
])
|
|
417
|
+
this.gitCommit(`wiki: archive ${rel}`)
|
|
418
|
+
return { archivedFrom: rel, archivedTo: `.archive/${rel.slice('.raw/'.length)}` }
|
|
419
|
+
}))
|
|
305
420
|
}
|
|
306
421
|
|
|
307
422
|
/**
|
|
@@ -400,6 +515,7 @@ export class Vault {
|
|
|
400
515
|
* @returns {Promise<{ hash: string, alreadyIngested: boolean }>}
|
|
401
516
|
*/
|
|
402
517
|
async trackSource({ sourcePath, pagesCreated = [], pagesUpdated = [] }) {
|
|
518
|
+
return await this.withFileLock('__manifest__', async () => {
|
|
403
519
|
const absolute = isAbsolute(sourcePath) ? sourcePath : join(this.root, sourcePath)
|
|
404
520
|
const rel = relative(this.root, absolute).split(sep).join('/')
|
|
405
521
|
const raw = await readFile(absolute).catch(error => {
|
|
@@ -420,6 +536,7 @@ export class Vault {
|
|
|
420
536
|
await mkdir(join(manifestPath, '..'), { recursive: true })
|
|
421
537
|
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
|
|
422
538
|
return { hash, alreadyIngested }
|
|
539
|
+
})
|
|
423
540
|
}
|
|
424
541
|
}
|
|
425
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",
|