llm-wiki-kit 0.2.14 → 0.2.15

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
@@ -1,9 +1,10 @@
1
1
  import { readdir } from 'fs/promises';
2
2
  import { dirname, isAbsolute, join, parse, relative, resolve, sep } from 'path';
3
+ import { EVIDENCE_PREFIXES, MAX_CMD_EVIDENCE_CHARS, parseEvidenceRef } from './evidence.js';
3
4
  import { exists, readText } from './fs-utils.js';
4
5
  import { maintenanceSummary } from './maintenance.js';
5
6
  import { inspectProjectState } from './project-state.js';
6
- import { hasSecretLikeText } from './redaction.js';
7
+ import { hasSecretLikeText, isSensitivePath } from './redaction.js';
7
8
  import { liveQaMaxBytes, liveQaMaxLines } from './live-qa.js';
8
9
  import {
9
10
  buildAliasMap,
@@ -145,6 +146,62 @@ async function sourceExists(projectRoot, sourceId) {
145
146
  return false;
146
147
  }
147
148
 
149
+ function fileEvidencePath(projectRoot, value) {
150
+ return resolve(projectRoot, String(value || '').replace(/\\/g, '/'));
151
+ }
152
+
153
+ async function validateEvidenceRef(projectRoot, page, ref) {
154
+ const issues = [];
155
+ const parsed = parseEvidenceRef(ref);
156
+ const raw = parsed.raw;
157
+ const value = parsed.value;
158
+ if (!parsed.prefix || !EVIDENCE_PREFIXES.has(parsed.prefix)) {
159
+ issues.push(issue('error', 'invalid-evidence-ref-prefix', page.rel, `unsupported evidence_refs prefix: ${raw || '(empty)'}`));
160
+ return issues;
161
+ }
162
+ if (hasSecretLikeText(raw)) {
163
+ issues.push(issue('error', 'secret-like-evidence-ref', page.rel, 'evidence_refs contains token, credential, private-key, or secret-like text'));
164
+ return issues;
165
+ }
166
+ if (parsed.prefix === 'file') {
167
+ const normalized = value.replace(/\\/g, '/');
168
+ if (!normalized) {
169
+ issues.push(issue('error', 'invalid-file-evidence-ref', page.rel, 'file evidence ref is empty'));
170
+ } else if (isAbsolute(normalized) || /^[a-z][a-z0-9+.-]*:/i.test(normalized) || normalized.split('/').includes('..') || isSensitivePath(normalized)) {
171
+ issues.push(issue('error', 'invalid-file-evidence-ref', page.rel, `file evidence ref must be safe repo-relative path: ${normalized}`));
172
+ } else if (!(await pathExistsNormalized(fileEvidencePath(projectRoot, normalized)))) {
173
+ issues.push(issue('warning', 'missing-file-evidence-ref', page.rel, `file evidence ref has no matching project file: ${normalized}`));
174
+ }
175
+ } else if (parsed.prefix === 'raw') {
176
+ if (invalidSourceId(value)) {
177
+ issues.push(issue('error', 'invalid-raw-evidence-ref', page.rel, 'raw evidence ref points outside the project or uses unsupported syntax'));
178
+ } else if (!(await sourceExists(projectRoot, value))) {
179
+ issues.push(issue('warning', 'missing-raw-evidence-ref', page.rel, `raw evidence ref has no matching source file: ${value}`));
180
+ }
181
+ } else if (parsed.prefix === 'url') {
182
+ try {
183
+ const url = new URL(value);
184
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
185
+ issues.push(issue('error', 'invalid-url-evidence-ref', page.rel, `url evidence ref must use http or https: ${value}`));
186
+ }
187
+ if (url.username || url.password) {
188
+ issues.push(issue('error', 'credential-url-evidence-ref', page.rel, 'url evidence ref must not include credentials'));
189
+ }
190
+ } catch {
191
+ issues.push(issue('error', 'invalid-url-evidence-ref', page.rel, `url evidence ref is not a valid URL: ${value}`));
192
+ }
193
+ } else if (parsed.prefix === 'cmd') {
194
+ if (!value) {
195
+ issues.push(issue('error', 'invalid-cmd-evidence-ref', page.rel, 'cmd evidence ref is empty'));
196
+ } else if (value.length > MAX_CMD_EVIDENCE_CHARS) {
197
+ issues.push(issue('error', 'invalid-cmd-evidence-ref', page.rel, `cmd evidence ref exceeds ${MAX_CMD_EVIDENCE_CHARS} characters`));
198
+ } else if (/[\r\n]/.test(value)) {
199
+ issues.push(issue('error', 'invalid-cmd-evidence-ref', page.rel, 'cmd evidence ref must be a single line'));
200
+ }
201
+ }
202
+ return issues;
203
+ }
204
+
148
205
  function titleKey(page) {
149
206
  return normalizeTarget(page.title);
150
207
  }
