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 +16 -1
- package/lib/search.js +67 -10
- package/lib/vault.js +122 -12
- 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,9 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { createHash } from 'node:crypto'
|
|
15
|
-
import {
|
|
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 {
|
|
83
|
-
*
|
|
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
|
-
|
|
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.
|
|
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",
|