llm-wiki-kit 0.2.9 → 0.2.11

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/src/wiki-lint.js CHANGED
@@ -4,14 +4,24 @@ import { exists, readText } from './fs-utils.js';
4
4
  import { maintenanceSummary } from './maintenance.js';
5
5
  import { inspectProjectState } from './project-state.js';
6
6
  import { hasSecretLikeText } from './redaction.js';
7
+ import { liveQaMaxBytes, liveQaMaxLines } from './live-qa.js';
7
8
  import {
8
9
  buildAliasMap,
9
10
  buildWikiGraph,
10
11
  collectWikiPages,
12
+ DEFAULT_MAX_WIKI_FILES,
13
+ MEMORY_BYTE_LIMIT,
11
14
  normalizeTarget,
12
15
  resolveWikiLink,
13
16
  wikiRoot,
14
17
  } from './wiki-model.js';
18
+ import {
19
+ hasSupersession,
20
+ isArchivedPage,
21
+ isEpisodicLayerPage,
22
+ isPromotedDurablePage,
23
+ isStalePage,
24
+ } from './wiki-visibility.js';
15
25
 
16
26
  const VALID_TYPES = new Set([
17
27
  'source',
@@ -29,11 +39,60 @@ const VALID_STATUS = new Set(['draft', 'reviewed', 'stale', 'archived']);
29
39
  const VALID_CONFIDENCE = new Set(['high', 'medium', 'low']);
30
40
  const VALID_MEMORY_TYPES = new Set(['semantic', 'episodic', 'procedural']);
31
41
  const CORE_PAGES = new Set(['wiki/index.md', 'wiki/log.md', 'wiki/memory.md']);
42
+ const MEMORY_NEAR_BUDGET_BYTES = 20 * 1024;
43
+ const HIDDEN_PAGE_WARNING_THRESHOLD = 50;
44
+ const SEARCH_CAP_WARNING_RATIO = 0.8;
45
+ const ARCHIVED_LIVE_QA_MARKER = '<!-- llm-wiki-kit:archived-live-qa -->';
32
46
 
33
47
  function issue(severity, code, path, message) {
34
48
  return { severity, code, path, message };
35
49
  }
36
50
 
51
+ function countLines(text) {
52
+ if (!text) return 0;
53
+ return String(text).split(/\r?\n/).length;
54
+ }
55
+
56
+ function liveQaOversized(text) {
57
+ return countLines(text) > liveQaMaxLines() || Buffer.byteLength(text || '', 'utf8') > liveQaMaxBytes();
58
+ }
59
+
60
+ async function liveQaGrowthIssues(projectRoot) {
61
+ const issues = [];
62
+ const root = join(projectRoot, 'llm-wiki', 'outputs', 'questions');
63
+ let entries = [];
64
+ try {
65
+ entries = await readdir(root, { withFileTypes: true });
66
+ } catch {
67
+ return issues;
68
+ }
69
+ for (const entry of entries) {
70
+ const full = join(root, entry.name);
71
+ if (entry.isFile() && /^\d{4}-\d{2}-\d{2}-live-qa\.md$/.test(entry.name)) {
72
+ const text = await readText(full, '');
73
+ if (!text.includes(ARCHIVED_LIVE_QA_MARKER) && liveQaOversized(text)) {
74
+ issues.push(issue('warning', 'live-qa-legacy-oversized', `outputs/questions/${entry.name}`, 'legacy daily live Q&A file is oversized; run archive-questions to split it into chunks'));
75
+ }
76
+ }
77
+ if (entry.isDirectory() && /^\d{4}-\d{2}-\d{2}$/.test(entry.name)) {
78
+ let chunkEntries = [];
79
+ try {
80
+ chunkEntries = await readdir(full, { withFileTypes: true });
81
+ } catch {
82
+ chunkEntries = [];
83
+ }
84
+ for (const chunk of chunkEntries) {
85
+ if (!chunk.isFile() || !/^live-qa-\d+\.md$/.test(chunk.name)) continue;
86
+ const text = await readText(join(full, chunk.name), '');
87
+ if (liveQaOversized(text)) {
88
+ issues.push(issue('warning', 'live-qa-chunk-oversized', `outputs/questions/${entry.name}/${chunk.name}`, 'live Q&A chunk exceeds the configured rollover budget'));
89
+ }
90
+ }
91
+ }
92
+ }
93
+ return issues;
94
+ }
95
+
37
96
  function isDateLike(value) {
38
97
  return value === 'unknown' || /^\d{4}-\d{2}-\d{2}$/.test(String(value || ''));
39
98
  }
@@ -125,8 +184,9 @@ export async function runLint(projectRoot, options = {}) {
125
184
  return lintResult(projectRoot, [], issues);
126
185
  }
127
186
 
187
+ const maxFiles = options.maxFiles || 1000;
128
188
  const pages = await collectWikiPages(projectRoot, {
129
- maxFiles: options.maxFiles || 1000,
189
+ maxFiles,
130
190
  maxChars: options.maxChars || 75000,
131
191
  });
132
192
  const byRel = new Map(pages.map((page) => [page.rel, page]));
@@ -226,9 +286,23 @@ export async function runLint(projectRoot, options = {}) {
226
286
  }
227
287
 
228
288
  const memoryText = await readText(join(projectRoot, 'llm-wiki', 'wiki', 'memory.md'), '');
229
- if (Buffer.byteLength(memoryText, 'utf8') > 25000) {
230
- issues.push(issue('warning', 'memory-too-large', 'wiki/memory.md', 'memory.md is larger than the hook excerpt budget'));
289
+ const memoryBytes = Buffer.byteLength(memoryText, 'utf8');
290
+ if (memoryBytes > MEMORY_BYTE_LIMIT) {
291
+ issues.push(issue('warning', 'memory-too-large', 'wiki/memory.md', `memory.md is oversized (${memoryBytes} bytes) and larger than the hook excerpt budget`));
292
+ } else if (memoryBytes >= MEMORY_NEAR_BUDGET_BYTES) {
293
+ issues.push(issue('warning', 'memory-near-budget', 'wiki/memory.md', `memory.md is near the hook excerpt budget (${memoryBytes} bytes)`));
294
+ }
295
+
296
+ const searchCap = options.maxFiles || DEFAULT_MAX_WIKI_FILES;
297
+ if (pages.length >= Math.floor(searchCap * SEARCH_CAP_WARNING_RATIO)) {
298
+ issues.push(issue('warning', 'wiki-page-count-near-search-cap', 'llm-wiki/wiki', `wiki page count ${pages.length} is near the default search cap ${searchCap}`));
299
+ }
300
+
301
+ const hiddenEpisodicPages = pages.filter((page) => isEpisodicLayerPage(page) && !isPromotedDurablePage(page));
302
+ if (hiddenEpisodicPages.length >= HIDDEN_PAGE_WARNING_THRESHOLD) {
303
+ issues.push(issue('warning', 'hidden-episodic-growth', 'llm-wiki/wiki', `${hiddenEpisodicPages.length} default-hidden episodic/context/session pages may add wiki growth and search noise`));
231
304
  }
305
+
232
306
  for (const page of pages.filter(isOrphanCandidate)) {
233
307
  const backlinks = graph.backlinks.get(page.rel);
234
308
  if (!backlinks || backlinks.size === 0) {
@@ -236,6 +310,15 @@ export async function runLint(projectRoot, options = {}) {
236
310
  }
237
311
  }
238
312
 
313
+ for (const page of pages) {
314
+ if (!isArchivedPage(page) && !isStalePage(page)) continue;
315
+ const backlinks = graph.backlinks.get(page.rel);
316
+ const outlinks = graph.outlinks.get(page.rel);
317
+ if (!hasSupersession(page) && (!backlinks || backlinks.size === 0) && (!outlinks || outlinks.size === 0)) {
318
+ issues.push(issue('warning', 'stale-archived-discoverability', page.rel, 'stale/archived page has no supersession metadata or wiki links for discoverability'));
319
+ }
320
+ }
321
+
239
322
  const projectState = await inspectProjectState(projectRoot).catch(() => null);
240
323
  for (const file of projectState?.managedFiles || []) {
241
324
  if (file.needsAttention) {
@@ -245,14 +328,18 @@ export async function runLint(projectRoot, options = {}) {
245
328
  }
246
329
  }
247
330
 
248
- const maintenance = await maintenanceSummary(projectRoot).catch(() => null);
249
- if (maintenance?.tooManyPending) {
250
- issues.push(issue('warning', 'maintenance-too-many-pending', 'outputs/maintenance/queue.md', `maintenance queue has ${maintenance.pendingCount} pending items; review and merge durable items into wiki pages`));
251
- }
252
- if ((maintenance?.stalePending || []).length > 0) {
253
- issues.push(issue('warning', 'maintenance-stale-pending', 'outputs/maintenance/queue.md', `${maintenance.stalePending.length} pending maintenance item(s) are older than ${maintenance.stalePendingDays} days`));
331
+ if (!options.skipMaintenance) {
332
+ const maintenance = await maintenanceSummary(projectRoot, { skipLint: true }).catch(() => null);
333
+ if (maintenance?.tooManyPending) {
334
+ issues.push(issue('warning', 'maintenance-too-many-pending', 'outputs/maintenance/queue.md', `maintenance queue has ${maintenance.pendingCount} pending items; review and merge durable items into wiki pages`));
335
+ }
336
+ if ((maintenance?.stalePending || []).length > 0) {
337
+ issues.push(issue('warning', 'maintenance-stale-pending', 'outputs/maintenance/queue.md', `${maintenance.stalePending.length} pending maintenance item(s) are older than ${maintenance.stalePendingDays} days`));
338
+ }
254
339
  }
255
340
 
341
+ issues.push(...await liveQaGrowthIssues(projectRoot));
342
+
256
343
  return lintResult(projectRoot, pages, issues);
257
344
  }
258
345
 
@@ -4,10 +4,20 @@ import { redactText } from './redaction.js';
4
4
  import {
5
5
  buildWikiGraph,
6
6
  collectWikiPages,
7
+ MEMORY_BYTE_LIMIT,
8
+ MEMORY_LINE_LIMIT,
7
9
  pageLookup,
8
10
  parseFrontmatter,
9
11
  readMemoryExcerpt,
10
12
  } from './wiki-model.js';
13
+ import {
14
+ isArchivedPage,
15
+ isDefaultSearchCandidate,
16
+ isEpisodicLayerPage,
17
+ isStalePage,
18
+ isSupersededPage,
19
+ searchWeight,
20
+ } from './wiki-visibility.js';
11
21
 
12
22
  const DEFAULT_LIMIT = 5;
13
23
  const SNIPPET_CHARS = 350;
@@ -65,6 +75,7 @@ function resultRecord(page, score, fields = {}) {
65
75
  title: page.title,
66
76
  type: page.type || '',
67
77
  memoryType: page.memoryType || '',
78
+ status: page.status || '',
68
79
  score,
69
80
  directScore: fields.directScore || 0,
70
81
  linkScore: fields.linkScore || 0,
@@ -81,22 +92,35 @@ function sortHits(a, b) {
81
92
  return a.path.localeCompare(b.path);
82
93
  }
83
94
 
84
- function importance(page) {
85
- const value = Number(page?.frontmatter?.importance || 0);
86
- return Number.isFinite(value) ? value : 0;
95
+ export async function searchWiki(projectRoot, query, options = {}) {
96
+ return (await performSearch(projectRoot, query, options)).hits;
87
97
  }
88
98
 
89
- function isDefaultSearchCandidate(page, options = {}) {
90
- if (options.includeEpisodic) return true;
91
- const type = String(page.type || '').toLowerCase();
92
- if (type === 'query' || page.rel.startsWith('wiki/queries/')) {
93
- return ['semantic', 'procedural'].includes(page.memoryType) && importance(page) >= 4;
99
+ function searchVisibility(pages, options = {}) {
100
+ const metadata = {
101
+ totalPages: pages.length,
102
+ searchedPages: 0,
103
+ hiddenEpisodicPages: 0,
104
+ hiddenArchivedPages: 0,
105
+ hiddenSupersededPages: 0,
106
+ stalePagesSearched: 0,
107
+ includeEpisodic: Boolean(options.includeEpisodic),
108
+ includeArchived: Boolean(options.includeArchived),
109
+ };
110
+ const visible = [];
111
+ for (const page of pages) {
112
+ const hiddenArchived = !options.includeArchived && isArchivedPage(page);
113
+ const hiddenSuperseded = !options.includeArchived && isSupersededPage(page);
114
+ const hiddenEpisodic = !options.includeEpisodic && isEpisodicLayerPage(page) && !isDefaultSearchCandidate(page, options);
115
+ if (hiddenArchived) metadata.hiddenArchivedPages += 1;
116
+ if (hiddenSuperseded) metadata.hiddenSupersededPages += 1;
117
+ if (hiddenEpisodic) metadata.hiddenEpisodicPages += 1;
118
+ if (!isDefaultSearchCandidate(page, options)) continue;
119
+ if (isStalePage(page)) metadata.stalePagesSearched += 1;
120
+ visible.push(page);
94
121
  }
95
- return true;
96
- }
97
-
98
- export async function searchWiki(projectRoot, query, options = {}) {
99
- return (await performSearch(projectRoot, query, options)).hits;
122
+ metadata.searchedPages = visible.length;
123
+ return { visible, metadata };
100
124
  }
101
125
 
102
126
  async function performSearch(projectRoot, query, options = {}) {
@@ -109,8 +133,21 @@ async function performSearch(projectRoot, query, options = {}) {
109
133
  }
110
134
 
111
135
  let pages = [];
136
+ let metadata = {
137
+ totalPages: 0,
138
+ searchedPages: 0,
139
+ hiddenEpisodicPages: 0,
140
+ hiddenArchivedPages: 0,
141
+ hiddenSupersededPages: 0,
142
+ stalePagesSearched: 0,
143
+ includeEpisodic: Boolean(opts.includeEpisodic),
144
+ includeArchived: Boolean(opts.includeArchived),
145
+ };
112
146
  try {
113
- pages = (await collectWikiPages(projectRoot, opts)).filter((page) => isDefaultSearchCandidate(page, opts));
147
+ const collected = await collectWikiPages(projectRoot, opts);
148
+ const visibility = searchVisibility(collected, opts);
149
+ pages = visibility.visible;
150
+ metadata = visibility.metadata;
114
151
  } catch {
115
152
  return { hits: [], search: 'missing-wiki' };
116
153
  }
@@ -144,7 +181,7 @@ async function performSearch(projectRoot, query, options = {}) {
144
181
  const page = byPath.get(item.id);
145
182
  if (!page) continue;
146
183
  const subScore = substringScore(page, terms);
147
- const score = item.score + subScore;
184
+ const score = (item.score + subScore) * searchWeight(page);
148
185
  hits.set(page.rel, resultRecord(page, score, {
149
186
  directScore: score,
150
187
  matchedTerms: item.terms || terms,
@@ -154,7 +191,7 @@ async function performSearch(projectRoot, query, options = {}) {
154
191
  }
155
192
 
156
193
  for (const page of pages) {
157
- const score = substringScore(page, terms);
194
+ const score = substringScore(page, terms) * searchWeight(page);
158
195
  if (score <= 0 || hits.has(page.rel)) continue;
159
196
  hits.set(page.rel, resultRecord(page, score, {
160
197
  directScore: score,
@@ -175,7 +212,7 @@ async function performSearch(projectRoot, query, options = {}) {
175
212
  if (hits.has(neighborPath)) continue;
176
213
  const page = byPath.get(neighborPath);
177
214
  if (!page) continue;
178
- const linkScore = seed.score * 0.2;
215
+ const linkScore = seed.score * 0.2 * searchWeight(page);
179
216
  hits.set(neighborPath, resultRecord(page, linkScore, {
180
217
  linkScore,
181
218
  source: 'linked',
@@ -190,6 +227,7 @@ async function performSearch(projectRoot, query, options = {}) {
190
227
  return {
191
228
  hits: [...hits.values()].sort(sortHits).slice(0, limit),
192
229
  search,
230
+ metadata,
193
231
  };
194
232
  }
195
233
 
@@ -282,11 +320,12 @@ export async function buildContextPack(projectRoot, query, options = {}) {
282
320
  const limit = Number(options.limit || DEFAULT_LIMIT);
283
321
  const expand = options.expand !== false;
284
322
  const memoryExcerpt = await readMemoryExcerpt(projectRoot);
285
- const indexExcerpt = (await readText(join(projectRoot, 'llm-wiki', 'wiki', 'index.md'))).slice(0, 1200).trim();
323
+ const rawIndexExcerpt = await readText(join(projectRoot, 'llm-wiki', 'wiki', 'index.md'));
324
+ const indexExcerpt = rawIndexExcerpt.slice(0, 1200).trim();
286
325
  const logExcerpt = options.includeLog
287
326
  ? (await readText(join(projectRoot, 'llm-wiki', 'wiki', 'log.md'))).slice(-1000).trim()
288
327
  : '';
289
- const result = query ? await performSearch(projectRoot, query, { ...options, limit, expand }) : { hits: [], search: 'none' };
328
+ const result = query ? await performSearch(projectRoot, query, { ...options, limit, expand }) : { hits: [], search: 'none', metadata: null };
290
329
  return {
291
330
  workspace: projectRoot,
292
331
  query: redactText(query || '', 1000),
@@ -297,6 +336,32 @@ export async function buildContextPack(projectRoot, query, options = {}) {
297
336
  indexExcerpt: redactText(indexExcerpt, 2000),
298
337
  logExcerpt: redactText(logExcerpt, 2000),
299
338
  hits: result.hits.map(redactHit),
339
+ metadata: {
340
+ search: result.metadata,
341
+ budgets: {
342
+ memory: {
343
+ maxLines: MEMORY_LINE_LIMIT,
344
+ maxBytes: MEMORY_BYTE_LIMIT,
345
+ returnedBytes: Buffer.byteLength(memoryExcerpt.trim(), 'utf8'),
346
+ redactionMaxChars: 30000,
347
+ },
348
+ index: {
349
+ maxChars: 1200,
350
+ sourceChars: rawIndexExcerpt.length,
351
+ returnedChars: indexExcerpt.length,
352
+ redactionMaxChars: 2000,
353
+ },
354
+ hits: {
355
+ requestedLimit: limit,
356
+ returned: result.hits.length,
357
+ snippetChars: SNIPPET_CHARS,
358
+ hookSnippetChars: HOOK_SNIPPET_CHARS,
359
+ expand,
360
+ includeEpisodic: Boolean(options.includeEpisodic),
361
+ includeArchived: Boolean(options.includeArchived),
362
+ },
363
+ },
364
+ },
300
365
  };
301
366
  }
302
367
 
@@ -0,0 +1,76 @@
1
+ export const DURABLE_MEMORY_TYPES = new Set(['semantic', 'procedural']);
2
+
3
+ export function frontmatterValues(value) {
4
+ if (Array.isArray(value)) return value.map((item) => String(item || '').trim()).filter(Boolean);
5
+ const text = String(value || '').trim();
6
+ if (!text || text === '[]') return [];
7
+ return [text];
8
+ }
9
+
10
+ export function pageImportance(page) {
11
+ const value = Number(page?.frontmatter?.importance || 0);
12
+ return Number.isFinite(value) ? value : 0;
13
+ }
14
+
15
+ export function isArchivedPage(page) {
16
+ return String(page?.status || '').toLowerCase() === 'archived';
17
+ }
18
+
19
+ export function isStalePage(page) {
20
+ return String(page?.status || '').toLowerCase() === 'stale';
21
+ }
22
+
23
+ export function supersededBy(page) {
24
+ return frontmatterValues(page?.frontmatter?.superseded_by);
25
+ }
26
+
27
+ export function supersedes(page) {
28
+ return frontmatterValues(page?.frontmatter?.supersedes);
29
+ }
30
+
31
+ export function isSupersededPage(page) {
32
+ return supersededBy(page).length > 0;
33
+ }
34
+
35
+ export function hasSupersession(page) {
36
+ return supersededBy(page).length > 0 || supersedes(page).length > 0;
37
+ }
38
+
39
+ export function isEpisodicLayerPage(page) {
40
+ const type = String(page?.type || '').toLowerCase();
41
+ const rel = String(page?.rel || '');
42
+ return (
43
+ type === 'query' ||
44
+ type === 'context' ||
45
+ type === 'session-log' ||
46
+ rel.startsWith('wiki/queries/') ||
47
+ rel.startsWith('wiki/context/')
48
+ );
49
+ }
50
+
51
+ export function isPromotedDurablePage(page) {
52
+ return DURABLE_MEMORY_TYPES.has(String(page?.memoryType || '').toLowerCase()) && pageImportance(page) >= 4;
53
+ }
54
+
55
+ export function includeArchivedPages(options = {}) {
56
+ return Boolean(options.includeArchived);
57
+ }
58
+
59
+ export function includeEpisodicPages(options = {}) {
60
+ return Boolean(options.includeEpisodic);
61
+ }
62
+
63
+ export function isDefaultSearchCandidate(page, options = {}) {
64
+ if (!includeArchivedPages(options) && (isArchivedPage(page) || isSupersededPage(page))) return false;
65
+ if (includeEpisodicPages(options)) return true;
66
+ if (isEpisodicLayerPage(page)) return isPromotedDurablePage(page);
67
+ return true;
68
+ }
69
+
70
+ export function searchWeight(page) {
71
+ let weight = 1;
72
+ if (DURABLE_MEMORY_TYPES.has(String(page?.memoryType || '').toLowerCase())) weight *= 1.2;
73
+ if (pageImportance(page) >= 4) weight *= 1.1;
74
+ if (isStalePage(page)) weight *= 0.35;
75
+ return weight;
76
+ }