@@ -250,6 +307,14 @@ export async function runLint(projectRoot, options = {}) {
250
307
  if (page.frontmatter.last_verified && !isDateLike(page.frontmatter.last_verified)) {
251
308
  issues.push(issue('warning', 'invalid-last-verified', page.rel, `last_verified should be YYYY-MM-DD or unknown: ${page.frontmatter.last_verified}`));
252
309
  }
310
+ if (page.frontmatter.evidence_refs !== undefined && !Array.isArray(page.frontmatter.evidence_refs)) {
311
+ issues.push(issue('warning', 'invalid-evidence-refs', page.rel, 'evidence_refs should be a YAML array'));
312
+ }
313
+ if (Array.isArray(page.frontmatter.evidence_refs)) {
314
+ for (const ref of page.evidenceRefs) {
315
+ issues.push(...await validateEvidenceRef(projectRoot, page, ref));
316
+ }
317
+ }
253
318
  if (page.status === 'stale') {
254
319
  issues.push(issue('warning', 'stale-page', page.rel, 'page is marked stale'));
255
320
  }
package/src/wiki-model.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { basename, join, posix, relative } from 'path';
2
+ import { normalizeEvidenceRefs } from './evidence.js';
2
3
  import { exists, listMarkdownFiles, readText } from './fs-utils.js';
3
4
  import { normalizeForStorage } from './redaction.js';
4
5
 
@@ -156,6 +157,7 @@ export function parseWikiPage(projectRoot, file, content) {
156
157
  wikilinks: extractWikilinks(content),
157
158
  markdownLinks: extractMarkdownLinks(content),
158
159
  sourceIds: Array.isArray(data.source_ids) ? data.source_ids : [],
160
+ evidenceRefs: normalizeEvidenceRefs(data.evidence_refs),
159
161
  };
160
162
  }
161
163
 
@@ -17,6 +17,8 @@ import {
17
17
  isStalePage,
18
18
  isSupersededPage,
19
19
  searchWeight,
20
+ visibleWikiPages,
21
+ wikiVisibility,
20
22
  } from './wiki-visibility.js';
21
23
 
22
24
  const DEFAULT_LIMIT = 5;
@@ -69,7 +71,41 @@ function snippetFor(page, terms) {
69
71
  return text.slice(start, start + SNIPPET_CHARS);
70
72
  }
71
73
 
74
+ function matchedFieldsFor(page, terms) {
75
+ const fields = [];
76
+ const title = normalizeText(page.title);
77
+ const path = normalizeText(page.rel);
78
+ const body = normalizeText(page.body);
79
+ if (terms.some((term) => title.includes(term))) fields.push('title');
80
+ if (terms.some((term) => path.includes(term))) fields.push('path');
81
+ if (terms.some((term) => body.includes(term))) fields.push('body');
82
+ return fields;
83
+ }
84
+
85
+ function rankReason(page, fields = {}) {
86
+ const parts = [];
87
+ if (fields.source === 'linked') {
88
+ parts.push(`linked from ${fields.via?.join(', ') || 'direct hit'}`);
89
+ } else if ((fields.matchedFields || []).length > 0) {
90
+ parts.push(`matched ${fields.matchedFields.join(', ')}`);
91
+ } else {
92
+ parts.push('selected by retrieval score');
93
+ }
94
+ if (page.memoryType) parts.push(`memory:${page.memoryType}`);
95
+ if (page.frontmatter?.importance) parts.push(`importance:${page.frontmatter.importance}`);
96
+ if (page.status) parts.push(`status:${page.status}`);
97
+ return parts.join('; ');
98
+ }
99
+
72
100
  function resultRecord(page, score, fields = {}) {
101
+ const matchedFields = fields.matchedFields || [];
102
+ const scoreBreakdown = {
103
+ miniSearchScore: fields.miniSearchScore || 0,
104
+ substringScore: fields.substringScore || 0,
105
+ directScore: fields.directScore || 0,
106
+ linkScore: fields.linkScore || 0,
107
+ weight: fields.weight || searchWeight(page),
108
+ };
73
109
  return {
74
110
  path: page.rel,
75
111
  title: page.title,
@@ -82,6 +118,11 @@ function resultRecord(page, score, fields = {}) {
82
118
  source: fields.source || 'direct',
83
119
  via: fields.via || [],
84
120
  matchedTerms: fields.matchedTerms || [],
121
+ matchedFields,
122
+ scoreBreakdown,
123
+ rankReason: fields.rankReason || rankReason(page, { ...fields, matchedFields }),
124
+ visibilityReason: fields.visibilityReason || wikiVisibility(page, fields.visibilityOptions || {}).reason,
125
+ evidenceRefs: page.evidenceRefs || [],
85
126
  snippet: fields.snippet || '',
86
127
  };
87
128
  }
@@ -108,18 +149,19 @@ function searchVisibility(pages, options = {}) {
108
149
  includeArchived: Boolean(options.includeArchived),
109
150
  };
110
151
  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);
