dsh-plugin-wiki-tools 0.7.0 → 0.9.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
@@ -16,6 +16,7 @@ import z from '@deepseek-ai/schemastery'
16
16
  import { Vault } from './lib/vault.js'
17
17
  import { quickView, searchVault } from './lib/search.js'
18
18
  import { lintVault } from './lib/lint.js'
19
+ import { scaffoldVault, SCAFFOLD_MODES } from './lib/scaffold.js'
19
20
 
20
21
  export const name = 'wiki-tools'
21
22
  export const inject = ['tools']
@@ -215,6 +216,78 @@ export function createTools(vault, options = {}) {
215
216
  presentCall: args => ({ card: 'generic', title: `Rename wiki page: ${args.title} → ${args.new_title}`, kind: 'other', rawInput: { from: args.title, to: args.new_title } }),
216
217
  })
217
218
 
219
+ const wikiScaffold = defineTool({
220
+ name: 'wiki_scaffold',
221
+ description:
222
+ 'Scaffold a wiki vault in one call: the chosen mode\u2019s folder structure with per-folder _index.md, '
223
+ + 'the core files (wiki/index.md, log.md, hot.md, overview.md), the mode\u2019s key seed pages, a raw-source '
224
+ + 'manifest, and the vault AGENTS.md conventions file. Idempotent — existing files are kept. Modes: '
225
+ + 'generic (matches wiki_write routing), sitemap, repository, business, personal, research, book. '
226
+ + 'The result carries a suggested typeFolders config for non-generic modes to paste into the profile.',
227
+ parameters: {
228
+ mode: {
229
+ type: 'string',
230
+ required: true,
231
+ enum: Object.keys(SCAFFOLD_MODES),
232
+ description: 'Scaffold mode; pick by what the vault is for (generic for a general knowledge base).',
233
+ },
234
+ purpose: {
235
+ type: 'string',
236
+ description: 'One-line vault purpose, written into overview.md and AGENTS.md.',
237
+ },
238
+ },
239
+ output: {
240
+ schema: {
241
+ type: 'object',
242
+ additionalProperties: true,
243
+ },
244
+ render: (_args, value) => [{
245
+ type: 'text',
246
+ text: typeof value === 'object' && value !== null && 'created' in value
247
+ ? `wiki_scaffold (${value.mode}): created ${value.created.length} files, skipped ${value.skipped.length} existing`
248
+ + (Object.keys(value.suggestedTypeFolders ?? {}).length > 0
249
+ ? `; suggested typeFolders: ${JSON.stringify(value.suggestedTypeFolders)}`
250
+ : '')
251
+ : 'wiki_scaffold: failed',
252
+ }],
253
+ },
254
+ async execute(args) {
255
+ return await scaffoldVault(vault.root, args)
256
+ },
257
+ presentCall: args => ({ card: 'generic', title: `Scaffold wiki vault (${args.mode})`, kind: 'other', rawInput: { mode: args.mode } }),
258
+ })
259
+
260
+ const wikiArchive = defineTool({
261
+ name: 'wiki_archive',
262
+ description:
263
+ 'Archive one cold raw source: move it from .raw/ to .archive/ (same subpath, file kept on disk) '
264
+ + 'and drop its manifest entry so future ingests treat it as new. Use when a source is no longer '
265
+ + 'active but should not be deleted. Pages derived from it are untouched.',
266
+ parameters: {
267
+ source_path: {
268
+ type: 'string',
269
+ required: true,
270
+ description: 'Vault-relative .raw/ source path, e.g. .raw/articles/note.md.',
271
+ },
272
+ },
273
+ output: {
274
+ schema: {
275
+ type: 'object',
276
+ additionalProperties: true,
277
+ },
278
+ render: (_args, value) => [{
279
+ type: 'text',
280
+ text: typeof value === 'object' && value !== null && 'archivedTo' in value
281
+ ? 'wiki_archive: ' + value.archivedFrom + ' → ' + value.archivedTo + ' (manifest entry dropped)'
282
+ : 'wiki_archive: failed',
283
+ }],
284
+ },
285
+ async execute(args) {
286
+ return await vault.archiveSource({ sourcePath: args.source_path })
287
+ },
288
+ presentCall: args => ({ card: 'generic', title: 'Archive source: ' + args.source_path, kind: 'other', rawInput: { source: args.source_path } }),
289
+ })
290
+
218
291
  const wikiLint = defineTool({
219
292
  name: 'wiki_lint',
220
293
  description:
@@ -238,7 +311,7 @@ export function createTools(vault, options = {}) {
238
311
  presentCall: () => ({ card: 'generic', title: 'Lint wiki vault', kind: 'other' }),
239
312
  })
240
313
 
241
- return [wikiQuery, wikiWrite, wikiRename, wikiLint]
314
+ return [wikiQuery, wikiWrite, wikiRename, wikiScaffold, wikiArchive, wikiLint]
242
315
  }
