dsh-plugin-wiki-tools 0.1.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/LICENSE +21 -0
- package/README.md +70 -0
- package/cordis.patch.yml +13 -0
- package/index.js +209 -0
- package/lib/lint.js +254 -0
- package/lib/search.js +111 -0
- package/lib/vault.js +360 -0
- package/package.json +53 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lion
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# dsh-plugin-wiki-tools
|
|
2
|
+
|
|
3
|
+
English | [中文](#中文)
|
|
4
|
+
|
|
5
|
+
Native [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) tools for an Obsidian wiki vault: `wiki_query`, `wiki_write`, and `wiki_lint` implement the mechanical core of the wiki skill suite — path routing, frontmatter completion, index/log bookkeeping, source delta tracking, and health checks — so the model spends its turns on synthesis instead of filesystem chores.
|
|
6
|
+
|
|
7
|
+
Pair with **[dsh-plugin-wiki-skills](https://github.com/Lion-1209/dsh-plugin-wiki-skills)** for the prompt-level skills (`wiki`, `wiki-ingest`, `wiki-query`, `wiki-lint`, `save`).
|
|
8
|
+
|
|
9
|
+
## Attribution
|
|
10
|
+
|
|
11
|
+
The vault layout and operation contracts follow the LLM Wiki pattern (Andrej Karpathy) as embodied by [claude-obsidian](https://github.com/AgriciDaniel/claude-obsidian) (MIT, © 2026 AgriciDaniel). This package is an independent plain-ESM implementation of the mechanical core; it contains no code or skill text from claude-obsidian.
|
|
12
|
+
|
|
13
|
+
## Tools
|
|
14
|
+
|
|
15
|
+
| Tool | What it does |
|
|
16
|
+
| --- | --- |
|
|
17
|
+
| `wiki_query` | Quick mode returns `hot.md` + `index.md` verbatim (the skill's read order); standard mode is full-text search over content pages with snippets and the inbound/outbound link graph |
|
|
18
|
+
| `wiki_write` | Writes one page with complete bookkeeping: type→folder routing, frontmatter completion (keeps `created` and unknown fields on update), filename-uniqueness guard, master-index entry, log entry; with `source_path`, records the source hash and skips unchanged sources unless `force` |
|
|
19
|
+
| `wiki_lint` | Health check: duplicate filenames, dead wikilinks, orphan pages, frontmatter gaps, empty sections, stale index entries, stale hot cache — report only, with suggestions, written to `wiki/meta/lint-report-<date>.md` |
|
|
20
|
+
|
|
21
|
+
## Install and configure
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
dsh plugin --profile web add dsh-plugin-wiki-tools
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`vaultPath` is **required**. The boot fails loud until you set it from your profile's `cordis.patch.yml`:
|
|
28
|
+
|
|
29
|
+
```yaml
|
|
30
|
+
- id: wiki-tools
|
|
31
|
+
config:
|
|
32
|
+
vaultPath: /absolute/path/to/vault
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The vault is the directory holding `wiki/` and `.raw/` — scaffold it first with the `wiki` skill's SCAFFOLD operation. `maxQueryResults` (default 10) is optional.
|
|
36
|
+
|
|
37
|
+
## Design notes
|
|
38
|
+
|
|
39
|
+
- The vault is host-local user data outside any session workspace (cross-project referencing is the point), so the tools read and write through `node:fs` against the explicitly configured absolute root — not a dsh filesystem seam.
|
|
40
|
+
- Writes serialize per target file in-process; filenames are guarded unique vault-wide because wikilinks resolve by bare name.
|
|
41
|
+
- Registers on `ctx.tools` via `defineTool`; schemas stay stable regardless of vault contents.
|
|
42
|
+
|
|
43
|
+
## Develop
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
npm install
|
|
47
|
+
node --test
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## License
|
|
51
|
+
|
|
52
|
+
[MIT](LICENSE)
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
# 中文
|
|
57
|
+
|
|
58
|
+
Obsidian wiki vault 的 DeepSeek Harness 原生工具:`wiki_query`、`wiki_write`、`wiki_lint` 实现知识库技能套件的机械核心(路径路由、frontmatter 补全、索引/日志簿记、来源增量追踪、健康检查),让模型把轮次花在综合而非文件操作上。与 [dsh-plugin-wiki-skills](https://github.com/Lion-1209/dsh-plugin-wiki-skills) 配套使用。
|
|
59
|
+
|
|
60
|
+
## 出处
|
|
61
|
+
|
|
62
|
+
vault 布局与操作契约遵循 LLM Wiki 模式(Karpathy),形态来自 claude-obsidian(AgriciDaniel,MIT)。本包为独立的纯 ESM 实现,不含 claude-obsidian 的代码或技能文本。
|
|
63
|
+
|
|
64
|
+
## 安装与配置
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
dsh plugin --profile web add dsh-plugin-wiki-tools
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`vaultPath` 为必填项:在你的 profile `cordis.patch.yml` 里覆盖 `wiki-tools` 行的 config 指向 vault 根目录(含 `wiki/` 与 `.raw/`,先用 wiki 技能的 SCAFFOLD 操作搭建),否则启动时会明确报错并给出示例。
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# The dsh-plugin-wiki-tools bundle patch: one insert over the profile root.
|
|
2
|
+
# vaultPath is REQUIRED: replace this row's config from your own profile
|
|
3
|
+
# cordis.patch.yml, e.g.
|
|
4
|
+
#
|
|
5
|
+
# - id: wiki-tools
|
|
6
|
+
# config:
|
|
7
|
+
# vaultPath: /absolute/path/to/vault
|
|
8
|
+
#
|
|
9
|
+
# A boot without vaultPath fails loud with these instructions.
|
|
10
|
+
|
|
11
|
+
- insert:
|
|
12
|
+
- id: wiki-tools
|
|
13
|
+
name: 'dsh-plugin-wiki-tools'
|
package/index.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wiki Tools — native DeepSeek Harness tools over an Obsidian wiki vault.
|
|
3
|
+
*
|
|
4
|
+
* Registers `wiki_query`, `wiki_write`, and `wiki_lint` on `ctx.tools`,
|
|
5
|
+
* implementing the mechanical core of the wiki skill suite (path routing,
|
|
6
|
+
* frontmatter completion, index/log bookkeeping, source delta tracking, and
|
|
7
|
+
* health checks) so the model spends its turns on synthesis instead of
|
|
8
|
+
* filesystem chores. Vault design follows the LLM Wiki pattern; see
|
|
9
|
+
* dsh-plugin-wiki-skills for the skill half.
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-plugin-wiki-tools
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
15
|
+
import z from '@deepseek-ai/schemastery'
|
|
16
|
+
import { Vault } from './lib/vault.js'
|
|
17
|
+
import { quickView, searchVault } from './lib/search.js'
|
|
18
|
+
import { lintVault } from './lib/lint.js'
|
|
19
|
+
|
|
20
|
+
export const name = 'wiki-tools'
|
|
21
|
+
export const inject = ['tools']
|
|
22
|
+
|
|
23
|
+
/** Tool-plugin configuration. */
|
|
24
|
+
export const Config = z.object({
|
|
25
|
+
/** Absolute path to the vault root: the directory holding `wiki/` and `.raw/`. */
|
|
26
|
+
vaultPath: z.string().required(),
|
|
27
|
+
/** Maximum pages returned by one wiki_query standard-mode call. */
|
|
28
|
+
maxQueryResults: z.number().default(10),
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const PAGE_TYPES = ['source', 'entity', 'concept', 'domain', 'question', 'comparison', 'meta']
|
|
32
|
+
const STATUSES = ['seed', 'developing', 'solid']
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build the three wiki tool definitions over one vault. Exported for tests.
|
|
36
|
+
* @param {Vault} vault - the configured vault.
|
|
37
|
+
* @param {{ maxQueryResults?: number }} [options] - tool options.
|
|
38
|
+
* @returns {import('@deepseek-ai/dsh-tools').ToolDefinition[]}
|
|
39
|
+
*/
|
|
40
|
+
export function createTools(vault, options = {}) {
|
|
41
|
+
const limit = options.maxQueryResults ?? 10
|
|
42
|
+
|
|
43
|
+
const wikiQuery = defineTool({
|
|
44
|
+
name: 'wiki_query',
|
|
45
|
+
description:
|
|
46
|
+
'Search the knowledge vault. Quick mode returns the hot cache and master index verbatim — '
|
|
47
|
+
+ 'read those before opening any page. Standard mode runs full-text search over every wiki page '
|
|
48
|
+
+ 'and returns ranked matches with snippets, inbound links, and outbound link counts. '
|
|
49
|
+
+ 'Use standard mode when quick context is not enough.',
|
|
50
|
+
parameters: {
|
|
51
|
+
query: {
|
|
52
|
+
type: 'string',
|
|
53
|
+
required: true,
|
|
54
|
+
description: 'The search text or question topic.',
|
|
55
|
+
},
|
|
56
|
+
mode: {
|
|
57
|
+
type: 'string',
|
|
58
|
+
enum: ['quick', 'standard'],
|
|
59
|
+
description: 'quick returns hot.md + index.md only (~1500 tokens); standard searches all pages. Defaults to standard.',
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
output: {
|
|
63
|
+
schema: {
|
|
64
|
+
type: 'object',
|
|
65
|
+
additionalProperties: true,
|
|
66
|
+
},
|
|
67
|
+
render: (_args, value) => [{
|
|
68
|
+
type: 'text',
|
|
69
|
+
text: typeof value === 'object' && value !== null && 'results' in value
|
|
70
|
+
? `wiki_query: ${value.results.length} of ${value.totalMatches} matching pages`
|
|
71
|
+
: 'wiki_query: returned hot cache and master index',
|
|
72
|
+
}],
|
|
73
|
+
},
|
|
74
|
+
async execute(args) {
|
|
75
|
+
if (args.mode === 'quick') {
|
|
76
|
+
const view = await quickView(vault.root)
|
|
77
|
+
return { mode: 'quick', ...view }
|
|
78
|
+
}
|
|
79
|
+
const { results, totalMatches } = await searchVault(vault.root, { query: args.query, limit })
|
|
80
|
+
return { mode: 'standard', query: args.query, results, totalMatches }
|
|
81
|
+
},
|
|
82
|
+
presentCall: args => ({ card: 'generic', title: `Query wiki: ${args.query}`, kind: 'read', rawInput: args.query }),
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
const wikiWrite = defineTool({
|
|
86
|
+
name: 'wiki_write',
|
|
87
|
+
description:
|
|
88
|
+
'Write or update one wiki page with full bookkeeping: routes the page to its type folder, '
|
|
89
|
+
+ 'completes YAML frontmatter (type, title, status, created, updated, tags), guards filename '
|
|
90
|
+
+ 'uniqueness, updates the master index entry, and prepends a log entry. The content is the '
|
|
91
|
+
+ 'markdown body only — frontmatter is managed. With source_path, records the source hash in '
|
|
92
|
+
+ 'the ingest manifest and reports already_ingested for unchanged content unless force is set.',
|
|
93
|
+
parameters: {
|
|
94
|
+
title: {
|
|
95
|
+
type: 'string',
|
|
96
|
+
required: true,
|
|
97
|
+
description: 'Page title; also the filename and [[wikilink]] target. Title Case with spaces.',
|
|
98
|
+
},
|
|
99
|
+
type: {
|
|
100
|
+
type: 'string',
|
|
101
|
+
required: true,
|
|
102
|
+
enum: PAGE_TYPES,
|
|
103
|
+
description: 'Page type, which selects the folder: sources, entities, concepts, domains, questions, comparisons, or meta.',
|
|
104
|
+
},
|
|
105
|
+
content: {
|
|
106
|
+
type: 'string',
|
|
107
|
+
required: true,
|
|
108
|
+
description: 'The markdown body after frontmatter.',
|
|
109
|
+
},
|
|
110
|
+
tags: {
|
|
111
|
+
type: 'array',
|
|
112
|
+
items: { type: 'string' },
|
|
113
|
+
description: 'Frontmatter tags; defaults to [type].',
|
|
114
|
+
},
|
|
115
|
+
status: {
|
|
116
|
+
type: 'string',
|
|
117
|
+
enum: STATUSES,
|
|
118
|
+
description: 'Frontmatter status; defaults to developing (kept on update).',
|
|
119
|
+
},
|
|
120
|
+
summary: {
|
|
121
|
+
type: 'string',
|
|
122
|
+
description: 'One-line master-index entry; defaults to the first content line.',
|
|
123
|
+
},
|
|
124
|
+
source_path: {
|
|
125
|
+
type: 'string',
|
|
126
|
+
description: 'Vault-relative .raw/ source this page derives from, for delta tracking.',
|
|
127
|
+
},
|
|
128
|
+
force: {
|
|
129
|
+
type: 'boolean',
|
|
130
|
+
description: 'Write even when the source hash is unchanged. Defaults to false.',
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
output: {
|
|
134
|
+
schema: {
|
|
135
|
+
type: 'object',
|
|
136
|
+
additionalProperties: true,
|
|
137
|
+
},
|
|
138
|
+
render: (args, value) => [{
|
|
139
|
+
type: 'text',
|
|
140
|
+
text: typeof value === 'object' && value !== null && 'path' in value
|
|
141
|
+
? `wiki_write: ${value.created ? 'created' : 'updated'} ${value.path}`
|
|
142
|
+
: `wiki_write: skipped ${args.title} (source unchanged)`,
|
|
143
|
+
}],
|
|
144
|
+
},
|
|
145
|
+
async execute(args) {
|
|
146
|
+
if (args.source_path !== undefined) {
|
|
147
|
+
const tracked = await vault.trackSource({
|
|
148
|
+
sourcePath: args.source_path,
|
|
149
|
+
pagesCreated: [args.title],
|
|
150
|
+
})
|
|
151
|
+
if (tracked.alreadyIngested && args.force !== true) {
|
|
152
|
+
return { alreadyIngested: true, hash: tracked.hash, title: args.title }
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return await vault.writePage(args)
|
|
156
|
+
},
|
|
157
|
+
presentCall: args => ({ card: 'generic', title: `Write wiki page: ${args.title}`, kind: 'other', rawInput: { title: args.title, type: args.type } }),
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
const wikiLint = defineTool({
|
|
161
|
+
name: 'wiki_lint',
|
|
162
|
+
description:
|
|
163
|
+
'Health-check the knowledge vault: duplicate filenames, dead wikilinks, orphan pages, '
|
|
164
|
+
+ 'frontmatter gaps, empty sections, stale index entries, and a stale hot cache. '
|
|
165
|
+
+ 'Report only — every issue carries a suggestion. Writes the dated report to wiki/meta/.',
|
|
166
|
+
parameters: {},
|
|
167
|
+
output: {
|
|
168
|
+
schema: {
|
|
169
|
+
type: 'object',
|
|
170
|
+
additionalProperties: true,
|
|
171
|
+
},
|
|
172
|
+
render: (_args, value) => [{
|
|
173
|
+
type: 'text',
|
|
174
|
+
text: typeof value === 'object' && value !== null && 'summary' in value
|
|
175
|
+
? `wiki_lint: ${value.summary.issues} issues across ${value.summary.pagesScanned} pages; report at ${value.reportPath ?? '(not written)'}`
|
|
176
|
+
: 'wiki_lint: failed',
|
|
177
|
+
}],
|
|
178
|
+
},
|
|
179
|
+
async execute() {
|
|
180
|
+
return await lintVault(vault.root)
|
|
181
|
+
},
|
|
182
|
+
presentCall: () => ({ card: 'generic', title: 'Lint wiki vault', kind: 'other' }),
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
return [wikiQuery, wikiWrite, wikiLint]
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Validate the deployment config and register the three wiki tools.
|
|
190
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx - registrant context carrying the tool registry.
|
|
191
|
+
* @param {import('@deepseek-ai/schemastery').Extract<typeof Config>} config - deployment config with the vault root.
|
|
192
|
+
* @returns {Promise<void>}
|
|
193
|
+
*/
|
|
194
|
+
export async function apply(ctx, config) {
|
|
195
|
+
if (typeof config.vaultPath !== 'string' || config.vaultPath.length === 0) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
'wiki-tools: config vaultPath is required. Set it on the wiki-tools row from your profile cordis.patch.yml, e.g.\n'
|
|
198
|
+
+ ' - id: wiki-tools\n'
|
|
199
|
+
+ ' config:\n'
|
|
200
|
+
+ ' vaultPath: /absolute/path/to/vault\n'
|
|
201
|
+
+ 'The vault is the directory holding wiki/ and .raw/ (scaffold it with the wiki skill first).',
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
const vault = new Vault(config.vaultPath)
|
|
205
|
+
await vault.assertRoot()
|
|
206
|
+
for (const tool of createTools(vault, config)) {
|
|
207
|
+
ctx.tools.register(tool)
|
|
208
|
+
}
|
|
209
|
+
}
|
package/lib/lint.js
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vault health checks: the mechanical half of the wiki-lint skill. Report
|
|
3
|
+
* only — auto-fixing is a human decision, so every issue carries a suggestion
|
|
4
|
+
* and the report lands in `wiki/meta/lint-report-YYYY-MM-DD.md`.
|
|
5
|
+
*
|
|
6
|
+
* @module dsh-plugin-wiki-tools/lib/lint
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
import { collectMarkdown, META_FILENAMES, splitFrontmatter, today } from './vault.js'
|
|
12
|
+
|
|
13
|
+
/** Frontmatter fields every content page must carry. */
|
|
14
|
+
const REQUIRED_FIELDS = ['type', 'status', 'created', 'updated', 'tags']
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Run every mechanical check over the vault.
|
|
18
|
+
* @param {string} root - absolute vault root.
|
|
19
|
+
* @returns {Promise<{ issues: Issue[], summary: { pagesScanned: number, issues: number, byCheck: Record<string, number> }, reportPath: string | undefined }>}
|
|
20
|
+
*/
|
|
21
|
+
export async function lintVault(root) {
|
|
22
|
+
const pages = await collectMarkdown(join(root, 'wiki'))
|
|
23
|
+
const issues = []
|
|
24
|
+
const add = (check, severity, page, detail, suggestion) => {
|
|
25
|
+
issues.push({ check, severity, page, detail, suggestion })
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
checkDuplicateFilenames(pages, add)
|
|
29
|
+
const indexed = await checkStaleIndexEntries(root, pages, add)
|
|
30
|
+
const inbound = checkDeadLinks(pages, add)
|
|
31
|
+
checkOrphans(pages, inbound, indexed, add)
|
|
32
|
+
checkFrontmatterGaps(pages, add)
|
|
33
|
+
checkEmptySections(pages, add)
|
|
34
|
+
await checkHotCacheStaleness(root, pages, add)
|
|
35
|
+
|
|
36
|
+
const byCheck = {}
|
|
37
|
+
for (const issue of issues) byCheck[issue.check] = (byCheck[issue.check] ?? 0) + 1
|
|
38
|
+
const reportPath = await writeReport(root, issues, pages.length)
|
|
39
|
+
return {
|
|
40
|
+
issues,
|
|
41
|
+
summary: { pagesScanned: pages.length, issues: issues.length, byCheck },
|
|
42
|
+
reportPath,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** @typedef {'error'|'warn'|'info'} Severity */
|
|
47
|
+
/** @typedef {{ check: string, severity: Severity, page: string, detail: string, suggestion: string }} Issue */
|
|
48
|
+
/** @typedef {(check: string, severity: Severity, page: string, detail: string, suggestion: string) => void} Add */
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Filenames must be unique vault-wide: wikilinks resolve by bare name.
|
|
52
|
+
* @param {Pages} pages - collected pages.
|
|
53
|
+
* @param {Add} add - issue recorder.
|
|
54
|
+
*/
|
|
55
|
+
function checkDuplicateFilenames(pages, add) {
|
|
56
|
+
const seen = new Map()
|
|
57
|
+
for (const page of pages) {
|
|
58
|
+
const key = page.name.toLowerCase()
|
|
59
|
+
if (seen.has(key)) {
|
|
60
|
+
add('duplicate-filename', 'error', page.name,
|
|
61
|
+
`filename also exists at ${seen.get(key)}`,
|
|
62
|
+
'Rename one page; wikilinks cannot address two files with one name')
|
|
63
|
+
} else {
|
|
64
|
+
seen.set(key, page.path)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Index entries pointing at pages that do not exist.
|
|
71
|
+
* @param {string} root - absolute vault root.
|
|
72
|
+
* @param {Pages} pages - collected pages.
|
|
73
|
+
* @param {Add} add - issue recorder.
|
|
74
|
+
* @returns {Promise<Set<string>>} titles listed in the master index.
|
|
75
|
+
*/
|
|
76
|
+
async function checkStaleIndexEntries(root, pages, add) {
|
|
77
|
+
const listed = new Set()
|
|
78
|
+
const raw = await readFile(join(root, 'wiki', 'index.md'), 'utf8').catch(() => undefined)
|
|
79
|
+
if (raw === undefined) return listed
|
|
80
|
+
for (const match of raw.matchAll(/\[\[([^\]]+)\]\]/g)) {
|
|
81
|
+
listed.add(match[1].split('|')[0].split('#')[0].trim())
|
|
82
|
+
}
|
|
83
|
+
const names = new Set(pages.map(page => page.name))
|
|
84
|
+
for (const title of listed) {
|
|
85
|
+
if (!names.has(title)) {
|
|
86
|
+
add('stale-index-entry', 'warn', title,
|
|
87
|
+
'listed in wiki/index.md but no page exists',
|
|
88
|
+
'Remove the entry, or restore/rename the page to match')
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return listed
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Wikilinks targeting pages that do not exist.
|
|
96
|
+
* @param {Pages} pages - collected pages.
|
|
97
|
+
* @param {Add} add - issue recorder.
|
|
98
|
+
* @returns {Map<string, string[]>} inbound links per page title.
|
|
99
|
+
*/
|
|
100
|
+
function checkDeadLinks(pages, add) {
|
|
101
|
+
const names = new Set(pages.map(page => page.name))
|
|
102
|
+
const inbound = new Map(pages.map(page => [page.name, []]))
|
|
103
|
+
for (const page of pages) {
|
|
104
|
+
for (const target of page.links) {
|
|
105
|
+
if (names.has(target)) {
|
|
106
|
+
inbound.get(target)?.push(page.name)
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
add('dead-link', 'error', page.name,
|
|
110
|
+
`links to [[${target}]] which does not exist`,
|
|
111
|
+
'Create a stub page or remove the link')
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return inbound
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Content pages with no inbound links and no index entry.
|
|
119
|
+
* @param {Pages} pages - collected pages.
|
|
120
|
+
* @param {Map<string, string[]>} inbound - inbound links per title.
|
|
121
|
+
* @param {Set<string>} indexed - titles listed in the master index.
|
|
122
|
+
* @param {Add} add - issue recorder.
|
|
123
|
+
*/
|
|
124
|
+
function checkOrphans(pages, inbound, indexed, add) {
|
|
125
|
+
for (const page of pages) {
|
|
126
|
+
const base = page.name.toLowerCase()
|
|
127
|
+
if (META_FILENAMES.has(base) || page.name.startsWith('_')) continue
|
|
128
|
+
const links = inbound.get(page.name) ?? []
|
|
129
|
+
const isIndexed = indexed.has(page.name)
|
|
130
|
+
if (links.length === 0 && !isIndexed) {
|
|
131
|
+
add('orphan-page', 'warn', page.name,
|
|
132
|
+
'no inbound wikilinks and not in the master index',
|
|
133
|
+
'Link it from a related page or the index, or delete it')
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Missing required frontmatter fields on content pages.
|
|
140
|
+
* @param {Pages} pages - collected pages.
|
|
141
|
+
* @param {Add} add - issue recorder.
|
|
142
|
+
*/
|
|
143
|
+
function checkFrontmatterGaps(pages, add) {
|
|
144
|
+
for (const page of pages) {
|
|
145
|
+
const base = page.name.toLowerCase()
|
|
146
|
+
if (META_FILENAMES.has(base) || page.name.startsWith('_')) continue
|
|
147
|
+
const missing = REQUIRED_FIELDS.filter(field => page.fields?.[field] === undefined)
|
|
148
|
+
if (missing.length > 0) {
|
|
149
|
+
add('frontmatter-gap', 'warn', page.name,
|
|
150
|
+
`missing fields: ${missing.join(', ')}`,
|
|
151
|
+
'Complete the frontmatter (type, status, created, updated, tags)')
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Headings with no content before the next heading.
|
|
158
|
+
* @param {Pages} pages - collected pages.
|
|
159
|
+
* @param {Add} add - issue recorder.
|
|
160
|
+
*/
|
|
161
|
+
function checkEmptySections(pages, add) {
|
|
162
|
+
for (const page of pages) {
|
|
163
|
+
if (META_FILENAMES.has(page.name.toLowerCase())) continue
|
|
164
|
+
const lines = [...page.content.split('\n'), '# __eof__']
|
|
165
|
+
let heading
|
|
166
|
+
let hasContent = false
|
|
167
|
+
for (const line of lines) {
|
|
168
|
+
if (/^#{1,6} /.test(line)) {
|
|
169
|
+
if (heading !== undefined && !hasContent) {
|
|
170
|
+
add('empty-section', 'info', page.name,
|
|
171
|
+
`section "${heading}" has no content`,
|
|
172
|
+
'Fill it, or remove the heading')
|
|
173
|
+
}
|
|
174
|
+
heading = line.replace(/^#+\s*/, '').replace('__eof__', '').trim()
|
|
175
|
+
hasContent = false
|
|
176
|
+
} else if (line.trim().length > 0) {
|
|
177
|
+
hasContent = true
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The hot cache lagging behind every page's last update.
|
|
185
|
+
* @param {string} root - absolute vault root.
|
|
186
|
+
* @param {Pages} pages - collected pages.
|
|
187
|
+
* @param {Add} add - issue recorder.
|
|
188
|
+
*/
|
|
189
|
+
async function checkHotCacheStaleness(root, pages, add) {
|
|
190
|
+
const raw = await readFile(join(root, 'wiki', 'hot.md'), 'utf8').catch(() => undefined)
|
|
191
|
+
if (raw === undefined) return
|
|
192
|
+
const { fields } = splitFrontmatter(raw, 'wiki/hot.md')
|
|
193
|
+
const hotUpdated = typeof fields?.updated === 'string' ? fields.updated : undefined
|
|
194
|
+
if (hotUpdated === undefined) return
|
|
195
|
+
const newest = pages
|
|
196
|
+
.map(page => (typeof page.fields?.updated === 'string' ? page.fields.updated : ''))
|
|
197
|
+
.sort()
|
|
198
|
+
.at(-1)
|
|
199
|
+
if (newest !== undefined && newest > hotUpdated) {
|
|
200
|
+
add('stale-hot-cache', 'warn', 'hot.md',
|
|
201
|
+
`hot cache updated ${hotUpdated}, newest page update ${newest}`,
|
|
202
|
+
'Refresh wiki/hot.md to reflect recent changes')
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Write the lint report page in the suite's canonical format.
|
|
208
|
+
* @param {string} root - absolute vault root.
|
|
209
|
+
* @param {Issue[]} issues - every found issue.
|
|
210
|
+
* @param {number} pagesScanned - pages covered by the run.
|
|
211
|
+
* @returns {Promise<string | undefined>} the report path, or undefined when writing failed.
|
|
212
|
+
*/
|
|
213
|
+
async function writeReport(root, issues, pagesScanned) {
|
|
214
|
+
const date = today()
|
|
215
|
+
const path = join(root, 'wiki', 'meta', `lint-report-${date}.md`)
|
|
216
|
+
const sections = new Map()
|
|
217
|
+
for (const issue of issues) {
|
|
218
|
+
if (!sections.has(issue.check)) sections.set(issue.check, [])
|
|
219
|
+
sections.get(issue.check).push(issue)
|
|
220
|
+
}
|
|
221
|
+
const lines = [
|
|
222
|
+
'---',
|
|
223
|
+
'type: meta',
|
|
224
|
+
`title: "Lint Report ${date}"`,
|
|
225
|
+
`created: ${date}`,
|
|
226
|
+
`updated: ${date}`,
|
|
227
|
+
'tags: [meta, lint]',
|
|
228
|
+
'status: developing',
|
|
229
|
+
'---',
|
|
230
|
+
'',
|
|
231
|
+
`# Lint Report: ${date}`,
|
|
232
|
+
'',
|
|
233
|
+
'## Summary',
|
|
234
|
+
`- Pages scanned: ${pagesScanned}`,
|
|
235
|
+
`- Issues found: ${issues.length}`,
|
|
236
|
+
'',
|
|
237
|
+
]
|
|
238
|
+
for (const [check, grouped] of sections) {
|
|
239
|
+
lines.push(`## ${check.replace(/(^|-)(\w)/g, (_, prefix, char) => (prefix === '-' ? ' ' : '') + char.toUpperCase())}`)
|
|
240
|
+
for (const issue of grouped) {
|
|
241
|
+
lines.push(`- [[${issue.page}]] (${issue.severity}): ${issue.detail}. Suggest: ${issue.suggestion}.`)
|
|
242
|
+
}
|
|
243
|
+
lines.push('')
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
await mkdir(join(root, 'wiki', 'meta'), { recursive: true })
|
|
247
|
+
await writeFile(path, `${lines.join('\n')}\n`, 'utf8')
|
|
248
|
+
return path
|
|
249
|
+
} catch {
|
|
250
|
+
return undefined
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** @typedef {{ name: string, path: string, rel: string, fields: Record<string, unknown> | undefined, content: string, links: string[] }[]} Pages */
|
package/lib/search.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vault-aware search: the wiki-query skill's read order (hot cache, master
|
|
3
|
+
* index, then pages) as one tool-call, plus full-text search with the link
|
|
4
|
+
* graph that makes wiki retrieval different from grep.
|
|
5
|
+
*
|
|
6
|
+
* @module dsh-plugin-wiki-tools/lib/search
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readFile } from 'node:fs/promises'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
import { collectMarkdown, META_FILENAMES } from './vault.js'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Answer the quick mode: the hot cache and master index verbatim. The caller
|
|
15
|
+
* (the model, following the wiki-query skill) reads these before any page.
|
|
16
|
+
* @param {string} root - absolute vault root.
|
|
17
|
+
* @returns {Promise<{ hot: string | undefined, index: string | undefined }>}
|
|
18
|
+
*/
|
|
19
|
+
export async function quickView(root) {
|
|
20
|
+
const [hot, index] = await Promise.all([
|
|
21
|
+
readFile(join(root, 'wiki', 'hot.md'), 'utf8').catch(() => undefined),
|
|
22
|
+
readFile(join(root, 'wiki', 'index.md'), 'utf8').catch(() => undefined),
|
|
23
|
+
])
|
|
24
|
+
return { hot, index }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Full-text search over the wiki tree with link-graph context. Matches are
|
|
29
|
+
* case-insensitive substrings scored by where they hit: title 5, tags 4,
|
|
30
|
+
* headings 2, body 1 per occurrence.
|
|
31
|
+
* @param {string} root - absolute vault root.
|
|
32
|
+
* @param {object} options - search options.
|
|
33
|
+
* @param {string} options.query - the search text.
|
|
34
|
+
* @param {number} [options.limit=10] - maximum results.
|
|
35
|
+
* @returns {Promise<{ results: { name: string, path: string, score: number, snippets: string[], inbound: string[], outbound: number }[], totalMatches: number }>}
|
|
36
|
+
*/
|
|
37
|
+
export async function searchVault(root, { query, limit = 10 }) {
|
|
38
|
+
const needle = query.trim().toLowerCase()
|
|
39
|
+
if (needle.length === 0) throw new Error('wiki-tools: query must be a non-empty string')
|
|
40
|
+
const pages = await collectMarkdown(join(root, 'wiki'))
|
|
41
|
+
// The link graph covers every file, but results exclude vault machinery:
|
|
42
|
+
// the index matches nearly every term by construction and is already the
|
|
43
|
+
// quick-mode payload.
|
|
44
|
+
const searchable = pages.filter(page =>
|
|
45
|
+
!META_FILENAMES.has(page.name.toLowerCase()) && !page.name.startsWith('_'))
|
|
46
|
+
const inbound = new Map(pages.map(page => [page.name, []]))
|
|
47
|
+
for (const page of searchable) {
|
|
48
|
+
for (const target of page.links) {
|
|
49
|
+
inbound.get(target)?.push(page.name)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const results = []
|
|
53
|
+
let totalMatches = 0
|
|
54
|
+
for (const page of searchable) {
|
|
55
|
+
const titleHits = countOccurrences(page.name.toLowerCase(), needle) * 5
|
|
56
|
+
const tags = Array.isArray(page.fields?.tags) ? page.fields.tags.join(' ').toLowerCase() : ''
|
|
57
|
+
const tagHits = tags.includes(needle) ? 4 : 0
|
|
58
|
+
const headings = page.content.split('\n').filter(line => line.startsWith('#')).join('\n').toLowerCase()
|
|
59
|
+
const headingHits = countOccurrences(headings, needle) * 2
|
|
60
|
+
const body = page.content.toLowerCase()
|
|
61
|
+
const bodyHits = countOccurrences(body, needle)
|
|
62
|
+
const score = titleHits + tagHits + headingHits + bodyHits
|
|
63
|
+
if (score === 0) continue
|
|
64
|
+
totalMatches += 1
|
|
65
|
+
results.push({
|
|
66
|
+
name: page.name,
|
|
67
|
+
path: page.path,
|
|
68
|
+
score,
|
|
69
|
+
snippets: matchLines(page.content, needle).slice(0, 2),
|
|
70
|
+
inbound: inbound.get(page.name) ?? [],
|
|
71
|
+
outbound: page.links.length,
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
results.sort((left, right) => right.score - left.score || left.name.localeCompare(right.name))
|
|
75
|
+
return { results: results.slice(0, limit), totalMatches: results.length }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Case-insensitive matching lines, trimmed for a result snippet.
|
|
80
|
+
* @param {string} content - page body.
|
|
81
|
+
* @param {string} needle - lowercased search text.
|
|
82
|
+
* @returns {string[]} up to 4 matching lines trimmed to 160 characters.
|
|
83
|
+
*/
|
|
84
|
+
function matchLines(content, needle) {
|
|
85
|
+
const lines = []
|
|
86
|
+
for (const line of content.split('\n')) {
|
|
87
|
+
if (line.toLowerCase().includes(needle)) {
|
|
88
|
+
const trimmed = line.trim()
|
|
89
|
+
lines.push(trimmed.length > 160 ? `${trimmed.slice(0, 157)}...` : trimmed)
|
|
90
|
+
if (lines.length === 4) break
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return lines
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Count substring occurrences.
|
|
98
|
+
* @param {string} haystack - lowercased text.
|
|
99
|
+
* @param {string} needle - lowercased substring.
|
|
100
|
+
* @returns {number} occurrence count.
|
|
101
|
+
*/
|
|
102
|
+
function countOccurrences(haystack, needle) {
|
|
103
|
+
if (!haystack.includes(needle)) return 0
|
|
104
|
+
let count = 0
|
|
105
|
+
let index = haystack.indexOf(needle)
|
|
106
|
+
while (index >= 0) {
|
|
107
|
+
count += 1
|
|
108
|
+
index = haystack.indexOf(needle, index + needle.length)
|
|
109
|
+
}
|
|
110
|
+
return count
|
|
111
|
+
}
|
package/lib/vault.js
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vault bookkeeping: path routing, frontmatter completion, index/log updates,
|
|
3
|
+
* and source delta tracking for an Obsidian wiki vault following the LLM Wiki
|
|
4
|
+
* layout (`.raw/` sources, `wiki/` knowledge tree).
|
|
5
|
+
*
|
|
6
|
+
* The vault is host-local user data addressed by an explicitly configured
|
|
7
|
+
* absolute root; reads and writes go through node:fs directly, not a dsh
|
|
8
|
+
* filesystem seam, because the vault lives outside any session workspace by
|
|
9
|
+
* design (cross-project referencing is the point).
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-plugin-wiki-tools/lib/vault
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createHash } from 'node:crypto'
|
|
15
|
+
import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises'
|
|
16
|
+
import { isAbsolute, join, relative, sep } from 'node:path'
|
|
17
|
+
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
|
|
18
|
+
|
|
19
|
+
/** Page-type to vault folder routing (the suite's generic mode). */
|
|
20
|
+
export const TYPE_FOLDERS = {
|
|
21
|
+
source: 'wiki/sources',
|
|
22
|
+
entity: 'wiki/entities',
|
|
23
|
+
concept: 'wiki/concepts',
|
|
24
|
+
domain: 'wiki/domains',
|
|
25
|
+
question: 'wiki/questions',
|
|
26
|
+
comparison: 'wiki/comparisons',
|
|
27
|
+
meta: 'wiki/meta',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Master-index section heading per page type, in catalog order. */
|
|
31
|
+
const INDEX_SECTIONS = {
|
|
32
|
+
domain: '## Domains',
|
|
33
|
+
entity: '## Entities',
|
|
34
|
+
concept: '## Concepts',
|
|
35
|
+
source: '## Sources',
|
|
36
|
+
question: '## Questions',
|
|
37
|
+
comparison: '## Comparisons',
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Pages that are vault machinery, never linted as content (basenames, no extension). */
|
|
41
|
+
export const META_FILENAMES = new Set(['index', '_index', 'log', 'hot', 'overview'])
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Split one Markdown file into frontmatter fields and body. A file without a
|
|
45
|
+
* frontmatter fence returns no fields; a broken fence throws.
|
|
46
|
+
* @param {string} raw - the complete file text.
|
|
47
|
+
* @param {string} source - path shown in error messages.
|
|
48
|
+
* @returns {{ fields: Record<string, unknown> | undefined, content: string }}
|
|
49
|
+
*/
|
|
50
|
+
export function splitFrontmatter(raw, source) {
|
|
51
|
+
if (!raw.startsWith('---\n') && !raw.startsWith('---\r\n')) return { fields: undefined, content: raw }
|
|
52
|
+
const firstLineEnd = raw.indexOf('\n')
|
|
53
|
+
const rest = raw.slice(firstLineEnd + 1)
|
|
54
|
+
const closing = rest.search(/^---(?:\r?\n|$)/m)
|
|
55
|
+
if (closing < 0) throw new Error(`${source}: frontmatter has no closing --- fence`)
|
|
56
|
+
const frontmatterText = rest.slice(0, closing)
|
|
57
|
+
const content = rest.slice(closing).replace(/^---\r?\n?/, '')
|
|
58
|
+
const fields = parseYaml(frontmatterText)
|
|
59
|
+
if (fields !== null && typeof fields !== 'object' && !Array.isArray(fields)) {
|
|
60
|
+
throw new Error(`${source}: frontmatter must be a YAML mapping`)
|
|
61
|
+
}
|
|
62
|
+
return { fields: fields === null ? undefined : fields, content }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Today as the vault's canonical YYYY-MM-DD stamp. */
|
|
66
|
+
export function today() {
|
|
67
|
+
return new Date().toISOString().slice(0, 10)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* One vault root. All bookkeeping mutations go through {@link Vault.writePage},
|
|
72
|
+
* which completes frontmatter, updates the master index, and prepends a log
|
|
73
|
+
* entry in one serialized write per file.
|
|
74
|
+
*/
|
|
75
|
+
export class Vault {
|
|
76
|
+
/** @param {string} root - absolute path to the vault root (the directory holding `wiki/` and `.raw/`). */
|
|
77
|
+
constructor(root) {
|
|
78
|
+
if (typeof root !== 'string' || root.length === 0 || !isAbsolute(root)) {
|
|
79
|
+
throw new Error(`wiki-tools: vaultPath must be an absolute directory path (got ${JSON.stringify(root)})`)
|
|
80
|
+
}
|
|
81
|
+
this.root = root
|
|
82
|
+
/** Per-file write chains so concurrent tool calls serialize per target. */
|
|
83
|
+
this.writeChains = new Map()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Absolute path of one routed page. @param {string} type - page type. @param {string} title - page title (also the filename). */
|
|
87
|
+
pagePath(type, title) {
|
|
88
|
+
const folder = TYPE_FOLDERS[type]
|
|
89
|
+
if (folder === undefined) throw new Error(`wiki-tools: unknown page type "${type}"`)
|
|
90
|
+
if (!/^[^/\\]+(\.md)?$/.test(title) || title.includes('\n')) {
|
|
91
|
+
throw new Error(`wiki-tools: title must be a plain filename without path separators (got ${JSON.stringify(title)})`)
|
|
92
|
+
}
|
|
93
|
+
const filename = title.endsWith('.md') ? title : `${title}.md`
|
|
94
|
+
return join(this.root, folder, filename)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Verify the vault root exists and is a directory.
|
|
99
|
+
* @returns {Promise<void>} rejects with a setup hint when the root is missing.
|
|
100
|
+
*/
|
|
101
|
+
async assertRoot() {
|
|
102
|
+
const info = await stat(this.root).catch(error => {
|
|
103
|
+
if (error.code === 'ENOENT') {
|
|
104
|
+
throw new Error(`wiki-tools: vault root ${this.root} does not exist; scaffold it first (the wiki skill's SCAFFOLD operation), then point vaultPath at it`)
|
|
105
|
+
}
|
|
106
|
+
throw error
|
|
107
|
+
})
|
|
108
|
+
if (!info.isDirectory()) throw new Error(`wiki-tools: vault root ${this.root} is not a directory`)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Read one page's frontmatter and body.
|
|
113
|
+
* @param {string} path - absolute page path.
|
|
114
|
+
* @returns {Promise<{ fields: Record<string, unknown> | undefined, content: string } | undefined>} undefined when missing.
|
|
115
|
+
*/
|
|
116
|
+
async readPage(path) {
|
|
117
|
+
let raw
|
|
118
|
+
try {
|
|
119
|
+
raw = await readFile(path, 'utf8')
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (error.code === 'ENOENT') return undefined
|
|
122
|
+
throw error
|
|
123
|
+
}
|
|
124
|
+
return splitFrontmatter(raw, path)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Serialize one mutating operation per target path.
|
|
129
|
+
* @param {string} path - absolute target path.
|
|
130
|
+
* @param {() => Promise<T>} operation - the mutation, run after the previous one settles.
|
|
131
|
+
* @returns {Promise<T>}
|
|
132
|
+
*/
|
|
133
|
+
enqueue(path, operation) {
|
|
134
|
+
const previous = this.writeChains.get(path) ?? Promise.resolve()
|
|
135
|
+
const next = previous.then(operation, operation)
|
|
136
|
+
this.writeChains.set(path, next.then(() => {}, () => {}))
|
|
137
|
+
return next
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Write one wiki page with complete bookkeeping: frontmatter completion,
|
|
142
|
+
* filename-uniqueness guard, master-index entry, and a log entry. Existing
|
|
143
|
+
* pages keep `created` and any unknown frontmatter fields; `updated` moves
|
|
144
|
+
* to today.
|
|
145
|
+
* @param {object} input - the write request.
|
|
146
|
+
* @param {string} input.type - page type (routed to a folder).
|
|
147
|
+
* @param {string} input.title - page title; also the filename and wikilink target.
|
|
148
|
+
* @param {string} input.content - markdown body after frontmatter.
|
|
149
|
+
* @param {string[]} [input.tags] - frontmatter tags; defaults to `[type]`.
|
|
150
|
+
* @param {string} [input.status] - frontmatter status; defaults to `developing` on create.
|
|
151
|
+
* @param {string} [input.summary] - one-line index entry; defaults to the first content line.
|
|
152
|
+
* @returns {Promise<{ path: string, created: boolean, title: string }>}
|
|
153
|
+
*/
|
|
154
|
+
async writePage({ type, title, content, tags, status, summary }) {
|
|
155
|
+
const path = this.pagePath(type, title)
|
|
156
|
+
return await this.enqueue(path, async () => {
|
|
157
|
+
await this.assertRoot()
|
|
158
|
+
const cleanTitle = title.endsWith('.md') ? title.slice(0, -3) : title
|
|
159
|
+
const existing = await this.readPage(path)
|
|
160
|
+
await this.assertUniqueFilename(cleanTitle, path)
|
|
161
|
+
const date = today()
|
|
162
|
+
const fields = {
|
|
163
|
+
...(existing?.fields ?? {}),
|
|
164
|
+
type,
|
|
165
|
+
title: cleanTitle,
|
|
166
|
+
status: status ?? existing?.fields?.status ?? 'developing',
|
|
167
|
+
created: existing?.fields?.created ?? date,
|
|
168
|
+
updated: date,
|
|
169
|
+
tags: tags ?? existing?.fields?.tags ?? [type],
|
|
170
|
+
}
|
|
171
|
+
const file = `---\n${stringifyYaml(fields).trimEnd()}\n---\n\n${content.replace(/^\s*\n/, '')}\n`
|
|
172
|
+
await mkdir(join(path, '..'), { recursive: true })
|
|
173
|
+
await writeFile(path, file, 'utf8')
|
|
174
|
+
await this.updateIndex(type, cleanTitle, summary ?? firstContentLine(content))
|
|
175
|
+
await this.prependLog(`## [${date}] ${existing === undefined ? 'create' : 'update'} | ${cleanTitle}`, [
|
|
176
|
+
`- ${existing === undefined ? 'Created' : 'Updated'}: [[${cleanTitle}]]`,
|
|
177
|
+
])
|
|
178
|
+
return { path, created: existing === undefined, title: cleanTitle }
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Reject a title whose filename already exists elsewhere in the tree:
|
|
184
|
+
* wikilinks address pages by bare filename, so duplicates break resolution.
|
|
185
|
+
* @param {string} title - page title being written.
|
|
186
|
+
* @param {string} path - the routed destination path.
|
|
187
|
+
*/
|
|
188
|
+
async assertUniqueFilename(title, path) {
|
|
189
|
+
const pages = await collectMarkdown(join(this.root, 'wiki'))
|
|
190
|
+
for (const page of pages) {
|
|
191
|
+
if (page.name.toLowerCase() === title.toLowerCase() && page.path !== path) {
|
|
192
|
+
throw new Error(`wiki-tools: filename "${title}.md" already exists at ${page.path}; wikilinks need unique filenames`)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Add or refresh one `- [[Title]]: summary` entry in the master index.
|
|
199
|
+
* Missing sections and files are created on first use.
|
|
200
|
+
* @param {string} type - page type selecting the index section.
|
|
201
|
+
* @param {string} title - page title.
|
|
202
|
+
* @param {string} summary - one-line description.
|
|
203
|
+
*/
|
|
204
|
+
async updateIndex(type, title, summary) {
|
|
205
|
+
const heading = INDEX_SECTIONS[type]
|
|
206
|
+
if (heading === undefined) return
|
|
207
|
+
const indexPath = join(this.root, 'wiki', 'index.md')
|
|
208
|
+
let raw = await readFile(indexPath, 'utf8').catch(() => undefined)
|
|
209
|
+
if (raw === undefined) {
|
|
210
|
+
raw = '# Wiki Index\n\n'
|
|
211
|
+
for (const section of Object.values(INDEX_SECTIONS)) raw += `${section}\n\n`
|
|
212
|
+
await mkdir(join(indexPath, '..'), { recursive: true })
|
|
213
|
+
}
|
|
214
|
+
const entry = `- [[${title}]]: ${summary.replace(/\n/g, ' ')}`
|
|
215
|
+
const lines = raw.split('\n')
|
|
216
|
+
let headingLine = lines.findIndex(line => line === heading)
|
|
217
|
+
if (headingLine < 0) {
|
|
218
|
+
lines.push('', heading, entry)
|
|
219
|
+
await writeFile(indexPath, lines.join('\n').replace(/\n{3,}/g, '\n\n'), 'utf8')
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
const linkPrefix = `- [[${title}]]`
|
|
223
|
+
let cursor = headingLine + 1
|
|
224
|
+
while (cursor < lines.length && lines[cursor] !== '' && !lines[cursor].startsWith('## ')) {
|
|
225
|
+
if (lines[cursor].startsWith(linkPrefix)) {
|
|
226
|
+
lines[cursor] = entry
|
|
227
|
+
await writeFile(indexPath, lines.join('\n'), 'utf8')
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
cursor += 1
|
|
231
|
+
}
|
|
232
|
+
lines.splice(cursor, 0, entry)
|
|
233
|
+
await writeFile(indexPath, lines.join('\n'), 'utf8')
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Prepend one dated entry to the append-only operation log.
|
|
238
|
+
* @param {string} header - the `## [date] op | title` heading line.
|
|
239
|
+
* @param {string[]} bullets - detail lines under the heading.
|
|
240
|
+
*/
|
|
241
|
+
async prependLog(header, bullets) {
|
|
242
|
+
const logPath = join(this.root, 'wiki', 'log.md')
|
|
243
|
+
const raw = await readFile(logPath, 'utf8').catch(() => '# Wiki Log\n')
|
|
244
|
+
const stripped = raw.replace(/^# Wiki Log\r?\n?/, '')
|
|
245
|
+
const entry = `${header}\n${bullets.join('\n')}\n\n`
|
|
246
|
+
await mkdir(join(logPath, '..'), { recursive: true })
|
|
247
|
+
await writeFile(logPath, `# Wiki Log\n\n${entry}${stripped.replace(/^\s*/, '')}`, 'utf8')
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Delta-track one raw source: report whether its content hash is unchanged
|
|
252
|
+
* since the last ingest, then record the new hash and touched pages.
|
|
253
|
+
* @param {object} input - the tracking request.
|
|
254
|
+
* @param {string} input.sourcePath - vault-relative or absolute `.raw/` source path.
|
|
255
|
+
* @param {string[]} [input.pagesCreated] - pages this ingest created.
|
|
256
|
+
* @param {string[]} [input.pagesUpdated] - pages this ingest updated.
|
|
257
|
+
* @returns {Promise<{ hash: string, alreadyIngested: boolean }>}
|
|
258
|
+
*/
|
|
259
|
+
async trackSource({ sourcePath, pagesCreated = [], pagesUpdated = [] }) {
|
|
260
|
+
const absolute = isAbsolute(sourcePath) ? sourcePath : join(this.root, sourcePath)
|
|
261
|
+
const rel = relative(this.root, absolute)
|
|
262
|
+
const raw = await readFile(absolute).catch(error => {
|
|
263
|
+
if (error.code === 'ENOENT') throw new Error(`wiki-tools: source ${sourcePath} not found under the vault`)
|
|
264
|
+
throw error
|
|
265
|
+
})
|
|
266
|
+
const hash = createHash('sha256').update(raw).digest('hex')
|
|
267
|
+
const manifestPath = join(this.root, '.raw', '.manifest.json')
|
|
268
|
+
const manifest = await readJson(manifestPath, { sources: {} })
|
|
269
|
+
const previous = manifest.sources[rel] ?? manifest.sources[sourcePath]
|
|
270
|
+
const alreadyIngested = previous !== undefined && previous.hash === hash
|
|
271
|
+
manifest.sources[rel] = {
|
|
272
|
+
hash,
|
|
273
|
+
ingested_at: today(),
|
|
274
|
+
pages_created: pagesCreated,
|
|
275
|
+
pages_updated: pagesUpdated,
|
|
276
|
+
}
|
|
277
|
+
await mkdir(join(manifestPath, '..'), { recursive: true })
|
|
278
|
+
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
|
|
279
|
+
return { hash, alreadyIngested }
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* First non-heading, non-empty content line, for index summaries.
|
|
285
|
+
* @param {string} content - markdown body.
|
|
286
|
+
* @returns {string} up to 120 characters of the first prose line.
|
|
287
|
+
*/
|
|
288
|
+
function firstContentLine(content) {
|
|
289
|
+
for (const line of content.split('\n')) {
|
|
290
|
+
const trimmed = line.trim()
|
|
291
|
+
if (trimmed.length > 0 && !trimmed.startsWith('#') && !trimmed.startsWith('---')) {
|
|
292
|
+
return trimmed.length > 120 ? `${trimmed.slice(0, 117)}...` : trimmed
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return '(no summary)'
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Read a JSON file, returning the fallback when absent.
|
|
300
|
+
* @param {string} path - absolute file path.
|
|
301
|
+
* @param {T} fallback - value when the file is missing.
|
|
302
|
+
* @returns {Promise<T>}
|
|
303
|
+
* @template T
|
|
304
|
+
*/
|
|
305
|
+
async function readJson(path, fallback) {
|
|
306
|
+
try {
|
|
307
|
+
return JSON.parse(await readFile(path, 'utf8'))
|
|
308
|
+
} catch (error) {
|
|
309
|
+
if (error.code === 'ENOENT') return fallback
|
|
310
|
+
throw error
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Recursively collect every Markdown page under a directory with title and wikilinks.
|
|
316
|
+
* @param {string} directory - absolute directory to walk.
|
|
317
|
+
* @returns {Promise<{ name: string, path: string, rel: string, fields: Record<string, unknown> | undefined, content: string, links: string[] }[]>}
|
|
318
|
+
*/
|
|
319
|
+
export async function collectMarkdown(directory) {
|
|
320
|
+
const pages = []
|
|
321
|
+
let entries
|
|
322
|
+
try {
|
|
323
|
+
entries = await readdir(directory, { withFileTypes: true })
|
|
324
|
+
} catch (error) {
|
|
325
|
+
if (error.code === 'ENOENT') return pages
|
|
326
|
+
throw error
|
|
327
|
+
}
|
|
328
|
+
for (const entry of entries) {
|
|
329
|
+
const path = join(directory, entry.name)
|
|
330
|
+
if (entry.isDirectory()) {
|
|
331
|
+
pages.push(...await collectMarkdown(path))
|
|
332
|
+
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
333
|
+
const { fields, content } = splitFrontmatter(await readFile(path, 'utf8'), path)
|
|
334
|
+
pages.push({
|
|
335
|
+
name: entry.name.slice(0, -3),
|
|
336
|
+
path,
|
|
337
|
+
rel: relative(directory, path).split(sep).join('/'),
|
|
338
|
+
fields,
|
|
339
|
+
content,
|
|
340
|
+
links: extractWikilinks(content),
|
|
341
|
+
})
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return pages
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Extract wikilink targets from markdown, dropping aliases and heading anchors.
|
|
349
|
+
* @param {string} content - markdown body.
|
|
350
|
+
* @returns {string[]} link targets in order of appearance.
|
|
351
|
+
*/
|
|
352
|
+
export function extractWikilinks(content) {
|
|
353
|
+
const links = []
|
|
354
|
+
const withoutFences = content.replace(/```[\s\S]*?```/g, '')
|
|
355
|
+
for (const match of withoutFences.matchAll(/\[\[([^\]]+)\]\]/g)) {
|
|
356
|
+
const target = match[1].split('|')[0].split('#')[0].trim()
|
|
357
|
+
if (target.length > 0) links.push(target)
|
|
358
|
+
}
|
|
359
|
+
return links
|
|
360
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-plugin-wiki-tools",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./index.js",
|
|
10
|
+
"./lib/vault.js": "./lib/vault.js",
|
|
11
|
+
"./lib/search.js": "./lib/search.js",
|
|
12
|
+
"./lib/lint.js": "./lib/lint.js",
|
|
13
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"index.js",
|
|
18
|
+
"lib/",
|
|
19
|
+
"cordis.patch.yml"
|
|
20
|
+
],
|
|
21
|
+
"keywords": [
|
|
22
|
+
"dsh-plugin",
|
|
23
|
+
"deepseek-harness",
|
|
24
|
+
"obsidian",
|
|
25
|
+
"knowledge-base",
|
|
26
|
+
"wiki"
|
|
27
|
+
],
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/Lion-1209/dsh-plugin-wiki-tools.git"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"yaml": "^2.8.0"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"@deepseek-ai/cordis": "*",
|
|
37
|
+
"@deepseek-ai/dsh-tools": "*",
|
|
38
|
+
"@deepseek-ai/schemastery": "*"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=22.19.0"
|
|
42
|
+
},
|
|
43
|
+
"dsh": {
|
|
44
|
+
"bundle": {
|
|
45
|
+
"patch": "./cordis.patch.yml"
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
50
|
+
"@deepseek-ai/dsh-tools": "^0.0.1-rc.1",
|
|
51
|
+
"@deepseek-ai/schemastery": "^3.18.1"
|
|
52
|
+
}
|
|
53
|
+
}
|