dsh-plugin-wiki-tools 0.6.0 → 0.7.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 +53 -5
- package/lib/vault.js +156 -7
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -38,8 +38,8 @@ export const Config = z.object({
|
|
|
38
38
|
}).default({}),
|
|
39
39
|
})
|
|
40
40
|
|
|
41
|
-
const PAGE_TYPES = ['source', 'entity', 'concept', 'domain', 'question', 'comparison', 'meta']
|
|
42
|
-
const STATUSES = ['seed', 'developing', '
|
|
41
|
+
const PAGE_TYPES = ['source', 'entity', 'concept', 'domain', 'question', 'synthesis', 'comparison', 'decision', 'session', 'meta']
|
|
42
|
+
const STATUSES = ['seed', 'developing', 'mature', 'evergreen']
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
45
|
* Build the three wiki tool definitions over one vault. Exported for tests.
|
|
@@ -125,12 +125,22 @@ export function createTools(vault, options = {}) {
|
|
|
125
125
|
status: {
|
|
126
126
|
type: 'string',
|
|
127
127
|
enum: STATUSES,
|
|
128
|
-
description: 'Frontmatter status; defaults to developing (kept on update).',
|
|
128
|
+
description: 'Frontmatter status (seed | developing | mature | evergreen); defaults to developing (kept on update).',
|
|
129
129
|
},
|
|
130
130
|
summary: {
|
|
131
131
|
type: 'string',
|
|
132
132
|
description: 'One-line master-index entry; defaults to the first content line.',
|
|
133
133
|
},
|
|
134
|
+
extra_frontmatter: {
|
|
135
|
+
type: 'object',
|
|
136
|
+
additionalProperties: true,
|
|
137
|
+
description:
|
|
138
|
+
'Flat schema fields to merge into frontmatter, e.g. related, sources, question, answer_quality '
|
|
139
|
+
+ '(question/synthesis), entity_type/role (entity), complexity/domain/aliases (concept), '
|
|
140
|
+
+ 'source_type/author/url/key_claims (source), subjects/dimensions/verdict (comparison), '
|
|
141
|
+
+ 'decision_date (decision). Values are scalars or scalar lists; nesting is forbidden; '
|
|
142
|
+
+ 'managed fields (type/title/status/created/updated/tags) are rejected.',
|
|
143
|
+
},
|
|
134
144
|
source_path: {
|
|
135
145
|
type: 'string',
|
|
136
146
|
description: 'Vault-relative .raw/ source this page derives from, for delta tracking.',
|
|
@@ -162,11 +172,49 @@ export function createTools(vault, options = {}) {
|
|
|
162
172
|
return { alreadyIngested: true, hash: tracked.hash, title: args.title }
|
|
163
173
|
}
|
|
164
174
|
}
|
|
165
|
-
|
|
175
|
+
const { extra_frontmatter: extraFrontmatter, ...rest } = args
|
|
176
|
+
return await vault.writePage({ ...rest, extraFrontmatter })
|
|
166
177
|
},
|
|
167
178
|
presentCall: args => ({ card: 'generic', title: `Write wiki page: ${args.title}`, kind: 'other', rawInput: { title: args.title, type: args.type } }),
|
|
168
179
|
})
|
|
169
180
|
|
|
181
|
+
const wikiRename = defineTool({
|
|
182
|
+
name: 'wiki_rename',
|
|
183
|
+
description:
|
|
184
|
+
'Rename one wiki page and rewrite every [[wikilink]] to it across the vault (aliases and heading '
|
|
185
|
+
+ 'anchors preserved), move the file to the new title, retitle its frontmatter, swap its master-index '
|
|
186
|
+
+ 'and folder _index entries, and log the rename. The append-only log and dated lint reports keep the '
|
|
187
|
+
+ 'old name as history. Use this instead of manual file renames, which strand every inbound link.',
|
|
188
|
+
parameters: {
|
|
189
|
+
title: {
|
|
190
|
+
type: 'string',
|
|
191
|
+
required: true,
|
|
192
|
+
description: 'Exact current page title.',
|
|
193
|
+
},
|
|
194
|
+
new_title: {
|
|
195
|
+
type: 'string',
|
|
196
|
+
required: true,
|
|
197
|
+
description: 'New title; also the new filename and [[wikilink]] target. Title Case with spaces.',
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
output: {
|
|
201
|
+
schema: {
|
|
202
|
+
type: 'object',
|
|
203
|
+
additionalProperties: true,
|
|
204
|
+
},
|
|
205
|
+
render: (_args, value) => [{
|
|
206
|
+
type: 'text',
|
|
207
|
+
text: typeof value === 'object' && value !== null && 'to' in value
|
|
208
|
+
? `wiki_rename: [[${value.from}]] → [[${value.to}]]; rewrote links in ${value.linksRewritten} files; page at ${value.path}`
|
|
209
|
+
: 'wiki_rename: failed',
|
|
210
|
+
}],
|
|
211
|
+
},
|
|
212
|
+
async execute(args) {
|
|
213
|
+
return await vault.renamePage({ title: args.title, newTitle: args.new_title })
|
|
214
|
+
},
|
|
215
|
+
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
|
+
|
|
170
218
|
const wikiLint = defineTool({
|
|
171
219
|
name: 'wiki_lint',
|
|
172
220
|
description:
|
|
@@ -190,7 +238,7 @@ export function createTools(vault, options = {}) {
|
|
|
190
238
|
presentCall: () => ({ card: 'generic', title: 'Lint wiki vault', kind: 'other' }),
|
|
191
239
|
})
|
|
192
240
|
|
|
193
|
-
return [wikiQuery, wikiWrite, wikiLint]
|
|
241
|
+
return [wikiQuery, wikiWrite, wikiRename, wikiLint]
|
|
194
242
|
}
|
|
195
243
|
|
|
196
244
|
/**
|
package/lib/vault.js
CHANGED
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { createHash } from 'node:crypto'
|
|
15
|
-
import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises'
|
|
16
|
-
import { basename, isAbsolute, join, relative, sep } from 'node:path'
|
|
15
|
+
import { mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
|
|
16
|
+
import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path'
|
|
17
17
|
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
|
|
18
18
|
|
|
19
19
|
/** Page-type to vault folder routing (the suite's generic mode). */
|
|
@@ -24,6 +24,9 @@ export const TYPE_FOLDERS = {
|
|
|
24
24
|
domain: 'wiki/domains',
|
|
25
25
|
question: 'wiki/questions',
|
|
26
26
|
comparison: 'wiki/comparisons',
|
|
27
|
+
synthesis: 'wiki/questions',
|
|
28
|
+
decision: 'wiki/meta',
|
|
29
|
+
session: 'wiki/meta',
|
|
27
30
|
meta: 'wiki/meta',
|
|
28
31
|
}
|
|
29
32
|
|
|
@@ -158,25 +161,29 @@ export class Vault {
|
|
|
158
161
|
|
|
159
162
|
/**
|
|
160
163
|
* Write one wiki page with complete bookkeeping: frontmatter completion,
|
|
161
|
-
* filename-uniqueness guard, master-index entry,
|
|
162
|
-
* pages keep `created` and any unknown frontmatter
|
|
163
|
-
* to today.
|
|
164
|
+
* filename-uniqueness guard, master-index entry, folder `_index.md` entry,
|
|
165
|
+
* and a log entry. Existing pages keep `created` and any unknown frontmatter
|
|
166
|
+
* fields; `updated` moves to today.
|
|
164
167
|
* @param {object} input - the write request.
|
|
165
168
|
* @param {string} input.type - page type (routed to a folder).
|
|
166
169
|
* @param {string} input.title - page title; also the filename and wikilink target.
|
|
167
170
|
* @param {string} input.content - markdown body after frontmatter.
|
|
168
171
|
* @param {string[]} [input.tags] - frontmatter tags; defaults to `[type]`.
|
|
169
|
-
* @param {string} [input.status] - frontmatter status;
|
|
172
|
+
* @param {string} [input.status] - frontmatter status; one of seed/developing/mature/evergreen.
|
|
170
173
|
* @param {string} [input.summary] - one-line index entry; defaults to the first content line.
|
|
174
|
+
* @param {Record<string, unknown>} [input.extraFrontmatter] - flat schema fields to merge
|
|
175
|
+
* (related, sources, question, answer_quality, entity_type, aliases, …); must stay flat and
|
|
176
|
+
* cannot override the managed fields.
|
|
171
177
|
* @returns {Promise<{ path: string, created: boolean, title: string }>}
|
|
172
178
|
*/
|
|
173
|
-
async writePage({ type, title, content, tags, status, summary }) {
|
|
179
|
+
async writePage({ type, title, content, tags, status, summary, extraFrontmatter }) {
|
|
174
180
|
const path = this.pagePath(type, title)
|
|
175
181
|
return await this.enqueue(path, async () => {
|
|
176
182
|
await this.assertRoot()
|
|
177
183
|
const cleanTitle = title.endsWith('.md') ? title.slice(0, -3) : title
|
|
178
184
|
const existing = await this.readPage(path)
|
|
179
185
|
await this.assertUniqueFilename(cleanTitle, path)
|
|
186
|
+
validateExtraFrontmatter(extraFrontmatter)
|
|
180
187
|
const date = today()
|
|
181
188
|
const fields = {
|
|
182
189
|
...(existing?.fields ?? {}),
|
|
@@ -186,11 +193,13 @@ export class Vault {
|
|
|
186
193
|
created: existing?.fields?.created ?? date,
|
|
187
194
|
updated: date,
|
|
188
195
|
tags: tags ?? existing?.fields?.tags ?? [type],
|
|
196
|
+
...(extraFrontmatter ?? {}),
|
|
189
197
|
}
|
|
190
198
|
const file = `---\n${stringifyYaml(fields).trimEnd()}\n---\n\n${content.replace(/^\s*\n/, '')}\n`
|
|
191
199
|
await mkdir(join(path, '..'), { recursive: true })
|
|
192
200
|
await writeFile(path, file, 'utf8')
|
|
193
201
|
await this.updateIndex(type, cleanTitle, summary ?? firstContentLine(content))
|
|
202
|
+
await this.updateFolderIndex(type, cleanTitle, summary ?? firstContentLine(content))
|
|
194
203
|
await this.prependLog(`## [${date}] ${existing === undefined ? 'create' : 'update'} | ${cleanTitle}`, [
|
|
195
204
|
`- ${existing === undefined ? 'Created' : 'Updated'}: [[${cleanTitle}]]`,
|
|
196
205
|
])
|
|
@@ -198,6 +207,119 @@ export class Vault {
|
|
|
198
207
|
})
|
|
199
208
|
}
|
|
200
209
|
|
|
210
|
+
/**
|
|
211
|
+
* Add or refresh one entry in the owning folder's `_index.md` sub-index, the
|
|
212
|
+
* wiki-ingest contract's per-folder catalog. Missing files are created with
|
|
213
|
+
* a single `## <Type>` section; existing entries are replaced in place in
|
|
214
|
+
* the section's dominant separator style.
|
|
215
|
+
* @param {string} type - page type selecting folder and section label.
|
|
216
|
+
* @param {string} title - page title.
|
|
217
|
+
* @param {string} summary - one-line description.
|
|
218
|
+
*/
|
|
219
|
+
async updateFolderIndex(type, title, summary) {
|
|
220
|
+
const folder = this.typeFolders[type]
|
|
221
|
+
const indexPath = join(this.root, folder, '_index.md')
|
|
222
|
+
const entryPattern = new RegExp(`^\\s*-?\\s*\\[\\[${escapeRegExp(title)}\\]\\]`)
|
|
223
|
+
let raw = await readFile(indexPath, 'utf8').catch(() => undefined)
|
|
224
|
+
if (raw === undefined) {
|
|
225
|
+
const heading = `## ${capitalize(type)}s`
|
|
226
|
+
const file = `---\ntype: meta\ntitle: "${capitalize(basename(folder))} Index"\nupdated: ${today()}\n---\n\n${heading}\n\n- [[${title}]]: ${summary.replace(/\n/g, ' ')}\n`
|
|
227
|
+
await writeFile(indexPath, file, 'utf8')
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
const lines = raw.split('\n')
|
|
231
|
+
const at = lines.findIndex(line => entryPattern.test(line))
|
|
232
|
+
const entry = `- [[${title}]]: ${summary.replace(/\n/g, ' ')}`
|
|
233
|
+
if (at >= 0) {
|
|
234
|
+
lines[at] = entry
|
|
235
|
+
} else {
|
|
236
|
+
let insert = lines.length
|
|
237
|
+
while (insert > 0 && lines[insert - 1].trim() === '') insert -= 1
|
|
238
|
+
lines.splice(insert, 0, entry)
|
|
239
|
+
}
|
|
240
|
+
const updatedLine = lines.findIndex(line => /^updated: /.test(line))
|
|
241
|
+
if (updatedLine >= 0) lines[updatedLine] = `updated: ${today()}`
|
|
242
|
+
await writeFile(indexPath, `${lines.join('\n')}\n`, 'utf8')
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Rename one page and rewrite every wikilink to it across the vault:
|
|
247
|
+
* `[[Old]]` → `[[New]]`, preserving aliases (`[[Old|x]]` → `[[New|x]]`) and
|
|
248
|
+
* heading anchors. The append-only log and dated lint reports are historical
|
|
249
|
+
* records and keep the old name; frontmatter aliases keep resolving.
|
|
250
|
+
* @param {object} input - the rename request.
|
|
251
|
+
* @param {string} input.title - exact current page title.
|
|
252
|
+
* @param {string} input.newTitle - the new title and filename.
|
|
253
|
+
* @returns {Promise<{ from: string, to: string, path: string, linksRewritten: number, filesRewritten: string[] }>}
|
|
254
|
+
*/
|
|
255
|
+
async renamePage({ title, newTitle }) {
|
|
256
|
+
return await this.enqueue(join(this.root, 'wiki'), async () => {
|
|
257
|
+
await this.assertRoot()
|
|
258
|
+
const cleanNew = newTitle.endsWith('.md') ? newTitle.slice(0, -3) : newTitle
|
|
259
|
+
if (!/^[^/\\]+(\.md)?$/.test(newTitle) || newTitle.includes('\n')) {
|
|
260
|
+
throw new Error(`wiki-tools: newTitle must be a plain filename without path separators (got ${JSON.stringify(newTitle)})`)
|
|
261
|
+
}
|
|
262
|
+
if (cleanNew === title) throw new Error('wiki-tools: newTitle equals the current title')
|
|
263
|
+
const pages = await collectMarkdown(join(this.root, 'wiki'))
|
|
264
|
+
const target = pages.find(page => page.name === title)
|
|
265
|
+
if (target === undefined) throw new Error(`wiki-tools: no page named "${title}" exists in the vault`)
|
|
266
|
+
if (pages.some(page => page.name !== title && page.name.toLowerCase() === cleanNew.toLowerCase())) {
|
|
267
|
+
throw new Error(`wiki-tools: filename "${cleanNew}.md" already exists; wikilinks need unique filenames`)
|
|
268
|
+
}
|
|
269
|
+
const type = typeof target.fields?.type === 'string' && target.fields.type in this.typeFolders
|
|
270
|
+
? target.fields.type
|
|
271
|
+
: 'meta'
|
|
272
|
+
|
|
273
|
+
// 1. Rewrite links everywhere except immutable records.
|
|
274
|
+
const rewritten = []
|
|
275
|
+
for (const page of pages) {
|
|
276
|
+
if (page.name === title || page.name.toLowerCase() === 'log' || /^lint-report-/.test(page.name)) continue
|
|
277
|
+
const raw = await readFile(page.path, 'utf8').catch(() => undefined)
|
|
278
|
+
if (raw === undefined) continue
|
|
279
|
+
const pattern = new RegExp(`\\[\\[${escapeRegExp(title)}(\]\]|\||#)`, 'g')
|
|
280
|
+
const updated = raw.replace(pattern, `[[${cleanNew}$1`)
|
|
281
|
+
if (updated !== raw) {
|
|
282
|
+
await writeFile(page.path, updated, 'utf8')
|
|
283
|
+
rewritten.push(page.name)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// 2. Move the page and retitle its frontmatter.
|
|
288
|
+
const newPath = this.pagePath(type, cleanNew)
|
|
289
|
+
const oldRaw = await readFile(target.path, 'utf8')
|
|
290
|
+
const retitled = oldRaw.replace(/^(title:.*)$/m, `title: ${cleanNew}`)
|
|
291
|
+
await mkdir(dirname(newPath), { recursive: true })
|
|
292
|
+
await writeFile(newPath, retitled, 'utf8')
|
|
293
|
+
if (newPath !== target.path) await rm(target.path)
|
|
294
|
+
|
|
295
|
+
// 3. Swap index entries: drop the old lines, add the new.
|
|
296
|
+
await this.removeIndexEntries(title)
|
|
297
|
+
const summary = firstContentLine(splitFrontmatter(retitled, newPath).content)
|
|
298
|
+
await this.updateIndex(type, cleanNew, summary)
|
|
299
|
+
await this.updateFolderIndex(type, cleanNew, summary)
|
|
300
|
+
await this.prependLog(`## [${today()}] rename | ${title}`, [
|
|
301
|
+
`- Renamed: [[${title}]] → [[${cleanNew}]] (${rewritten.length} files' links rewritten)`,
|
|
302
|
+
])
|
|
303
|
+
return { from: title, to: cleanNew, path: newPath, linksRewritten: rewritten.length, filesRewritten: rewritten }
|
|
304
|
+
})
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Remove one page's entry lines from the master index and its folder
|
|
309
|
+
* `_index.md`, used when a rename replaces rather than refreshes.
|
|
310
|
+
* @param {string} title - page title whose entries are removed.
|
|
311
|
+
*/
|
|
312
|
+
async removeIndexEntries(title) {
|
|
313
|
+
const pattern = new RegExp(`^\\s*-?\\s*\\[\\[${escapeRegExp(title)}\\]\\]`)
|
|
314
|
+
for (const indexPath of [join(this.root, 'wiki', 'index.md'), ...Object.values(this.typeFolders).map(folder => join(this.root, folder, '_index.md'))]) {
|
|
315
|
+
const raw = await readFile(indexPath, 'utf8').catch(() => undefined)
|
|
316
|
+
if (raw === undefined) continue
|
|
317
|
+
const lines = raw.split('\n')
|
|
318
|
+
const filtered = lines.filter(line => !pattern.test(line))
|
|
319
|
+
if (filtered.length !== lines.length) await writeFile(indexPath, `${filtered.join('\n')}\n`, 'utf8')
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
201
323
|
/**
|
|
202
324
|
* Reject a title whose filename already exists elsewhere in the tree:
|
|
203
325
|
* wikilinks address pages by bare filename, so duplicates break resolution.
|
|
@@ -325,6 +447,33 @@ function escapeRegExp(value) {
|
|
|
325
447
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
326
448
|
}
|
|
327
449
|
|
|
450
|
+
/** Frontmatter fields writePage manages itself; extraFrontmatter cannot override them. */
|
|
451
|
+
const MANAGED_FIELDS = new Set(['type', 'title', 'status', 'created', 'updated', 'tags'])
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Validate caller-supplied schema fields: flat mapping of primitives or lists
|
|
455
|
+
* of primitives (the vault schema forbids nesting for Obsidian's Properties UI).
|
|
456
|
+
* @param {Record<string, unknown> | undefined} extra - the extraFrontmatter input.
|
|
457
|
+
* @returns {void} throws on any violation.
|
|
458
|
+
*/
|
|
459
|
+
function validateExtraFrontmatter(extra) {
|
|
460
|
+
if (extra === undefined) return
|
|
461
|
+
if (typeof extra !== 'object' || extra === null || Array.isArray(extra)) {
|
|
462
|
+
throw new Error('wiki-tools: extraFrontmatter must be a flat object of schema fields')
|
|
463
|
+
}
|
|
464
|
+
for (const [key, value] of Object.entries(extra)) {
|
|
465
|
+
if (MANAGED_FIELDS.has(key)) {
|
|
466
|
+
throw new Error(`wiki-tools: extraFrontmatter cannot override managed field "${key}"`)
|
|
467
|
+
}
|
|
468
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
469
|
+
throw new Error(`wiki-tools: extraFrontmatter must stay flat; "${key}" is an object (the schema forbids nesting)`)
|
|
470
|
+
}
|
|
471
|
+
if (Array.isArray(value) && value.some(item => item !== null && typeof item === 'object')) {
|
|
472
|
+
throw new Error(`wiki-tools: extraFrontmatter list "${key}" may hold only scalars`)
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
328
477
|
/**
|
|
329
478
|
* Pick the index separator for one entry: reuse the matched line's style, else
|
|
330
479
|
* the section's dominant style (`: ` or ` — `), else the canonical colon.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-wiki-tools",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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",
|