243
316
 
244
317
  /**
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
+ }
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Vault scaffolding: the mechanical half of the wiki skill's SCAFFOLD
3
+ * operation as one call. Creates the mode's folder structure, the core wiki
4
+ * files (index, log, hot cache, overview), per-folder sub-indexes, the mode's
5
+ * key seed pages, the vault AGENTS.md conventions file, and the raw-source
6
+ * manifest. Idempotent: existing files are kept and reported as skipped.
7
+ *
8
+ * @module dsh-plugin-wiki-tools/lib/scaffold
9
+ */
10
+
11
+ import { mkdir, writeFile } from 'node:fs/promises'
12
+ import { join } from 'node:path'
13
+ import { readFile } from 'node:fs/promises'
14
+ import { today } from './vault.js'
15
+
16
+ /**
17
+ * Folder sets per scaffold mode. `generic` matches the wiki_write routing
18
+ * (TYPE_FOLDERS); the six named modes follow the wiki skill's modes reference.
19
+ * `stubFolder` holds the mode's overview page; `stubs` are the key pages the
20
+ * mode's reference lists, each seeded into the folder named alongside it.
21
+ */
22
+ export const SCAFFOLD_MODES = {
23
+ generic: {
24
+ label: 'Generic knowledge base',
25
+ folders: ['wiki/sources', 'wiki/entities', 'wiki/concepts', 'wiki/domains', 'wiki/questions', 'wiki/comparisons', 'wiki/meta'],
26
+ stubs: [],
27
+ typeFolders: {},
28
+ },
29
+ sitemap: {
30
+ label: 'Website / sitemap',
31
+ folders: ['wiki/pages', 'wiki/structure', 'wiki/audits', 'wiki/keywords', 'wiki/entities'],
32
+ stubs: [
33
+ { title: 'Site Overview', folder: 'wiki/structure', type: 'page' },
34
+ { title: 'Navigation Structure', folder: 'wiki/structure', type: 'page' },
35
+ { title: 'Content Gaps', folder: 'wiki/audits', type: 'page' },
36
+ { title: 'Redirect Map', folder: 'wiki/audits', type: 'page' },
37
+ { title: 'Keyword Clusters', folder: 'wiki/keywords', type: 'page' },
38
+ ],
39
+ typeFolders: { source: 'wiki/pages', entity: 'wiki/entities' },
40
+ },
41
+ repository: {
42
+ label: 'GitHub / repository',
43
+ folders: ['wiki/modules', 'wiki/components', 'wiki/decisions', 'wiki/dependencies', 'wiki/flows'],
44
+ stubs: [
45
+ { title: 'Architecture Overview', folder: 'wiki/modules', type: 'module' },
46
+ { title: 'Data Flow', folder: 'wiki/flows', type: 'flow' },
47
+ { title: 'Tech Stack', folder: 'wiki/dependencies', type: 'dependency' },
48
+ { title: 'Dependency Graph', folder: 'wiki/dependencies', type: 'dependency' },
49
+ { title: 'Key Decisions', folder: 'wiki/decisions', type: 'decision' },
50
+ ],
51
+ typeFolders: { source: 'wiki/modules', comparison: 'wiki/decisions' },
52
+ },
53
+ business: {
54
+ label: 'Business / project',
55
+ folders: ['wiki/stakeholders', 'wiki/decisions', 'wiki/deliverables', 'wiki/intel', 'wiki/comms'],
56
+ stubs: [
57
+ { title: 'Project Overview', folder: 'wiki/deliverables', type: 'deliverable' },
58
+ { title: 'Stakeholder Map', folder: 'wiki/stakeholders', type: 'stakeholder' },
59
+ { title: 'Decision Log', folder: 'wiki/decisions', type: 'decision' },
60
+ { title: 'Competitor Landscape', folder: 'wiki/intel', type: 'competitor' },
61
+ ],
62
+ typeFolders: { entity: 'wiki/stakeholders', source: 'wiki/comms', decision: 'wiki/decisions' },
63
+ },
64
+ personal: {
65
+ label: 'Personal / second brain',
66
+ folders: ['wiki/goals', 'wiki/learning', 'wiki/people', 'wiki/areas', 'wiki/resources'],
67
+ stubs: [
68
+ { title: 'North Star', folder: 'wiki/goals', type: 'goal' },
69
+ { title: 'Annual Goals', folder: 'wiki/goals', type: 'goal' },
70
+ ],
71
+ typeFolders: { concept: 'wiki/learning', domain: 'wiki/areas', entity: 'wiki/people', source: 'wiki/resources' },
72
+ },
73
+ research: {
74
+ label: 'Research',
75
+ folders: ['wiki/papers', 'wiki/concepts', 'wiki/entities', 'wiki/thesis', 'wiki/gaps'],
76
+ stubs: [
77
+ { title: 'Research Overview', folder: 'wiki/thesis', type: 'thesis' },
78
+ { title: 'Open Questions', folder: 'wiki/gaps', type: 'gap' },
79
+ ],
80
+ typeFolders: { source: 'wiki/papers', question: 'wiki/gaps', concept: 'wiki/concepts' },
81
+ },
82
+ book: {
83
+ label: 'Book / course',
84
+ folders: ['wiki/characters', 'wiki/themes', 'wiki/concepts', 'wiki/timeline', 'wiki/synthesis'],
85
+ stubs: [
86
+ { title: 'Book Overview', folder: 'wiki/timeline', type: 'chapter' },
87
+ { title: 'My Takeaways', folder: 'wiki/synthesis', type: 'synthesis' },
88
+ ],
89
+ typeFolders: { concept: 'wiki/concepts', question: 'wiki/synthesis' },
90
+ },
91
+ }
92
+
93
+ /**
94
+ * Scaffold one vault for a mode. Every artifact is written only when absent,
95
+ * so re-running after a partial scaffold or over an existing vault is safe.
96
+ * @param {string} root - absolute vault root.
97
+ * @param {object} input - scaffold options.
98
+ * @param {keyof typeof SCAFFOLD_MODES} input.mode - scaffold mode.
99
+ * @param {string} [input.purpose] - one-line vault purpose for overview and AGENTS.md.
100
+ * @returns {Promise<{ mode: string, created: string[], skipped: string[], suggestedTypeFolders: Record<string, string> }>}
101
+ */
102
+ export async function scaffoldVault(root, { mode, purpose }) {
103
+ const spec = SCAFFOLD_MODES[mode]
104
+ if (spec === undefined) {
105
+ throw new Error(`wiki-tools: unknown scaffold mode "${mode}"; choose one of ${Object.keys(SCAFFOLD_MODES).join(', ')}`)
106
+ }
107
+ const date = today()
108
+ const created = []
109
+ const skipped = []
110
+ const writeIfAbsent = async (relPath, content) => {
111
+ const path = join(root, relPath)
112
+ if (await readFile(path, 'utf8').then(() => true, () => false)) {
113
+ skipped.push(relPath)
114
+ return
115
+ }
116
+ await mkdir(join(path, '..'), { recursive: true })
117
+ await writeFile(path, content, 'utf8')
118
+ created.push(relPath)
119
+ }
120
+
121
+ await writeIfAbsent('.raw/.manifest.json', '{"sources":{}}\n')
122
+ for (const folder of spec.folders) {
123
+ await writeIfAbsent(`${folder}/_index.md`, [
124
+ '---',
125
+ 'type: meta',
126
+ `title: "${folder.split('/').pop()} Index"`,
127
+ `updated: ${date}`,
128
+ '---',
129
+ '',
130
+ `# ${folder.split('/').pop()}`,
131
+ '',
132
+ ].join('\n'))
133
+ }
134
+
135
+ await writeIfAbsent('wiki/index.md', indexTemplate(date))
136
+ await writeIfAbsent('wiki/log.md', `# Wiki Log\n`)
137
+ await writeIfAbsent('wiki/hot.md', [
138
+ '---',
139
+ 'type: meta',
140
+ 'title: "Hot Cache"',
141
+ `updated: ${date}`,
142
+ '---',
143
+ '',
144
+ '# Recent Context',
145
+ '',
146
+ `Scaffolded ${date}. ${purpose ?? spec.label}.`,
147
+ '',
148
+ ].join('\n'))
149
+ await writeIfAbsent('wiki/overview.md', [
150
+ '---',
151
+ 'type: overview',
152
+ `title: "Overview"`,
153
+ `updated: ${date}`,
154
+ '---',
155
+ '',
156
+ '# Overview',
157
+ '',
158
+ purpose ?? spec.label,
159
+ '',
160
+ ].join('\n'))
161
+
162
+ for (const stub of spec.stubs) {
163
+ await writeIfAbsent(`${stub.folder}/${stub.title}.md`, [
164
+ '---',
165
+ `type: ${stub.type}`,
166
+ `title: "${stub.title}"`,
167
+ 'status: seed',
168
+ `created: ${date}`,
169
+ `updated: ${date}`,
170
+ 'tags:',
171
+ ` - ${stub.type}`,
172
+ '---',
173
+ '',
174
+ `# ${stub.title}`,
175
+ '',
176
+ `Seed page from ${mode} scaffold. Fill in.`,
177
+ '',
178
+ ].join('\n'))
179
+ }
180
+
181
+ await writeIfAbsent('AGENTS.md', agentsTemplate(mode, spec.label, purpose, date))
182
+
183
+ return { mode, created, skipped, suggestedTypeFolders: spec.typeFolders }
184
+ }
185
+
186
+ /**
187
+ * Master-index template with one empty section per generic type, in catalog
188
+ * order; named-mode vaults keep the same sections for their mapped types.
189
+ * @param {string} date - scaffold date.
190
+ * @returns {string} the index file content.
191
+ */
192
+ function indexTemplate(date) {
193
+ const sections = ['Entities', 'Concepts', 'Sources', 'Questions']
194
+ return [
195
+ '---',
196
+ 'type: meta',
197
+ 'title: "Wiki Index"',
198
+ `updated: ${date}`,
199
+ '---',
200
+ '',
201
+ '# Wiki Index',
202
+ '',
203
+ ...sections.flatMap(section => [`## ${section}`, '']),
204
+ ].join('\n')
205
+ }
206
+
207
+ /**
208
+ * Vault conventions file: the rules every contributing agent follows.
209
+ * @param {string} mode - scaffold mode id.
210
+ * @param {string} label - human-readable mode label.
211
+ * @param {string | undefined} purpose - one-line vault purpose.
212
+ * @param {string} date - scaffold date.
213
+ * @returns {string} the AGENTS.md content.
214
+ */
215
+ function agentsTemplate(mode, label, purpose, date) {
216
+ return `# Wiki Vault Conventions
217
+
218
+ Mode: ${mode} (${label})
219
+ Purpose: ${purpose ?? '(fill in)'}
220
+ Created: ${date}
221
+
222
+ ## Rules
223
+
224
+ - Every page uses flat YAML frontmatter: type, title, status, created, updated, tags at minimum.
225
+ - status is one of seed | developing | mature | evergreen.
226
+ - Wikilinks use [[Note Name]]; filenames are unique across the vault, no paths needed.
227
+ - .raw/ holds immutable sources; never modify them.
228
+ - wiki/index.md is the master catalog; every page is listed in its section.
229
+ - wiki/log.md is append-only; new entries go at the TOP; never edit past entries.
230
+ - wiki/hot.md is a ~500-word cache of recent context; overwrite it completely each update.
231
+ - Prefer the wiki tools (wiki_query, wiki_write, wiki_rename, wiki_lint) over raw file edits;
232
+ they keep frontmatter, the index, the folder _index.md files, and the log consistent.
233
+ - Contradictions between pages get > [!contradiction] callouts on both pages, never silent edits.
234
+ `
235
+ }
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
@@ -54,25 +55,30 @@ export async function searchVault(root, { query, limit = 10 }) {
54
55
  if (resolved !== undefined) inbound.get(resolved)?.push(page.name)
55
56
  }
