llm-wiki-kit 0.1.5 → 0.1.6
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/README.md +32 -2
- package/docs/concepts.md +12 -2
- package/docs/integrations/claude-code.md +2 -0
- package/docs/integrations/codex.md +2 -2
- package/docs/operations.md +92 -3
- package/docs/research/baseline.md +3 -1
- package/docs/security.md +2 -0
- package/docs/troubleshooting.md +30 -1
- package/package.json +4 -1
- package/src/cli.js +48 -3
- package/src/consolidate.js +203 -0
- package/src/install.js +4 -2
- package/src/project-state.js +76 -11
- package/src/project.js +11 -55
- package/src/templates.js +90 -7
- package/src/update.js +10 -4
- package/src/wiki-lint.js +271 -0
- package/src/wiki-model.js +263 -0
- package/src/wiki-search.js +243 -0
package/src/wiki-lint.js
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { readdir } from 'fs/promises';
|
|
2
|
+
import { dirname, isAbsolute, join, parse, relative, resolve, sep } from 'path';
|
|
3
|
+
import { exists, readText } from './fs-utils.js';
|
|
4
|
+
import { hasSecretLikeText } from './redaction.js';
|
|
5
|
+
import {
|
|
6
|
+
buildAliasMap,
|
|
7
|
+
buildWikiGraph,
|
|
8
|
+
collectWikiPages,
|
|
9
|
+
normalizeTarget,
|
|
10
|
+
resolveWikiLink,
|
|
11
|
+
wikiRoot,
|
|
12
|
+
} from './wiki-model.js';
|
|
13
|
+
|
|
14
|
+
const VALID_TYPES = new Set([
|
|
15
|
+
'source',
|
|
16
|
+
'concept',
|
|
17
|
+
'entity',
|
|
18
|
+
'decision',
|
|
19
|
+
'architecture',
|
|
20
|
+
'debugging',
|
|
21
|
+
'context',
|
|
22
|
+
'query',
|
|
23
|
+
'session-log',
|
|
24
|
+
'convention',
|
|
25
|
+
]);
|
|
26
|
+
const VALID_STATUS = new Set(['draft', 'reviewed', 'stale']);
|
|
27
|
+
const VALID_CONFIDENCE = new Set(['high', 'medium', 'low']);
|
|
28
|
+
const VALID_MEMORY_TYPES = new Set(['semantic', 'episodic', 'procedural']);
|
|
29
|
+
const CORE_PAGES = new Set(['wiki/index.md', 'wiki/log.md', 'wiki/memory.md']);
|
|
30
|
+
|
|
31
|
+
function issue(severity, code, path, message) {
|
|
32
|
+
return { severity, code, path, message };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isDateLike(value) {
|
|
36
|
+
return value === 'unknown' || /^\d{4}-\d{2}-\d{2}$/.test(String(value || ''));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function relativeMarkdownTarget(page, link) {
|
|
40
|
+
return resolve(dirname(page.absolutePath), link.path);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isOutsideProject(projectRoot, absolutePath) {
|
|
44
|
+
const root = resolve(projectRoot);
|
|
45
|
+
const target = resolve(absolutePath);
|
|
46
|
+
const rel = relative(root, target);
|
|
47
|
+
return rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function invalidSourceId(sourceId) {
|
|
51
|
+
const id = String(sourceId || '').trim().replace(/\\/g, '/');
|
|
52
|
+
if (!id) return true;
|
|
53
|
+
if (id.startsWith('/') || /^[a-z][a-z0-9+.-]*:/i.test(id)) return true;
|
|
54
|
+
return id.split('/').includes('..');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function sourceCandidates(projectRoot, sourceId) {
|
|
58
|
+
const id = String(sourceId || '').trim();
|
|
59
|
+
if (!id) return [];
|
|
60
|
+
const rawSources = join(projectRoot, 'llm-wiki', 'raw', 'sources');
|
|
61
|
+
const rawInbox = join(projectRoot, 'llm-wiki', 'raw', 'inbox');
|
|
62
|
+
const wikiSources = join(projectRoot, 'llm-wiki', 'wiki', 'sources');
|
|
63
|
+
const candidates = [
|
|
64
|
+
join(rawSources, id),
|
|
65
|
+
join(rawSources, `${id}.md`),
|
|
66
|
+
join(rawSources, `${id}.txt`),
|
|
67
|
+
join(rawInbox, id),
|
|
68
|
+
join(rawInbox, `${id}.md`),
|
|
69
|
+
join(wikiSources, id),
|
|
70
|
+
join(wikiSources, `${id}.md`),
|
|
71
|
+
];
|
|
72
|
+
if (id.includes('/') || id.includes('\\')) {
|
|
73
|
+
candidates.push(join(projectRoot, id));
|
|
74
|
+
candidates.push(join(projectRoot, 'llm-wiki', id));
|
|
75
|
+
}
|
|
76
|
+
return candidates.map((candidate) => resolve(candidate))
|
|
77
|
+
.filter((candidate) => !isOutsideProject(projectRoot, candidate));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function sourceExists(projectRoot, sourceId) {
|
|
81
|
+
for (const candidate of sourceCandidates(projectRoot, sourceId)) {
|
|
82
|
+
if (await pathExistsNormalized(candidate)) return true;
|
|
83
|
+
}
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function titleKey(page) {
|
|
88
|
+
return normalizeTarget(page.title);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isOrphanCandidate(page) {
|
|
92
|
+
if (CORE_PAGES.has(page.rel)) return false;
|
|
93
|
+
if (page.rel.startsWith('wiki/queries/')) return false;
|
|
94
|
+
if (page.rel.startsWith('wiki/context/')) return false;
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function pathExistsNormalized(absolutePath) {
|
|
99
|
+
if (await exists(absolutePath)) return true;
|
|
100
|
+
const target = resolve(absolutePath);
|
|
101
|
+
const parsed = parse(target);
|
|
102
|
+
const segments = target.slice(parsed.root.length).split(sep).filter(Boolean);
|
|
103
|
+
let current = parsed.root || sep;
|
|
104
|
+
for (const segment of segments) {
|
|
105
|
+
let entries = [];
|
|
106
|
+
try {
|
|
107
|
+
entries = await readdir(current);
|
|
108
|
+
} catch {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
const match = entries.find((entry) => entry.normalize('NFC') === segment.normalize('NFC'));
|
|
112
|
+
if (!match) return false;
|
|
113
|
+
current = join(current, match);
|
|
114
|
+
}
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function runLint(projectRoot, options = {}) {
|
|
119
|
+
const issues = [];
|
|
120
|
+
const root = wikiRoot(projectRoot);
|
|
121
|
+
if (!(await exists(root))) {
|
|
122
|
+
issues.push(issue('error', 'missing-wiki', 'llm-wiki/wiki', 'wiki directory does not exist'));
|
|
123
|
+
return lintResult(projectRoot, [], issues);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const pages = await collectWikiPages(projectRoot, {
|
|
127
|
+
maxFiles: options.maxFiles || 1000,
|
|
128
|
+
maxChars: options.maxChars || 75000,
|
|
129
|
+
});
|
|
130
|
+
const byRel = new Map(pages.map((page) => [page.rel, page]));
|
|
131
|
+
const aliasMap = buildAliasMap(pages);
|
|
132
|
+
const graph = buildWikiGraph(pages);
|
|
133
|
+
|
|
134
|
+
for (const rel of ['wiki/index.md', 'wiki/log.md', 'wiki/memory.md']) {
|
|
135
|
+
if (!byRel.has(rel)) {
|
|
136
|
+
issues.push(issue(rel === 'wiki/memory.md' ? 'warning' : 'error', 'missing-core-page', rel, 'core wiki page is missing'));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
for (const [alias, matches] of aliasMap.entries()) {
|
|
141
|
+
if (matches.length > 1 && alias.length > 0) {
|
|
142
|
+
issues.push(issue('warning', 'duplicate-alias', matches.join(', '), `alias "${alias}" maps to multiple pages`));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const titles = new Map();
|
|
147
|
+
for (const page of pages) {
|
|
148
|
+
const key = titleKey(page);
|
|
149
|
+
if (!key) continue;
|
|
150
|
+
if (!titles.has(key)) titles.set(key, []);
|
|
151
|
+
titles.get(key).push(page.rel);
|
|
152
|
+
}
|
|
153
|
+
for (const [key, matches] of titles.entries()) {
|
|
154
|
+
if (matches.length > 1) {
|
|
155
|
+
issues.push(issue('warning', 'duplicate-title', matches.join(', '), `title "${key}" appears on multiple pages`));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
for (const page of pages) {
|
|
160
|
+
if (hasSecretLikeText(page.content)) {
|
|
161
|
+
issues.push(issue('error', 'secret-like-content', page.rel, 'page contains token, credential, private-key, or secret-like text'));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (!CORE_PAGES.has(page.rel) && !page.hasFrontmatter) {
|
|
165
|
+
issues.push(issue('warning', 'missing-frontmatter', page.rel, 'curated wiki page should have YAML frontmatter'));
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (page.hasFrontmatter) {
|
|
170
|
+
if (!page.type) issues.push(issue('warning', 'missing-type', page.rel, 'frontmatter is missing type'));
|
|
171
|
+
if (page.type && !VALID_TYPES.has(page.type)) issues.push(issue('error', 'invalid-type', page.rel, `invalid type: ${page.type}`));
|
|
172
|
+
if (!page.status) issues.push(issue('warning', 'missing-status', page.rel, 'frontmatter is missing status'));
|
|
173
|
+
if (page.status && !VALID_STATUS.has(page.status)) issues.push(issue('error', 'invalid-status', page.rel, `invalid status: ${page.status}`));
|
|
174
|
+
if (!page.confidence) issues.push(issue('warning', 'missing-confidence', page.rel, 'frontmatter is missing confidence'));
|
|
175
|
+
if (page.confidence && !VALID_CONFIDENCE.has(page.confidence)) issues.push(issue('error', 'invalid-confidence', page.rel, `invalid confidence: ${page.confidence}`));
|
|
176
|
+
if (page.frontmatter.memory_type && !VALID_MEMORY_TYPES.has(page.frontmatter.memory_type)) {
|
|
177
|
+
issues.push(issue('error', 'invalid-memory-type', page.rel, `invalid memory_type: ${page.frontmatter.memory_type}`));
|
|
178
|
+
}
|
|
179
|
+
if (page.frontmatter.importance !== undefined) {
|
|
180
|
+
const importance = Number(page.frontmatter.importance);
|
|
181
|
+
if (!Number.isInteger(importance) || importance < 1 || importance > 5) {
|
|
182
|
+
issues.push(issue('error', 'invalid-importance', page.rel, 'importance must be an integer from 1 to 5'));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (page.frontmatter.last_updated && !isDateLike(page.frontmatter.last_updated)) {
|
|
186
|
+
issues.push(issue('warning', 'invalid-last-updated', page.rel, `last_updated should be YYYY-MM-DD or unknown: ${page.frontmatter.last_updated}`));
|
|
187
|
+
}
|
|
188
|
+
if (page.frontmatter.last_verified && !isDateLike(page.frontmatter.last_verified)) {
|
|
189
|
+
issues.push(issue('warning', 'invalid-last-verified', page.rel, `last_verified should be YYYY-MM-DD or unknown: ${page.frontmatter.last_verified}`));
|
|
190
|
+
}
|
|
191
|
+
if (page.status === 'stale') {
|
|
192
|
+
issues.push(issue('warning', 'stale-page', page.rel, 'page is marked stale'));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
for (const link of page.wikilinks) {
|
|
197
|
+
const resolved = resolveWikiLink(aliasMap, link.raw, page);
|
|
198
|
+
const matches = aliasMap.get(normalizeTarget(link.target)) || [];
|
|
199
|
+
if (!resolved && matches.length === 0) {
|
|
200
|
+
issues.push(issue('error', 'broken-wikilink', page.rel, `unresolved wikilink: [[${link.raw}]]`));
|
|
201
|
+
} else if (!resolved && matches.length > 1) {
|
|
202
|
+
issues.push(issue('error', 'ambiguous-wikilink', page.rel, `ambiguous wikilink: [[${link.raw}]] -> ${matches.join(', ')}`));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
for (const link of page.markdownLinks) {
|
|
207
|
+
const target = relativeMarkdownTarget(page, link);
|
|
208
|
+
if (isOutsideProject(projectRoot, target)) {
|
|
209
|
+
issues.push(issue('warning', 'outside-project-link', page.rel, `markdown link points outside project: ${link.raw}`));
|
|
210
|
+
} else if (!(await pathExistsNormalized(target))) {
|
|
211
|
+
issues.push(issue('error', 'broken-markdown-link', page.rel, `missing markdown link target: ${link.raw}`));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
for (const sourceId of page.sourceIds) {
|
|
216
|
+
if (invalidSourceId(sourceId)) {
|
|
217
|
+
issues.push(issue('error', 'invalid-source-id', page.rel, 'source_id points outside the project or uses unsupported syntax'));
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (!(await sourceExists(projectRoot, sourceId))) {
|
|
221
|
+
issues.push(issue('warning', 'missing-source', page.rel, `source_id has no matching raw/wiki source file: ${sourceId}`));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const memoryText = await readText(join(projectRoot, 'llm-wiki', 'wiki', 'memory.md'), '');
|
|
227
|
+
if (Buffer.byteLength(memoryText, 'utf8') > 25000) {
|
|
228
|
+
issues.push(issue('warning', 'memory-too-large', 'wiki/memory.md', 'memory.md is larger than the hook excerpt budget'));
|
|
229
|
+
}
|
|
230
|
+
for (const page of pages.filter(isOrphanCandidate)) {
|
|
231
|
+
const backlinks = graph.backlinks.get(page.rel);
|
|
232
|
+
if (!backlinks || backlinks.size === 0) {
|
|
233
|
+
issues.push(issue('warning', 'orphan-page', page.rel, 'page is not linked from memory/index and has no backlinks'));
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return lintResult(projectRoot, pages, issues);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function lintResult(projectRoot, pages, issues) {
|
|
241
|
+
const errorCount = issues.filter((item) => item.severity === 'error').length;
|
|
242
|
+
const warningCount = issues.filter((item) => item.severity === 'warning').length;
|
|
243
|
+
return {
|
|
244
|
+
workspace: projectRoot,
|
|
245
|
+
ok: errorCount === 0,
|
|
246
|
+
pages: pages.length,
|
|
247
|
+
issueCount: issues.length,
|
|
248
|
+
errorCount,
|
|
249
|
+
warningCount,
|
|
250
|
+
issues,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function formatLintResult(result) {
|
|
255
|
+
const lines = [
|
|
256
|
+
'llm-wiki lint',
|
|
257
|
+
`- workspace: ${result.workspace}`,
|
|
258
|
+
`- pages: ${result.pages}`,
|
|
259
|
+
`- errors: ${result.errorCount}`,
|
|
260
|
+
`- warnings: ${result.warningCount}`,
|
|
261
|
+
];
|
|
262
|
+
if (result.issues.length === 0) {
|
|
263
|
+
lines.push('- result: ok');
|
|
264
|
+
return lines.join('\n');
|
|
265
|
+
}
|
|
266
|
+
lines.push('', 'Issues:');
|
|
267
|
+
for (const item of result.issues) {
|
|
268
|
+
lines.push(`- ${item.severity} ${item.code} ${item.path}: ${item.message}`);
|
|
269
|
+
}
|
|
270
|
+
return lines.join('\n');
|
|
271
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { basename, join, posix, relative } from 'path';
|
|
2
|
+
import { exists, listMarkdownFiles, readText } from './fs-utils.js';
|
|
3
|
+
import { normalizeForStorage } from './redaction.js';
|
|
4
|
+
|
|
5
|
+
export const MEMORY_LINE_LIMIT = 200;
|
|
6
|
+
export const MEMORY_BYTE_LIMIT = 25 * 1024;
|
|
7
|
+
export const DEFAULT_MAX_WIKI_FILES = 500;
|
|
8
|
+
export const DEFAULT_MAX_PAGE_CHARS = 50000;
|
|
9
|
+
|
|
10
|
+
export function wikiRoot(projectRoot) {
|
|
11
|
+
return join(projectRoot, 'llm-wiki', 'wiki');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function wikiRel(projectRoot, file) {
|
|
15
|
+
return relative(join(projectRoot, 'llm-wiki'), file).split('\\').join('/');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function stripQuotes(value) {
|
|
19
|
+
return String(value || '').replace(/^["']|["']$/g, '');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseScalar(value) {
|
|
23
|
+
const trimmed = String(value || '').trim();
|
|
24
|
+
if (trimmed === '[]') return [];
|
|
25
|
+
if (/^\[.*\]$/.test(trimmed)) {
|
|
26
|
+
const inner = trimmed.slice(1, -1).trim();
|
|
27
|
+
if (!inner) return [];
|
|
28
|
+
return inner.split(',').map((item) => stripQuotes(item.trim())).filter(Boolean);
|
|
29
|
+
}
|
|
30
|
+
return stripQuotes(trimmed);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function parseFrontmatter(content) {
|
|
34
|
+
const text = normalizeForStorage(content || '');
|
|
35
|
+
const match = text.match(/^---\n([\s\S]*?)\n---\n?/);
|
|
36
|
+
if (!match) return { data: {}, body: text, hasFrontmatter: false };
|
|
37
|
+
|
|
38
|
+
const data = {};
|
|
39
|
+
const lines = match[1].split('\n');
|
|
40
|
+
let currentArrayKey = null;
|
|
41
|
+
for (const line of lines) {
|
|
42
|
+
const arrayItem = line.match(/^\s*-\s+(.+)\s*$/);
|
|
43
|
+
if (arrayItem && currentArrayKey) {
|
|
44
|
+
data[currentArrayKey].push(stripQuotes(arrayItem[1].trim()));
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const keyValue = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
49
|
+
if (!keyValue) {
|
|
50
|
+
currentArrayKey = null;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const [, key, value] = keyValue;
|
|
55
|
+
if (value.trim() === '') {
|
|
56
|
+
data[key] = [];
|
|
57
|
+
currentArrayKey = key;
|
|
58
|
+
} else {
|
|
59
|
+
data[key] = parseScalar(value);
|
|
60
|
+
currentArrayKey = Array.isArray(data[key]) ? key : null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
data,
|
|
66
|
+
body: text.slice(match[0].length),
|
|
67
|
+
hasFrontmatter: true,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function firstHeading(body) {
|
|
72
|
+
return (body.match(/^#\s+(.+)$/m)?.[1] || '').trim();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function normalizeTarget(value) {
|
|
76
|
+
return normalizeForStorage(value || '')
|
|
77
|
+
.split('|')[0]
|
|
78
|
+
.split('#')[0]
|
|
79
|
+
.trim()
|
|
80
|
+
.replace(/\\/g, '/')
|
|
81
|
+
.replace(/^\.?\//, '')
|
|
82
|
+
.replace(/\.md$/i, '')
|
|
83
|
+
.toLowerCase();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function stripMarkdownCode(content) {
|
|
87
|
+
return normalizeForStorage(content || '')
|
|
88
|
+
.replace(/(^|\n)(`{3,}|~{3,})[^\n]*\n[\s\S]*?\n\2[ \t]*(?=\n|$)/g, '$1')
|
|
89
|
+
.replace(/`+[^`\n]*`+/g, '');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function extractWikilinks(content) {
|
|
93
|
+
const links = [];
|
|
94
|
+
const regex = /\[\[([^\]]+)\]\]/g;
|
|
95
|
+
const searchable = stripMarkdownCode(content);
|
|
96
|
+
let match = regex.exec(searchable);
|
|
97
|
+
while (match) {
|
|
98
|
+
const raw = match[1].trim();
|
|
99
|
+
const target = normalizeTarget(raw);
|
|
100
|
+
if (target) links.push({ raw, target });
|
|
101
|
+
match = regex.exec(searchable);
|
|
102
|
+
}
|
|
103
|
+
return links;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function extractMarkdownLinks(content) {
|
|
107
|
+
const links = [];
|
|
108
|
+
const regex = /(?<!!)\[[^\]]+\]\(([^)]+)\)/g;
|
|
109
|
+
const searchable = stripMarkdownCode(content);
|
|
110
|
+
let match = regex.exec(searchable);
|
|
111
|
+
while (match) {
|
|
112
|
+
const raw = match[1].trim();
|
|
113
|
+
const href = raw.replace(/^<|>$/g, '').split(/\s+/)[0];
|
|
114
|
+
if (!href || href.startsWith('#') || href.startsWith('/') || /^[a-z][a-z0-9+.-]*:/i.test(href)) {
|
|
115
|
+
match = regex.exec(searchable);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const path = href.split('#')[0].split('?')[0].replace(/\\/g, '/');
|
|
119
|
+
if (path) links.push({ raw, path });
|
|
120
|
+
match = regex.exec(searchable);
|
|
121
|
+
}
|
|
122
|
+
return links;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function parseWikiPage(projectRoot, file, content) {
|
|
126
|
+
const rel = wikiRel(projectRoot, file);
|
|
127
|
+
const { data, body, hasFrontmatter } = parseFrontmatter(content);
|
|
128
|
+
const title = String(data.title || firstHeading(body) || basename(file, '.md')).trim();
|
|
129
|
+
const stem = basename(file, '.md');
|
|
130
|
+
const relNoExt = rel.replace(/\.md$/i, '');
|
|
131
|
+
const withoutWiki = rel.replace(/^wiki\//, '');
|
|
132
|
+
const withoutWikiNoExt = withoutWiki.replace(/\.md$/i, '');
|
|
133
|
+
const aliases = [
|
|
134
|
+
stem,
|
|
135
|
+
title,
|
|
136
|
+
rel,
|
|
137
|
+
relNoExt,
|
|
138
|
+
withoutWiki,
|
|
139
|
+
withoutWikiNoExt,
|
|
140
|
+
].map(normalizeTarget).filter(Boolean);
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
absolutePath: file,
|
|
144
|
+
rel,
|
|
145
|
+
title,
|
|
146
|
+
stem,
|
|
147
|
+
frontmatter: data,
|
|
148
|
+
hasFrontmatter,
|
|
149
|
+
body,
|
|
150
|
+
content: normalizeForStorage(content || ''),
|
|
151
|
+
type: data.type || '',
|
|
152
|
+
status: data.status || '',
|
|
153
|
+
confidence: data.confidence || '',
|
|
154
|
+
memoryType: data.memory_type || '',
|
|
155
|
+
aliases: [...new Set(aliases)],
|
|
156
|
+
wikilinks: extractWikilinks(content),
|
|
157
|
+
markdownLinks: extractMarkdownLinks(content),
|
|
158
|
+
sourceIds: Array.isArray(data.source_ids) ? data.source_ids : [],
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function collectWikiPages(projectRoot, options = {}) {
|
|
163
|
+
const root = wikiRoot(projectRoot);
|
|
164
|
+
if (!(await exists(root))) {
|
|
165
|
+
throw new Error(`llm-wiki wiki directory not found: ${root}`);
|
|
166
|
+
}
|
|
167
|
+
const files = (await listMarkdownFiles(root, options.maxFiles || DEFAULT_MAX_WIKI_FILES))
|
|
168
|
+
.sort();
|
|
169
|
+
const maxChars = options.maxChars || DEFAULT_MAX_PAGE_CHARS;
|
|
170
|
+
const pages = [];
|
|
171
|
+
for (const file of files) {
|
|
172
|
+
const content = (await readText(file)).slice(0, maxChars);
|
|
173
|
+
pages.push(parseWikiPage(projectRoot, file, content));
|
|
174
|
+
}
|
|
175
|
+
return pages;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function buildAliasMap(pages) {
|
|
179
|
+
const aliases = new Map();
|
|
180
|
+
for (const page of pages) {
|
|
181
|
+
for (const alias of page.aliases) {
|
|
182
|
+
if (!aliases.has(alias)) aliases.set(alias, []);
|
|
183
|
+
aliases.get(alias).push(page.rel);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return aliases;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function pageRelativeTarget(page, target) {
|
|
190
|
+
if (!page || !target) return null;
|
|
191
|
+
const raw = normalizeForStorage(target)
|
|
192
|
+
.split('|')[0]
|
|
193
|
+
.split('#')[0]
|
|
194
|
+
.trim()
|
|
195
|
+
.replace(/\\/g, '/');
|
|
196
|
+
if (!raw.startsWith('./') && !raw.startsWith('../')) return null;
|
|
197
|
+
const pageDir = posix.dirname(page.rel.replace(/^wiki\//, ''));
|
|
198
|
+
const joined = posix.normalize(posix.join(pageDir, raw));
|
|
199
|
+
if (joined === '..' || joined.startsWith('../')) return null;
|
|
200
|
+
return normalizeTarget(joined);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function resolveWikiLink(aliasMap, target, page = null) {
|
|
204
|
+
const normalized = pageRelativeTarget(page, target) || normalizeTarget(target);
|
|
205
|
+
const matches = aliasMap.get(normalized) || [];
|
|
206
|
+
return matches.length === 1 ? matches[0] : null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function resolveMarkdownWikiLink(aliasMap, page, link) {
|
|
210
|
+
if (!link?.path || !/\.md$/i.test(link.path)) return null;
|
|
211
|
+
const pageDir = posix.dirname(page.rel.replace(/^wiki\//, ''));
|
|
212
|
+
const joined = posix.normalize(posix.join(pageDir, link.path));
|
|
213
|
+
if (joined === '..' || joined.startsWith('../')) return null;
|
|
214
|
+
return resolveWikiLink(aliasMap, joined);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function buildWikiGraph(pages) {
|
|
218
|
+
const aliasMap = buildAliasMap(pages);
|
|
219
|
+
const outlinks = new Map();
|
|
220
|
+
const backlinks = new Map();
|
|
221
|
+
for (const page of pages) {
|
|
222
|
+
outlinks.set(page.rel, new Set());
|
|
223
|
+
backlinks.set(page.rel, new Set());
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
for (const page of pages) {
|
|
227
|
+
for (const link of page.wikilinks) {
|
|
228
|
+
const resolved = resolveWikiLink(aliasMap, link.raw, page);
|
|
229
|
+
if (!resolved || resolved === page.rel) continue;
|
|
230
|
+
outlinks.get(page.rel).add(resolved);
|
|
231
|
+
backlinks.get(resolved)?.add(page.rel);
|
|
232
|
+
}
|
|
233
|
+
for (const link of page.markdownLinks) {
|
|
234
|
+
const resolved = resolveMarkdownWikiLink(aliasMap, page, link);
|
|
235
|
+
if (!resolved || resolved === page.rel) continue;
|
|
236
|
+
outlinks.get(page.rel).add(resolved);
|
|
237
|
+
backlinks.get(resolved)?.add(page.rel);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return { aliasMap, outlinks, backlinks };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export async function readMemoryExcerpt(projectRoot) {
|
|
245
|
+
const path = join(projectRoot, 'llm-wiki', 'wiki', 'memory.md');
|
|
246
|
+
const content = await readText(path, '');
|
|
247
|
+
if (!content) return '';
|
|
248
|
+
const lines = content.split('\n').slice(0, MEMORY_LINE_LIMIT).join('\n');
|
|
249
|
+
if (Buffer.byteLength(lines, 'utf8') <= MEMORY_BYTE_LIMIT) return lines;
|
|
250
|
+
let bytes = 0;
|
|
251
|
+
let output = '';
|
|
252
|
+
for (const char of lines) {
|
|
253
|
+
const nextBytes = Buffer.byteLength(char, 'utf8');
|
|
254
|
+
if (bytes + nextBytes > MEMORY_BYTE_LIMIT) break;
|
|
255
|
+
output += char;
|
|
256
|
+
bytes += nextBytes;
|
|
257
|
+
}
|
|
258
|
+
return output;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function pageLookup(pages) {
|
|
262
|
+
return new Map(pages.map((page) => [page.rel, page]));
|
|
263
|
+
}
|