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
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { join } from 'path';
|
|
2
|
+
import { readText } from './fs-utils.js';
|
|
3
|
+
import { redactText } from './redaction.js';
|
|
4
|
+
import {
|
|
5
|
+
buildWikiGraph,
|
|
6
|
+
collectWikiPages,
|
|
7
|
+
pageLookup,
|
|
8
|
+
readMemoryExcerpt,
|
|
9
|
+
} from './wiki-model.js';
|
|
10
|
+
|
|
11
|
+
const DEFAULT_LIMIT = 5;
|
|
12
|
+
const SNIPPET_CHARS = 350;
|
|
13
|
+
let miniSearchLoader = null;
|
|
14
|
+
|
|
15
|
+
async function loadMiniSearch() {
|
|
16
|
+
if (!miniSearchLoader) {
|
|
17
|
+
miniSearchLoader = import('minisearch')
|
|
18
|
+
.then((module) => module.default || module.MiniSearch || null)
|
|
19
|
+
.catch(() => null);
|
|
20
|
+
}
|
|
21
|
+
return miniSearchLoader;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function normalizeText(value) {
|
|
25
|
+
return String(value || '').normalize('NFC').toLowerCase();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function tokenizeWikiQuery(query) {
|
|
29
|
+
return normalizeText(query)
|
|
30
|
+
.replace(/[^\p{Letter}\p{Number}]+/gu, ' ')
|
|
31
|
+
.split(/\s+/)
|
|
32
|
+
.filter((token) => token.length >= 2)
|
|
33
|
+
.slice(0, 80);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function substringScore(page, terms) {
|
|
37
|
+
if (terms.length === 0) return 0;
|
|
38
|
+
const haystack = normalizeText(`${page.title}\n${page.rel}\n${page.body}`);
|
|
39
|
+
let score = 0;
|
|
40
|
+
for (const term of terms) {
|
|
41
|
+
if (haystack.includes(term)) score += term.length > 3 ? 2 : 1;
|
|
42
|
+
}
|
|
43
|
+
return score;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function snippetFor(page, terms) {
|
|
47
|
+
const text = page.body.replace(/\s+/g, ' ').trim() || page.content.replace(/\s+/g, ' ').trim();
|
|
48
|
+
const lower = normalizeText(text);
|
|
49
|
+
let start = 0;
|
|
50
|
+
for (const term of terms) {
|
|
51
|
+
const index = lower.indexOf(term);
|
|
52
|
+
if (index !== -1) {
|
|
53
|
+
start = Math.max(0, index - 80);
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return text.slice(start, start + SNIPPET_CHARS);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function resultRecord(page, score, fields = {}) {
|
|
61
|
+
return {
|
|
62
|
+
path: page.rel,
|
|
63
|
+
title: page.title,
|
|
64
|
+
type: page.type || '',
|
|
65
|
+
memoryType: page.memoryType || '',
|
|
66
|
+
score,
|
|
67
|
+
directScore: fields.directScore || 0,
|
|
68
|
+
linkScore: fields.linkScore || 0,
|
|
69
|
+
source: fields.source || 'direct',
|
|
70
|
+
via: fields.via || [],
|
|
71
|
+
matchedTerms: fields.matchedTerms || [],
|
|
72
|
+
snippet: fields.snippet || '',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function sortHits(a, b) {
|
|
77
|
+
if (b.score !== a.score) return b.score - a.score;
|
|
78
|
+
if (a.source !== b.source) return a.source === 'direct' ? -1 : 1;
|
|
79
|
+
return a.path.localeCompare(b.path);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function searchWiki(projectRoot, query, options = {}) {
|
|
83
|
+
return (await performSearch(projectRoot, query, options)).hits;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function performSearch(projectRoot, query, options = {}) {
|
|
87
|
+
const opts = typeof options === 'number' ? { limit: options } : options;
|
|
88
|
+
const limit = Number(opts.limit || DEFAULT_LIMIT);
|
|
89
|
+
const expand = opts.expand !== false;
|
|
90
|
+
const terms = tokenizeWikiQuery(query);
|
|
91
|
+
if (!query || terms.length === 0) {
|
|
92
|
+
return { hits: [], search: 'none' };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let pages = [];
|
|
96
|
+
try {
|
|
97
|
+
pages = await collectWikiPages(projectRoot, opts);
|
|
98
|
+
} catch {
|
|
99
|
+
return { hits: [], search: 'missing-wiki' };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const docs = pages.map((page) => ({
|
|
103
|
+
id: page.rel,
|
|
104
|
+
title: page.title,
|
|
105
|
+
path: page.rel,
|
|
106
|
+
body: page.body,
|
|
107
|
+
type: page.type || '',
|
|
108
|
+
memoryType: page.memoryType || '',
|
|
109
|
+
}));
|
|
110
|
+
const MiniSearch = await loadMiniSearch();
|
|
111
|
+
const byPath = pageLookup(pages);
|
|
112
|
+
const hits = new Map();
|
|
113
|
+
let search = 'substring+wikilink';
|
|
114
|
+
|
|
115
|
+
if (MiniSearch) {
|
|
116
|
+
search = 'minisearch+wikilink';
|
|
117
|
+
const index = new MiniSearch({
|
|
118
|
+
fields: ['title', 'path', 'body'],
|
|
119
|
+
storeFields: ['title', 'path', 'type', 'memoryType'],
|
|
120
|
+
searchOptions: {
|
|
121
|
+
boost: { title: 3, path: 2 },
|
|
122
|
+
prefix: true,
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
index.addAll(docs);
|
|
126
|
+
|
|
127
|
+
for (const item of index.search(query, { prefix: true, boost: { title: 3, path: 2 } })) {
|
|
128
|
+
const page = byPath.get(item.id);
|
|
129
|
+
if (!page) continue;
|
|
130
|
+
const subScore = substringScore(page, terms);
|
|
131
|
+
const score = item.score + subScore;
|
|
132
|
+
hits.set(page.rel, resultRecord(page, score, {
|
|
133
|
+
directScore: score,
|
|
134
|
+
matchedTerms: item.terms || terms,
|
|
135
|
+
snippet: snippetFor(page, terms),
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
for (const page of pages) {
|
|
141
|
+
const score = substringScore(page, terms);
|
|
142
|
+
if (score <= 0 || hits.has(page.rel)) continue;
|
|
143
|
+
hits.set(page.rel, resultRecord(page, score, {
|
|
144
|
+
directScore: score,
|
|
145
|
+
matchedTerms: terms,
|
|
146
|
+
snippet: snippetFor(page, terms),
|
|
147
|
+
}));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (expand && hits.size > 0) {
|
|
151
|
+
const graph = buildWikiGraph(pages);
|
|
152
|
+
const direct = [...hits.values()].sort(sortHits).slice(0, Math.max(limit, 5));
|
|
153
|
+
for (const seed of direct) {
|
|
154
|
+
const neighbors = new Set([
|
|
155
|
+
...(graph.outlinks.get(seed.path) || []),
|
|
156
|
+
...(graph.backlinks.get(seed.path) || []),
|
|
157
|
+
]);
|
|
158
|
+
for (const neighborPath of neighbors) {
|
|
159
|
+
if (hits.has(neighborPath)) continue;
|
|
160
|
+
const page = byPath.get(neighborPath);
|
|
161
|
+
if (!page) continue;
|
|
162
|
+
const linkScore = seed.score * 0.2;
|
|
163
|
+
hits.set(neighborPath, resultRecord(page, linkScore, {
|
|
164
|
+
linkScore,
|
|
165
|
+
source: 'linked',
|
|
166
|
+
via: [seed.path],
|
|
167
|
+
matchedTerms: [],
|
|
168
|
+
snippet: snippetFor(page, terms),
|
|
169
|
+
}));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
hits: [...hits.values()].sort(sortHits).slice(0, limit),
|
|
176
|
+
search,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function redactHit(hit) {
|
|
181
|
+
return {
|
|
182
|
+
...hit,
|
|
183
|
+
path: redactText(hit.path, 300),
|
|
184
|
+
title: redactText(hit.title, 300),
|
|
185
|
+
via: Array.isArray(hit.via) ? hit.via.map((item) => redactText(item, 300)) : [],
|
|
186
|
+
matchedTerms: Array.isArray(hit.matchedTerms) ? hit.matchedTerms.map((item) => redactText(item, 120)) : [],
|
|
187
|
+
snippet: redactText(hit.snippet, SNIPPET_CHARS),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function buildContextPack(projectRoot, query, options = {}) {
|
|
192
|
+
const limit = Number(options.limit || DEFAULT_LIMIT);
|
|
193
|
+
const expand = options.expand !== false;
|
|
194
|
+
const memoryExcerpt = await readMemoryExcerpt(projectRoot);
|
|
195
|
+
const indexExcerpt = (await readText(join(projectRoot, 'llm-wiki', 'wiki', 'index.md'))).slice(0, 1200).trim();
|
|
196
|
+
const logExcerpt = options.includeLog
|
|
197
|
+
? (await readText(join(projectRoot, 'llm-wiki', 'wiki', 'log.md'))).slice(-1000).trim()
|
|
198
|
+
: '';
|
|
199
|
+
const result = query ? await performSearch(projectRoot, query, { ...options, limit, expand }) : { hits: [], search: 'none' };
|
|
200
|
+
return {
|
|
201
|
+
workspace: projectRoot,
|
|
202
|
+
query: redactText(query || '', 1000),
|
|
203
|
+
limit,
|
|
204
|
+
expand,
|
|
205
|
+
search: result.search,
|
|
206
|
+
memoryExcerpt: redactText(memoryExcerpt.trim(), 30000),
|
|
207
|
+
indexExcerpt: redactText(indexExcerpt, 2000),
|
|
208
|
+
logExcerpt: redactText(logExcerpt, 2000),
|
|
209
|
+
hits: result.hits.map(redactHit),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function formatContextPack(pack) {
|
|
214
|
+
const lines = [
|
|
215
|
+
'LLM Wiki context from llm-wiki-kit:',
|
|
216
|
+
'- Treat chat memory as temporary; update project Markdown when knowledge should persist.',
|
|
217
|
+
'- Preserve raw/wiki separation. Do not store secrets, tokens, .env contents, private keys, or personal/customer identifiers.',
|
|
218
|
+
'- Prefer updating existing wiki pages over creating duplicate pages.',
|
|
219
|
+
];
|
|
220
|
+
if (pack.query) {
|
|
221
|
+
lines.push(`- query: "${pack.query}"`);
|
|
222
|
+
lines.push(`- search: ${pack.search}`);
|
|
223
|
+
}
|
|
224
|
+
if (pack.memoryExcerpt) {
|
|
225
|
+
lines.push('', 'Memory excerpt:', pack.memoryExcerpt);
|
|
226
|
+
}
|
|
227
|
+
if (pack.indexExcerpt) {
|
|
228
|
+
lines.push('', 'Index excerpt:', pack.indexExcerpt);
|
|
229
|
+
}
|
|
230
|
+
if (pack.hits.length > 0) {
|
|
231
|
+
lines.push('', 'Relevant wiki pages:');
|
|
232
|
+
for (const hit of pack.hits) {
|
|
233
|
+
const suffix = hit.source === 'linked' && hit.via.length > 0
|
|
234
|
+
? `, linked via ${hit.via.join(', ')}`
|
|
235
|
+
: '';
|
|
236
|
+
lines.push(`- ${hit.path} (score ${hit.score.toFixed(2)}, ${hit.source}${suffix}): ${hit.snippet}`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (pack.logExcerpt) {
|
|
240
|
+
lines.push('', 'Recent log excerpt:', pack.logExcerpt);
|
|
241
|
+
}
|
|
242
|
+
return lines.join('\n').trim();
|
|
243
|
+
}
|