dsh-plugin-wiki-tools 0.11.0 → 0.13.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 +13 -15
  2. package/lib/vault.js +94 -7
  3. package/package.json +1 -1
package/index.js CHANGED
@@ -106,7 +106,9 @@ export function createTools(vault, options = {}) {
106
106
  + 'CONFIGURED VAULT (run wiki_query to see its absolute root), not the session workspace. '
107
107
  + 'The content is the '
108
108
  + 'markdown body only — frontmatter is managed. With source_path, records the source hash in '
109
- + 'the ingest manifest and reports already_ingested for unchanged content unless force is set.',
109
+ + 'the ingest manifest and reports already_ingested for unchanged content unless force is set. '
110
+ + 'The result lists unresolved wikilinks in the written page (targets that match no page or alias) '
111
+ + 'so dead links are fixed at write time, not at the next lint.',
110
112
  parameters: {
111
113
  title: {
112
114
  type: 'string',
@@ -164,23 +166,19 @@ export function createTools(vault, options = {}) {
164
166
  },
165
167
  render: (args, value) => [{
166
168
  type: 'text',
167
- text: typeof value === 'object' && value !== null && 'path' in value
168
- ? `wiki_write: ${value.created ? 'created' : 'updated'} ${value.path}`
169
- : `wiki_write: skipped ${args.title} (source unchanged)`,
169
+ text: typeof value === 'object' && value !== null && value.alreadyIngested === true
170
+ ? `wiki_write: skipped ${args.title} source hash unchanged (pass force: true to re-ingest)`
171
+ : typeof value === 'object' && value !== null && 'path' in value
172
+ ? `wiki_write: ${value.created ? 'created' : 'updated'} ${value.path}`
173
+ + (Array.isArray(value.unresolvedLinks) && value.unresolvedLinks.length > 0
174
+ ? `\nwiki_write: note — ${value.unresolvedLinks.length} unresolved wikilink(s) in this page: ${value.unresolvedLinks.slice(0, 8).join(', ')}${value.unresolvedLinks.length > 8 ? ', …' : ''}. Create those pages or fix the targets; until then lint reports them as dead links.`
175
+ : '')
176
+ : `wiki_write: skipped ${args.title} (source unchanged)`,
170
177
  }],
171
178
  },
172
179
  async execute(args) {
173
- if (args.source_path !== undefined) {
174
- const tracked = await vault.trackSource({
175
- sourcePath: args.source_path,
176
- pagesCreated: [args.title],
177
- })
178
- if (tracked.alreadyIngested && args.force !== true) {
179
- return { alreadyIngested: true, hash: tracked.hash, title: args.title }
180
- }
181
- }
182
- const { extra_frontmatter: extraFrontmatter, ...rest } = args
183
- return await vault.writePage({ ...rest, extraFrontmatter })
180
+ const { extra_frontmatter: extraFrontmatter, source_path: sourcePath, ...rest } = args
181
+ return await vault.writePage({ ...rest, extraFrontmatter, sourcePath })
184
182
  },
185
183
  presentCall: args => ({ card: 'generic', title: `Write wiki page: ${args.title}`, kind: 'other', rawInput: { title: args.title, type: args.type } }),
186
184
  })
package/lib/vault.js CHANGED
@@ -76,6 +76,18 @@ export function today() {
76
76
  return new Date().toISOString().slice(0, 10)
77
77
  }
78
78
 
79
+ /**
80
+ * Compare a stored manifest key against a normalized vault-relative path.
81
+ * Hand-written entries (the wiki-ingest skill era) may carry the platform's
82
+ * backslash separators; matching must not depend on them.
83
+ * @param {string} storedKey - key as written in `.raw/.manifest.json`.
84
+ * @param {string} rel - normalized `.raw/…` path.
85
+ * @returns {boolean}
86
+ */
87
+ function sameSourceKey(storedKey, rel) {
88
+ return storedKey === rel || storedKey.replace(/\\/g, '/') === rel
89
+ }
90
+
79
91
  /**
80
92
  * One vault root. All bookkeeping mutations go through {@link Vault.writePage},
81
93
  * which completes frontmatter, updates the master index, and prepends a log
@@ -277,9 +289,13 @@ export class Vault {
277
289
  * @param {Record<string, unknown>} [input.extraFrontmatter] - flat schema fields to merge
278
290
  * (related, sources, question, answer_quality, entity_type, aliases, …); must stay flat and
279
291
  * cannot override the managed fields.
280
- * @returns {Promise<{ path: string, created: boolean, title: string }>}
292
+ * @param {string} [input.sourcePath] - vault-relative `.raw/` source this page derives from;
293
+ * registers/refreshes the manifest entry (sha256 delta tracking) and returns `alreadyIngested`
294
+ * without writing when the hash is unchanged.
295
+ * @param {boolean} [input.force] - with sourcePath, write even when the source hash is unchanged.
296
+ * @returns {Promise<{ path: string, created: boolean, title: string } | { alreadyIngested: true, title: string, sourceHash: string, detail: string }>}
281
297
  */