152
+ const visibility = visibleWikiPages(pages, options);
153
+ for (const item of visibility.hidden) {
154
+ const page = item.page;
155
+ if (!options.includeArchived && isArchivedPage(page)) metadata.hiddenArchivedPages += 1;
156
+ if (!options.includeArchived && isSupersededPage(page)) metadata.hiddenSupersededPages += 1;
157
+ if (!options.includeEpisodic && isEpisodicLayerPage(page) && !isDefaultSearchCandidate(page, options)) metadata.hiddenEpisodicPages += 1;
158
+ }
159
+ for (const item of visibility.visible) {
160
+ if (isStalePage(item.page)) metadata.stalePagesSearched += 1;
161
+ visible.push(item.page);
121
162
  }
122
163
  metadata.searchedPages = visible.length;
164
+ metadata.visibilityPolicy = visibility.policy;
123
165
  return { visible, metadata };
124
166
  }
125
167
 
@@ -181,22 +223,34 @@ async function performSearch(projectRoot, query, options = {}) {
181
223
  const page = byPath.get(item.id);
182
224
  if (!page) continue;
183
225
  const subScore = substringScore(page, terms);
184
- const score = (item.score + subScore) * searchWeight(page);
226
+ const weight = searchWeight(page);
227
+ const score = (item.score + subScore) * weight;
185
228
  hits.set(page.rel, resultRecord(page, score, {
186
229
  directScore: score,
230
+ miniSearchScore: item.score,
231
+ substringScore: subScore,
232
+ weight,
233
+ matchedFields: matchedFieldsFor(page, terms),
187
234
  matchedTerms: item.terms || terms,
188
235
  snippet: snippetFor(page, terms),
236
+ visibilityOptions: opts,
189
237
  }));
190
238
  }
191
239
  }
192
240
 
193
241
  for (const page of pages) {
194
- const score = substringScore(page, terms) * searchWeight(page);
242
+ const subScore = substringScore(page, terms);
243
+ const weight = searchWeight(page);
244
+ const score = subScore * weight;
195
245
  if (score <= 0 || hits.has(page.rel)) continue;
196
246
  hits.set(page.rel, resultRecord(page, score, {
197
247
  directScore: score,
248
+ substringScore: subScore,
249
+ weight,
250
+ matchedFields: matchedFieldsFor(page, terms),
198
251
  matchedTerms: terms,
199
252
  snippet: snippetFor(page, terms),
253
+ visibilityOptions: opts,
200
254
  }));
201
255
  }
202
256
 
@@ -212,13 +266,17 @@ async function performSearch(projectRoot, query, options = {}) {
212
266
  if (hits.has(neighborPath)) continue;
213
267
  const page = byPath.get(neighborPath);
214
268
  if (!page) continue;
215
- const linkScore = seed.score * 0.2 * searchWeight(page);
269
+ const weight = searchWeight(page);
270
+ const linkScore = seed.score * 0.2 * weight;
216
271
  hits.set(neighborPath, resultRecord(page, linkScore, {
217
272
  linkScore,
273
+ weight,
218
274
  source: 'linked',
219
275
  via: [seed.path],
220
276
  matchedTerms: [],
277
+ matchedFields: [],
221
278
  snippet: snippetFor(page, terms),
279
+ visibilityOptions: opts,
222
280
  }));
223
281
  }