56
57
  }
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
+ 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
+
57
67
  const results = []
58
68
  let totalMatches = 0
59
69
  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
70
+ const bm25 = bm25Scores.get(page.name) ?? 0
71
+ const titleHit = page.name.toLowerCase().includes(needle)
72
+ || (page.aliases ?? []).some(alias => alias.toLowerCase().includes(needle))
73
+ const tagHit = tagsOf(page).includes(needle)
74
+ const bodyHit = page.content.toLowerCase().includes(needle)
75
+ if (bm25 === 0 && !titleHit && !tagHit && !bodyHit) continue
71
76
  totalMatches += 1
77
+ const score = bm25 + (titleHit ? 5 : 0) + (tagHit ? 4 : 0) + (bodyHit ? 1 : 0)
72
78
  results.push({
73
79
  name: page.name,
74
80
  path: page.path,
75
- score,
81
+ score: Math.round(score * 100) / 100,
76
82
  snippets: matchLines(page.content, needle).slice(0, 2),
77
83
  inbound: inbound.get(page.name) ?? [],
78
84
  outbound: new Set(page.links).size,
@@ -100,19 +106,12 @@ function matchLines(content, needle) {
100
106
  return lines
101
107
  }
102
108
 
109
+
103
110
  /**
104
- * Count substring occurrences.
105
- * @param {string} haystack - lowercased text.
106
- * @param {string} needle - lowercased substring.
107
- * @returns {number} occurrence count.
111
+ * A page's tags as one lowercase string for matching.
112
+ * @param {{ fields?: Record<string, unknown> }} page - collected page.
113
+ * @returns {string} space-joined tags.
108
114
  */
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
115
+ function tagsOf(page) {
116
+ return Array.isArray(page.fields?.tags) ? page.fields.tags.join(' ').toLowerCase() : ''
118
117
  }
package/lib/vault.js CHANGED
@@ -304,6 +304,41 @@ export class Vault {
304
304
  })
305
305
  }