282
- async writePage({ type, title, content, tags, status, summary, extraFrontmatter }) {
298
+ async writePage({ type, title, content, tags, status, summary, extraFrontmatter, sourcePath, force }) {
283
299
  const path = this.pagePath(type, title)
284
300
  return await this.enqueue(path, async () => await this.withFileLock(relative(this.root, path).split(sep).join('/'), async () => {
285
301
  await this.assertRoot()
@@ -290,6 +306,19 @@ export class Vault {
290
306
  if (status !== undefined && !PAGE_STATUSES.includes(status)) {
291
307
  throw new Error(`wiki-tools: status must be one of ${PAGE_STATUSES.join('/')} (got ${JSON.stringify(status)})`)
292
308
  }
309
+ // Source registration is mechanical bookkeeping, not model work: when the
310
+ // page summarizes a `.raw/` source, the manifest entry (sha256 delta
311
+ // tracking) is written by this tool, and an unchanged hash is rejected
312
+ // unless the caller forces a re-ingest.
313
+ if (sourcePath !== undefined) {
314
+ const { hash, alreadyIngested } = await this.inspectSource(sourcePath)
315
+ if (alreadyIngested && force !== true) {
316
+ return {
317
+ alreadyIngested: true, title: cleanTitle, sourceHash: hash,
318
+ detail: 'source hash unchanged; pass force: true to re-ingest, or omit source_path for a plain page write',
319
+ }
320
+ }
321
+ }
293
322
  const date = today()
294
323
  const fields = {
295
324
  ...(existing?.fields ?? {}),
@@ -310,7 +339,25 @@ export class Vault {
310
339
  `- ${existing === undefined ? 'Created' : 'Updated'}: [[${cleanTitle}]]`,
311
340
  ])
312
341
  this.gitCommit(`wiki: ${existing === undefined ? 'create' : 'update'} ${cleanTitle}`)
313
- return { path, created: existing === undefined, title: cleanTitle }
342
+ if (sourcePath !== undefined) {
343
+ await this.trackSource({
344
+ sourcePath,
345
+ pagesCreated: existing === undefined ? [cleanTitle] : [],
346
+ pagesUpdated: existing === undefined ? [] : [cleanTitle],
347
+ })
348
+ }
349
+ // Surface dead-on-arrival wikilinks now, not at the next lint: forward
350
+ // links to pages about to be created are legitimate, so this is a note,
351
+ // never a rejection.
352
+ const pagesNow = await collectMarkdown(join(this.root, 'wiki'))
353
+ const names = new Set(pagesNow.map(page => page.name))
354
+ const aliases = buildAliasMap(pagesNow)
355
+ const unresolvedLinks = [...new Set(extractWikilinks(content))]
356
+ .filter(target => resolveLinkTarget(target, names, aliases) === undefined)
357
+ return {
358
+ path, created: existing === undefined, title: cleanTitle,
359
+ ...(unresolvedLinks.length > 0 ? { unresolvedLinks } : {}),
360
+ }
314
361
  }))
315
362
  }
316
363
 
@@ -370,6 +417,9 @@ export class Vault {
370
417
  const pages = await collectMarkdown(join(this.root, 'wiki'))
371
418
  const target = pages.find(page => page.name === title)
372
419
  if (target === undefined) throw new Error(`wiki-tools: no page named "${title}" exists in the vault`)
420
+ if (isMachineryPage(title)) {
421
+ throw new Error(`wiki-tools: "${title}" is vault machinery (index, log, hot cache, overview, sub-index, or lint report); renames would break conventions — fix the pages linking to it instead`)
422
+ }
373
423
  if (pages.some(page => page.name !== title && page.name.toLowerCase() === cleanNew.toLowerCase())) {
374
424
  throw new Error(`wiki-tools: filename "${cleanNew}.md" already exists; wikilinks need unique filenames`)
375
425
  }
@@ -443,7 +493,9 @@ export class Vault {
443
493
  await rm(absolute)
444
494
  const manifestPath = join(this.root, '.raw', '.manifest.json')
445
495
  const manifest = await readJson(manifestPath, { sources: {} })
446
- delete manifest.sources[rel]
496
+ for (const key of Object.keys(manifest.sources)) {
497
+ if (sameSourceKey(key, rel)) delete manifest.sources[key]
498
+ }
447
499
  await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
448
500
  await this.prependLog(`## [${today()}] archive | ${rel}`, [
449
501
  `- Archived: \`${rel}\` → \`.archive/${rel.slice('.raw/'.length)}\``,
@@ -559,8 +611,13 @@ export class Vault {
559
611
  const hash = createHash('sha256').update(raw).digest('hex')
560
612
  const manifestPath = join(this.root, '.raw', '.manifest.json')
561
613
  const manifest = await readJson(manifestPath, { sources: {} })
562
- const previous = manifest.sources[rel] ?? manifest.sources[sourcePath]
614
+ let previous
615
+ let twinKey
616
+ for (const [key, entry] of Object.entries(manifest.sources)) {
617
+ if (sameSourceKey(key, rel)) { previous = entry; twinKey = key; break }
618
+ }
563
619
  const alreadyIngested = previous !== undefined && previous.hash === hash
620
+ if (twinKey !== undefined && twinKey !== rel) delete manifest.sources[twinKey]
564
621
  manifest.sources[rel] = {
565
622
  hash,
566
623
  ingested_at: today(),
@@ -572,6 +629,31 @@ export class Vault {
572
629
  return { hash, alreadyIngested }
573
630
  })
574
631
  }
632
+
633
+ /**
634
+ * Hash one raw source and report whether the manifest already carries this
635
+ * exact hash — the wiki-ingest delta check's mechanical half.
636
+ * @param {string} sourcePath - vault-relative (or absolute) `.raw/` path.
637
+ * @returns {Promise<{ rel: string, hash: string, alreadyIngested: boolean }>}
638
+ */
639
+ async inspectSource(sourcePath) {
640
+ const absolute = isAbsolute(sourcePath) ? sourcePath : join(this.root, sourcePath)
641
+ const rel = relative(this.root, absolute).split(sep).join('/')
642
+ if (!rel.startsWith('.raw/')) {
643
+ throw new Error('wiki-tools: source_path must be a vault-relative path under .raw/')
644
+ }
645
+ const raw = await readFile(absolute).catch(error => {
646
+ if (error.code === 'ENOENT') throw new Error(`wiki-tools: source ${sourcePath} not found under the vault`)
647
+ throw error
648
+ })
649
+ const hash = createHash('sha256').update(raw).digest('hex')
650
+ const manifest = await readJson(join(this.root, '.raw', '.manifest.json'), { sources: {} })
651
+ let previous
652
+ for (const [key, entry] of Object.entries(manifest.sources)) {
653
+ if (sameSourceKey(key, rel)) { previous = entry; break }
654
+ }
655
+ return { rel, hash, alreadyIngested: previous !== undefined && previous.hash === hash }
656
+ }
575
657
  }
576
658
 
577
659
  /**
@@ -761,7 +843,12 @@ export function extractWikilinks(content) {
761
843
  * @returns {boolean} whether the page is machinery.
762
844
  */
763
845
  export function isMachineryPage(name) {
764
- return META_FILENAMES.has(name.toLowerCase())
846
+ const lowered = name.toLowerCase()
847
+ return META_FILENAMES.has(lowered)
765
848
  || name.startsWith('_')
766
- || /^lint-report-/.test(name)
849
+ || lowered.startsWith('_')
850
+ // Case-insensitive with a real boundary: reports have been hand-retitled
851
+ // to "Lint Report …" in real sessions, and those are still machinery —
852
+ // but "Lint Reporter Profile" is content.
853
+ || /^lint[- _]?report([- _].+)?$/i.test(name)
767
854
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-wiki-tools",
3
- "version": "0.11.0",
3
+ "version": "0.13.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",