224
282
  }
@@ -238,6 +296,10 @@ function redactHit(hit) {
238
296
  title: redactText(hit.title, 300),
239
297
  via: Array.isArray(hit.via) ? hit.via.map((item) => redactText(item, 300)) : [],
240
298
  matchedTerms: Array.isArray(hit.matchedTerms) ? hit.matchedTerms.map((item) => redactText(item, 120)) : [],
299
+ matchedFields: Array.isArray(hit.matchedFields) ? hit.matchedFields.map((item) => redactText(item, 120)) : [],
300
+ rankReason: redactText(hit.rankReason || '', 500),
301
+ visibilityReason: redactText(hit.visibilityReason || '', 500),
302
+ evidenceRefs: Array.isArray(hit.evidenceRefs) ? hit.evidenceRefs.map((item) => redactText(item, 700)) : [],
241
303
  snippet: redactText(hit.snippet, SNIPPET_CHARS),
242
304
  };
243
305
  }
@@ -392,6 +454,10 @@ export function formatContextPack(pack) {
392
454
  ? `, linked via ${hit.via.join(', ')}`
393
455
  : '';
394
456
  lines.push(`- ${hit.path} (score ${hit.score.toFixed(2)}, ${hit.source}${suffix}): ${hit.snippet}`);
457
+ lines.push(` why selected: ${hit.rankReason || hit.visibilityReason || 'retrieval match'}`);
458
+ if ((hit.evidenceRefs || []).length > 0) {
459
+ lines.push(` evidence_refs: ${hit.evidenceRefs.slice(0, 3).join(', ')}`);
460
+ }
395
461
  }
396
462
  }
397
463
  if (pack.logExcerpt) {
@@ -1,4 +1,5 @@
1
1
  export const DURABLE_MEMORY_TYPES = new Set(['semantic', 'procedural']);
2
+ export const DURABLE_VISIBILITY_POLICY = 'durable-default-v1';
2
3
 
3
4
  export function frontmatterValues(value) {
4
5
  if (Array.isArray(value)) return value.map((item) => String(item || '').trim()).filter(Boolean);
@@ -60,11 +61,40 @@ export function includeEpisodicPages(options = {}) {
60
61
  return Boolean(options.includeEpisodic);
61
62
  }
62
63
 
64
+ export function wikiVisibility(page, options = {}) {
65
+ if (!includeArchivedPages(options) && isArchivedPage(page)) {
66
+ return { visible: false, reason: 'hidden: archived page; use --include-archived to inspect it' };
67
+ }
68
+ if (!includeArchivedPages(options) && isSupersededPage(page)) {
69
+ return { visible: false, reason: 'hidden: superseded page; use --include-archived to inspect it' };
70
+ }
71
+ if (includeEpisodicPages(options)) {
72
+ return { visible: true, reason: 'visible: --include-episodic includes episodic/context pages' };
73
+ }
74
+ if (isEpisodicLayerPage(page) && !isPromotedDurablePage(page)) {
75
+ return { visible: false, reason: 'hidden: default episodic query/context/session page' };
76
+ }
77
+ const memoryType = String(page?.memoryType || '').toLowerCase();
78
+ if (DURABLE_MEMORY_TYPES.has(memoryType)) {
79
+ return { visible: true, reason: `visible: durable ${memoryType} wiki page` };
80
+ }
81
+ if (isStalePage(page)) return { visible: true, reason: 'visible: stale page kept searchable with lower ranking' };
82
+ return { visible: true, reason: 'visible: durable non-episodic wiki page' };
83
+ }
84
+
63
85
  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;
86
+ return wikiVisibility(page, options).visible;
87
+ }
88
+
89
+ export function visibleWikiPages(pages, options = {}) {
90
+ const visible = [];
91
+ const hidden = [];
92
+ for (const page of pages) {
93
+ const visibility = wikiVisibility(page, options);
94
+ if (visibility.visible) visible.push({ page, visibility });
95
+ else hidden.push({ page, visibility });
96
+ }
97
+ return { visible, hidden, policy: DURABLE_VISIBILITY_POLICY };
68
98
  }
69
99
 
70
100
  export function searchWeight(page) {