dsh-plugin-wiki-tools 0.5.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.
Files changed (3) hide show
  1. package/index.js +68 -7
  2. package/lib/vault.js +157 -8
  3. 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', 'solid']
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.
@@ -76,7 +76,7 @@ export function createTools(vault, options = {}) {
76
76
  },
77
77
  render: (_args, value) => [{
78
78
  type: 'text',
79
- text: renderQueryResult(value),
79
+ text: `${renderVaultRoot(vault.root)}\n\n${renderQueryResult(value)}`,
80
80
  }],
81
81
  },
82
82
  async execute(args) {
@@ -95,7 +95,9 @@ export function createTools(vault, options = {}) {
95
95
  description:
96
96
  'Write or update one wiki page with full bookkeeping: routes the page to its type folder, '
97
97
  + 'completes YAML frontmatter (type, title, status, created, updated, tags), guards filename '
98
- + 'uniqueness, updates the master index entry, and prepends a log entry. The content is the '
98
+ + 'uniqueness, updates the master index entry, and prepends a log entry. Writes go to the '
99
+ + 'CONFIGURED VAULT (run wiki_query to see its absolute root), not the session workspace. '
100
+ + 'The content is the '
99
101
  + 'markdown body only — frontmatter is managed. With source_path, records the source hash in '
100
102
  + 'the ingest manifest and reports already_ingested for unchanged content unless force is set.',
101
103
  parameters: {
@@ -123,12 +125,22 @@ export function createTools(vault, options = {}) {
123
125
  status: {
124
126
  type: 'string',
125
127
  enum: STATUSES,
126
- description: 'Frontmatter status; defaults to developing (kept on update).',
128
+ description: 'Frontmatter status (seed | developing | mature | evergreen); defaults to developing (kept on update).',
127
129
  },
128
130
  summary: {
129
131
  type: 'string',
130
132
  description: 'One-line master-index entry; defaults to the first content line.',
131
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
+ },
132
144
  source_path: {
133
145
  type: 'string',
134
146
  description: 'Vault-relative .raw/ source this page derives from, for delta tracking.',
@@ -160,11 +172,49 @@ export function createTools(vault, options = {}) {
160
172
  return { alreadyIngested: true, hash: tracked.hash, title: args.title }
161
173
  }
162
174
  }
163
- return await vault.writePage(args)
175
+ const { extra_frontmatter: extraFrontmatter, ...rest } = args
176
+ return await vault.writePage({ ...rest, extraFrontmatter })
164
177
  },
165
178
  presentCall: args => ({ card: 'generic', title: `Write wiki page: ${args.title}`, kind: 'other', rawInput: { title: args.title, type: args.type } }),
166
179
  })
167
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
+
168
218
  const wikiLint = defineTool({
169
219
  name: 'wiki_lint',
170
220
  description:
@@ -188,7 +238,18 @@ export function createTools(vault, options = {}) {
188
238
  presentCall: () => ({ card: 'generic', title: 'Lint wiki vault', kind: 'other' }),
189
239
  })
190
240
 
191
- return [wikiQuery, wikiWrite, wikiLint]
241
+ return [wikiQuery, wikiWrite, wikiRename, wikiLint]
242
+ }
243
+
244
+ /**
245
+ * Render a compact byline disclosing the vault root. Without it, a model whose
246
+ * session workspace differs from the vault resolves `.raw/…` against the
247
+ * workspace and concludes the tools point somewhere else.
248
+ * @param {string} root - absolute vault root.
249
+ * @returns {string} the byline.
250
+ */
251
+ function renderVaultRoot(root) {
252
+ return `wiki vault: ${root} — every wiki tool (query, write, lint) operates on this configured vault, not the session workspace. Resolve vault-relative paths like .raw/… and wiki/… against this root.`
192
253
  }
193
254
 
194
255
  /**
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, and a log entry. Existing
162
- * pages keep `created` and any unknown frontmatter fields; `updated` moves
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; defaults to `developing` on create.
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.
@@ -279,7 +401,7 @@ export class Vault {
279
401
  */
280
402
  async trackSource({ sourcePath, pagesCreated = [], pagesUpdated = [] }) {
281
403
  const absolute = isAbsolute(sourcePath) ? sourcePath : join(this.root, sourcePath)
282
- const rel = relative(this.root, absolute)
404
+ const rel = relative(this.root, absolute).split(sep).join('/')
283
405
  const raw = await readFile(absolute).catch(error => {
284
406
  if (error.code === 'ENOENT') throw new Error(`wiki-tools: source ${sourcePath} not found under the vault`)
285
407
  throw error
@@ -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.5.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",