306
306
 
307
+ /**
308
+ * Archive one raw source: move it from `.raw/` to `.archive/` (same
309
+ * subpath) and drop its manifest entry, the wiki skill's cold-source
310
+ * hygiene rule. Archived files leave the ingest set but stay on disk.
311
+ * @param {object} input - the archive request.
312
+ * @param {string} input.sourcePath - vault-relative `.raw/` path.
313
+ * @returns {Promise<{ archivedFrom: string, archivedTo: string }>}
314
+ */
315
+ async archiveSource({ sourcePath }) {
316
+ return await this.enqueue(join(this.root, '.archive'), async () => {
317
+ await this.assertRoot()
318
+ const absolute = isAbsolute(sourcePath) ? sourcePath : join(this.root, sourcePath)
319
+ const rel = relative(this.root, absolute).split(sep).join('/')
320
+ if (!rel.startsWith('.raw/')) {
321
+ throw new Error('wiki-tools: wiki_archive moves .raw/ sources only')
322
+ }
323
+ const raw = await readFile(absolute).catch(error => {
324
+ if (error.code === 'ENOENT') throw new Error(`wiki-tools: source ${sourcePath} not found under the vault`)
325
+ throw error
326
+ })
327
+ const destination = join(this.root, '.archive', rel.slice('.raw/'.length))
328
+ await mkdir(join(destination, '..'), { recursive: true })
329
+ await writeFile(destination, raw)
330
+ await rm(absolute)
331
+ const manifestPath = join(this.root, '.raw', '.manifest.json')
332
+ const manifest = await readJson(manifestPath, { sources: {} })
333
+ delete manifest.sources[rel]
334
+ await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
335
+ await this.prependLog(`## [${today()}] archive | ${rel}`, [
336
+ `- Archived: \`${rel}\` → \`.archive/${rel.slice('.raw/'.length)}\``,
337
+ ])
338
+ return { archivedFrom: rel, archivedTo: `.archive/${rel.slice('.raw/'.length)}` }
339
+ })
340
+ }
341
+
307
342
  /**
308
343
  * Remove one page's entry lines from the master index and its folder
309
344
  * `_index.md`, used when a rename replaces rather than refreshes.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-wiki-tools",
3
- "version": "0.7.0",
3
+ "version": "0.9.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",