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 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
- 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
+ })
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. Matches are
29
- * case-insensitive substrings scored by where they hit: title 5, tags 4,
30
- * 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.
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 aliasText = (page.aliases ?? []).join(' ').toLowerCase()
61
- const titleHits = (countOccurrences(page.name.toLowerCase(), needle)
62
- + (aliasText.includes(needle) ? 1 : 0)) * 5
63
- const tags = Array.isArray(page.fields?.tags) ? page.fields.tags.join(' ').toLowerCase() : ''
64
- const tagHits = tags.includes(needle) ? 4 : 0
65
- const headings = page.content.split('\n').filter(line => line.startsWith('#')).join('\n').toLowerCase()
66
- const headingHits = countOccurrences(headings, needle) * 2
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: matchLines(page.content, needle).slice(0, 2),
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
- * Count substring occurrences.
105
- * @param {string} haystack - lowercased text.
106
- * @param {string} needle - lowercased substring.
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 countOccurrences(haystack, needle) {
110
- if (!haystack.includes(needle)) return 0
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 { 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,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.8.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",