dsh-plugin-wiki-tools 0.6.0 → 0.8.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 +95 -5
- package/lib/scaffold.js +235 -0
- package/lib/vault.js +156 -7
- package/package.json +1 -1
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']
|
|
@@ -38,8 +39,8 @@ export const Config = z.object({
|
|
|
38
39
|
}).default({}),
|
|
39
40
|
})
|
|
40
41
|
|
|
41
|
-
const PAGE_TYPES = ['source', 'entity', 'concept', 'domain', 'question', 'comparison', 'meta']
|
|
42
|
-
const STATUSES = ['seed', 'developing', '
|
|
42
|
+
const PAGE_TYPES = ['source', 'entity', 'concept', 'domain', 'question', 'synthesis', 'comparison', 'decision', 'session', 'meta']
|
|
43
|
+
const STATUSES = ['seed', 'developing', 'mature', 'evergreen']
|
|
43
44
|
|
|
44
45
|
/**
|
|
45
46
|
* Build the three wiki tool definitions over one vault. Exported for tests.
|
|
@@ -125,12 +126,22 @@ export function createTools(vault, options = {}) {
|
|
|
125
126
|
status: {
|
|
126
127
|
type: 'string',
|
|
127
128
|
enum: STATUSES,
|
|
128
|
-
description: 'Frontmatter status; defaults to developing (kept on update).',
|
|
129
|
+
description: 'Frontmatter status (seed | developing | mature | evergreen); defaults to developing (kept on update).',
|
|
129
130
|
},
|
|
130
131
|
summary: {
|
|
131
132
|
type: 'string',
|
|
132
133
|
description: 'One-line master-index entry; defaults to the first content line.',
|
|
133
134
|
},
|
|
135
|
+
extra_frontmatter: {
|
|
136
|
+
type: 'object',
|
|
137
|
+
additionalProperties: true,
|
|
138
|
+
description:
|
|
139
|
+
'Flat schema fields to merge into frontmatter, e.g. related, sources, question, answer_quality '
|
|
140
|
+
+ '(question/synthesis), entity_type/role (entity), complexity/domain/aliases (concept), '
|
|
141
|
+
+ 'source_type/author/url/key_claims (source), subjects/dimensions/verdict (comparison), '
|
|
142
|
+
+ 'decision_date (decision). Values are scalars or scalar lists; nesting is forbidden; '
|
|
143
|
+
+ 'managed fields (type/title/status/created/updated/tags) are rejected.',
|
|
144
|
+
},
|
|
134
145
|
source_path: {
|
|
135
146
|
type: 'string',
|
|
136
147
|
description: 'Vault-relative .raw/ source this page derives from, for delta tracking.',
|
|
@@ -162,11 +173,90 @@ export function createTools(vault, options = {}) {
|
|
|
162
173
|
return { alreadyIngested: true, hash: tracked.hash, title: args.title }
|
|
163
174
|
}
|
|
164
175
|
}
|
|
165
|
-
|
|
176
|
+
const { extra_frontmatter: extraFrontmatter, ...rest } = args
|
|
177
|
+
return await vault.writePage({ ...rest, extraFrontmatter })
|
|
166
178
|
},
|
|
167
179
|
presentCall: args => ({ card: 'generic', title: `Write wiki page: ${args.title}`, kind: 'other', rawInput: { title: args.title, type: args.type } }),
|
|
168
180
|
})
|
|
169
181
|
|
|
182
|
+
const wikiRename = defineTool({
|
|
183
|
+
name: 'wiki_rename',
|
|
184
|
+
description:
|
|
185
|
+
'Rename one wiki page and rewrite every [[wikilink]] to it across the vault (aliases and heading '
|
|
186
|
+
+ 'anchors preserved), move the file to the new title, retitle its frontmatter, swap its master-index '
|
|
187
|
+
+ 'and folder _index entries, and log the rename. The append-only log and dated lint reports keep the '
|
|
188
|
+
+ 'old name as history. Use this instead of manual file renames, which strand every inbound link.',
|
|
189
|
+
parameters: {
|
|
190
|
+
title: {
|
|
191
|
+
type: 'string',
|
|
192
|
+
required: true,
|
|
193
|
+
description: 'Exact current page title.',
|
|
194
|
+
},
|
|
195
|
+
new_title: {
|
|
196
|
+
type: 'string',
|
|
197
|
+
required: true,
|
|
198
|
+
description: 'New title; also the new filename and [[wikilink]] target. Title Case with spaces.',
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
output: {
|
|
202
|
+
schema: {
|
|
203
|
+
type: 'object',
|
|
204
|
+
additionalProperties: true,
|
|
205
|
+
},
|
|
206
|
+
render: (_args, value) => [{
|
|
207
|
+
type: 'text',
|
|
208
|
+
text: typeof value === 'object' && value !== null && 'to' in value
|
|
209
|
+
? `wiki_rename: [[${value.from}]] → [[${value.to}]]; rewrote links in ${value.linksRewritten} files; page at ${value.path}`
|
|
210
|
+
: 'wiki_rename: failed',
|
|
211
|
+
}],
|
|
212
|
+
},
|
|
213
|
+
async execute(args) {
|
|
214
|
+
return await vault.renamePage({ title: args.title, newTitle: args.new_title })
|
|
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 } }),
|
|
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
|
+
|
|
170
260
|
const wikiLint = defineTool({
|
|
171
261
|
name: 'wiki_lint',
|
|
172
262
|
description:
|
|
@@ -190,7 +280,7 @@ export function createTools(vault, options = {}) {
|
|
|
190
280
|
presentCall: () => ({ card: 'generic', title: 'Lint wiki vault', kind: 'other' }),
|
|
191
281
|
})
|
|
192
282
|
|
|
193
|
-
return [wikiQuery, wikiWrite, wikiLint]
|
|
283
|
+
return [wikiQuery, wikiWrite, wikiRename, wikiScaffold, wikiLint]
|
|
194
284
|
}
|
|
195
285
|
|
|
196
286
|
/**
|
package/lib/scaffold.js
ADDED
|
@@ -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/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.8.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",
|