llm-wiki-compiler 0.8.0 → 0.9.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.
@@ -7,6 +7,16 @@
7
7
  * timestamps, plus a "Warnings" block fed by `payload.warnings`
8
8
  * (parser issues, unresolved citations, malformed citation entries).
9
9
  *
10
+ * Freshness badges (STALE, ORPHANED, CONTRADICTED, ARCHIVED) are
11
+ * rendered from `payload.freshness`. The design rule: badge ONLY the
12
+ * two actionable source-freshness states (stale, orphaned) plus the
13
+ * two provenance flags (contradicted, archived). `fresh` and
14
+ * `unverified` get NO badge — badging neutral/default states would
15
+ * stamp nearly the whole wiki. A "Freshness as of <generatedAt>"
16
+ * caption is shown on every page (keyed on the always-present
17
+ * `generatedAt`), anchoring the displayed freshness to snapshot/server-
18
+ * start time because the viewer does not live-watch the filesystem.
19
+ *
10
20
  * Fields render only when the frontmatter actually carries a value, so
11
21
  * a legacy page with no provenance metadata shows a compact rail
12
22
  * rather than a wall of `(none)` rows. Labels mirror `review show`
@@ -58,9 +68,11 @@ export function renderSupportRail(payload) {
58
68
  const support = document.querySelector(SUPPORT_SELECTOR);
59
69
  if (!support) return;
60
70
  support.innerHTML = "";
71
+ appendFreshnessBadges(support, payload);
61
72
  appendFrontmatterDl(support, extractFrontmatter(payload));
62
73
  const warnings = extractWarnings(payload);
63
74
  if (warnings.length > 0) support.appendChild(buildRailWarnings(warnings));
75
+ appendFreshnessCaption(support, payload);
64
76
  }
65
77
 
66
78
  /** Clear the support rail entirely (used on non-page routes). */
@@ -255,3 +267,62 @@ function buildWarningItem(warning) {
255
267
  function warningText(safe) {
256
268
  return safe.message || safe.code || "";
257
269
  }
270
+
271
+ /**
272
+ * Badge specs: each entry is [modifier, label, predicate(freshness)].
273
+ * Only entries whose predicate is truthy produce a badge. Ordered by
274
+ * axis: source-freshness first (stale, orphaned), then provenance
275
+ * (contradicted, archived).
276
+ */
277
+ const BADGE_SPECS = [
278
+ ["stale", "STALE", (f) => f.freshnessStatus === "stale"],
279
+ ["orphaned", "ORPHANED", (f) => f.freshnessStatus === "orphaned"],
280
+ ["contradicted","CONTRADICTED",(f) => f.contradicted],
281
+ ["archived", "ARCHIVED", (f) => f.archived],
282
+ ];
283
+
284
+ /**
285
+ * Append freshness badges to the rail. Badges render ONLY for actionable
286
+ * states: STALE, ORPHANED (source-freshness axis) and CONTRADICTED,
287
+ * ARCHIVED (provenance axis). `fresh` and `unverified` get no badge —
288
+ * they are the neutral defaults and badging them would stamp nearly the
289
+ * whole wiki with noise.
290
+ */
291
+ function appendFreshnessBadges(support, payload) {
292
+ const freshness = payload?.freshness;
293
+ if (!freshness) return;
294
+ const wrap = buildFreshnessBadgeWrap(freshness);
295
+ if (wrap) support.appendChild(wrap);
296
+ }
297
+
298
+ /** Build the badges container from the freshness object, or null if no badges apply. */
299
+ function buildFreshnessBadgeWrap(freshness) {
300
+ const activeBadges = BADGE_SPECS.filter(([, , pred]) => pred(freshness));
301
+ if (activeBadges.length === 0) return null;
302
+ const wrap = document.createElement("div");
303
+ wrap.className = "freshness-badges";
304
+ for (const [modifier, label] of activeBadges) wrap.appendChild(buildBadge(modifier, label));
305
+ return wrap;
306
+ }
307
+
308
+ /** Build one badge `<span>` with a modifier class and text label. */
309
+ function buildBadge(modifier, label) {
310
+ const span = document.createElement("span");
311
+ span.className = `freshness-badge badge-${modifier}`;
312
+ span.textContent = label;
313
+ return span;
314
+ }
315
+
316
+ /**
317
+ * Append the "Freshness as of <generatedAt>" caption when the payload
318
+ * carries a generatedAt timestamp. Explicitly honest: the viewer
319
+ * does not live-watch the filesystem, so this caption anchors the
320
+ * freshness data to the server-start time rather than implying live state.
321
+ */
322
+ function appendFreshnessCaption(support, payload) {
323
+ if (!payload?.generatedAt) return;
324
+ const caption = document.createElement("p");
325
+ caption.className = "freshness-caption";
326
+ caption.textContent = `Freshness as of ${payload.generatedAt}`;
327
+ support.appendChild(caption);
328
+ }
@@ -11,12 +11,37 @@
11
11
  * includes `kind` so the grouping is correct from the first byte);
12
12
  * the full `/api/pages` envelope replaces the contents once it
13
13
  * arrives.
14
+ *
15
+ * A per-axis freshness filter (all / stale / orphaned / contradicted /
16
+ * archived) narrows the page list client-side over the already-loaded
17
+ * `/api/pages` rows. No new endpoint is needed — the filter is pure
18
+ * DOM manipulation over the in-memory page list.
14
19
  */
15
20
 
16
21
  const SIDEBAR_SELECTOR = "[data-sidebar]";
17
22
  const DEFAULT_KIND = "concept";
18
23
  const EMPTY_PLACEHOLDER_TEXT = "No pages yet — run `llmwiki compile`.";
19
24
 
25
+ /**
26
+ * CSS class on the standing "Project" section. Shared between
27
+ * `buildProjectSection` (which sets it) and `reRenderSidebarGroups`
28
+ * (whose keep-selector preserves it across filter re-renders) so a
29
+ * rename can't silently break the re-render keep-list.
30
+ */
31
+ const PROJECT_SECTION_CLASS = "sidebar-health";
32
+
33
+ /** The active freshness filter. "all" means no narrowing. */
34
+ let activeFreshnessFilter = "all";
35
+
36
+ /** Available filter values and their human labels. */
37
+ const FRESHNESS_FILTER_OPTIONS = [
38
+ { value: "all", label: "All" },
39
+ { value: "stale", label: "Stale" },
40
+ { value: "orphaned", label: "Orphaned" },
41
+ { value: "contradicted", label: "Contradicted" },
42
+ { value: "archived", label: "Archived" },
43
+ ];
44
+
20
45
  /**
21
46
  * Static (non-page) hash routes that have a dedicated sidebar link.
22
47
  * `markActive` highlights the entry via `a[data-route="<route>"]`
@@ -27,18 +52,43 @@ const STATIC_ROUTE_LINK_SELECTORS = new Map([
27
52
  ["#/health", 'a[data-route="health"]'],
28
53
  ]);
29
54
 
55
+ /** Full page list captured at the last renderSidebar call for filter re-renders. */
56
+ let lastPages = [];
57
+
30
58
  /** Render the sidebar groups + standing Health entry, then mark active. */
31
59
  export function renderSidebar(pages) {
60
+ lastPages = pages;
32
61
  const sidebar = document.querySelector(SIDEBAR_SELECTOR);
33
62
  if (!sidebar) return;
34
63
  sidebar.innerHTML = "";
35
64
  sidebar.appendChild(buildProjectSection());
36
- const concepts = filterByDirectory(pages, "concepts");
37
- const queries = filterByDirectory(pages, "queries");
65
+ sidebar.appendChild(buildFreshnessFilter());
66
+ renderFilteredGroups(sidebar, pages);
67
+ markActive();
68
+ }
69
+
70
+ /** Re-render only the page groups using the stored lastPages + active filter. */
71
+ function reRenderSidebarGroups() {
72
+ const sidebar = document.querySelector(SIDEBAR_SELECTOR);
73
+ if (!sidebar) return;
74
+ // Remove everything after the project section and filter control.
75
+ const keep = sidebar.querySelectorAll(`section.${PROJECT_SECTION_CLASS}, .freshness-filter`);
76
+ const keepSet = new Set(Array.from(keep));
77
+ Array.from(sidebar.children).forEach((child) => {
78
+ if (!keepSet.has(child)) child.remove();
79
+ });
80
+ renderFilteredGroups(sidebar, lastPages);
81
+ markActive();
82
+ }
83
+
84
+ /** Append the filtered concept/query groups and the empty placeholder if needed. */
85
+ function renderFilteredGroups(sidebar, pages) {
86
+ const filtered = applyFreshnessFilter(pages, activeFreshnessFilter);
87
+ const concepts = filterByDirectory(filtered, "concepts");
88
+ const queries = filterByDirectory(filtered, "queries");
38
89
  appendConceptGroups(sidebar, concepts);
39
90
  appendQueryGroup(sidebar, queries);
40
91
  appendEmptyPlaceholderIfNeeded(sidebar, concepts, queries);
41
- markActive();
42
92
  }
43
93
 
44
94
  /** Filter pages to those whose `pageDirectory` matches the given bucket. */
@@ -180,7 +230,7 @@ function buildPageListItem(page) {
180
230
  /** Build the standing "Project" sidebar section with Health and Graph links. */
181
231
  function buildProjectSection() {
182
232
  const wrap = document.createElement("section");
183
- wrap.className = "sidebar-health";
233
+ wrap.className = PROJECT_SECTION_CLASS;
184
234
  const heading = document.createElement("h2");
185
235
  heading.textContent = "Project";
186
236
  wrap.appendChild(heading);
@@ -219,3 +269,64 @@ function parseExpectedPageId(hash) {
219
269
  }
220
270
  return `${match[1]}/${slug}`;
221
271
  }
272
+
273
+ /**
274
+ * Build the freshness-filter `<div>` with a `<select>` control. The
275
+ * filter is client-side over the already-loaded page rows — no new
276
+ * endpoint, no query params.
277
+ */
278
+ function buildFreshnessFilter() {
279
+ const wrap = document.createElement("div");
280
+ wrap.className = "freshness-filter";
281
+ const label = document.createElement("label");
282
+ label.className = "freshness-filter-label";
283
+ label.setAttribute("for", "freshness-filter-select");
284
+ label.textContent = "Filter by freshness";
285
+ const select = document.createElement("select");
286
+ select.id = "freshness-filter-select";
287
+ select.className = "freshness-filter-select";
288
+ for (const { value, label: optLabel } of FRESHNESS_FILTER_OPTIONS) {
289
+ const option = document.createElement("option");
290
+ option.value = value;
291
+ option.textContent = optLabel;
292
+ if (value === activeFreshnessFilter) option.selected = true;
293
+ select.appendChild(option);
294
+ }
295
+ select.addEventListener("change", onFreshnessFilterChange);
296
+ wrap.appendChild(label);
297
+ wrap.appendChild(select);
298
+ return wrap;
299
+ }
300
+
301
+ /** Handle freshness filter selection change — update state and re-render. */
302
+ function onFreshnessFilterChange(event) {
303
+ activeFreshnessFilter = event.target.value;
304
+ reRenderSidebarGroups();
305
+ }
306
+
307
+ /**
308
+ * Lookup table: filter value → predicate over a freshness object.
309
+ * Keeps `matchesFreshnessFilter` branch-free.
310
+ */
311
+ const FRESHNESS_PREDICATES = {
312
+ stale: (f) => f.freshnessStatus === "stale",
313
+ orphaned: (f) => f.freshnessStatus === "orphaned",
314
+ contradicted:(f) => f.contradicted === true,
315
+ archived: (f) => f.archived === true,
316
+ };
317
+
318
+ /**
319
+ * Apply the active freshness filter to the page list. "all" returns all
320
+ * pages; other values match the corresponding freshness flag on each page.
321
+ */
322
+ function applyFreshnessFilter(pages, filter) {
323
+ if (filter === "all") return pages;
324
+ return pages.filter((page) => matchesFreshnessFilter(page, filter));
325
+ }
326
+
327
+ /** True when the page's freshness satisfies the active filter. */
328
+ function matchesFreshnessFilter(page, filter) {
329
+ const f = page.freshness;
330
+ const predicate = FRESHNESS_PREDICATES[filter];
331
+ return f != null && predicate != null && predicate(f);
332
+ }
@@ -314,6 +314,75 @@ a:hover, a:focus-visible { text-decoration: underline; }
314
314
  }
315
315
  }
316
316
 
317
+ /* Freshness badges — shown in the support rail for STALE, ORPHANED,
318
+ * CONTRADICTED, ARCHIVED pages. fresh/unverified get no badge. */
319
+ .freshness-badges {
320
+ display: flex;
321
+ flex-wrap: wrap;
322
+ gap: 0.35rem;
323
+ margin-bottom: 0.6rem;
324
+ }
325
+ .freshness-badge {
326
+ display: inline-block;
327
+ padding: 0.15rem 0.45rem;
328
+ border-radius: 999px;
329
+ font-size: 0.72rem;
330
+ font-weight: 700;
331
+ letter-spacing: 0.04em;
332
+ text-transform: uppercase;
333
+ }
334
+ .badge-stale { background: rgba(200, 90, 0, 0.12); color: #b85c00; }
335
+ .badge-orphaned { background: rgba(176, 0, 32, 0.10); color: var(--missing); }
336
+ .badge-contradicted { background: rgba(100, 0, 180, 0.10); color: #6400b4; }
337
+ .badge-archived { background: rgba(100, 100, 110, 0.12); color: var(--fg-muted); }
338
+ @media (prefers-color-scheme: dark) {
339
+ .badge-stale { background: rgba(255, 160, 60, 0.14); color: #ffa03c; }
340
+ .badge-contradicted { background: rgba(180, 100, 255, 0.14); color: #b464ff; }
341
+ }
342
+
343
+ /* Freshness caption — "Freshness as of <timestamp>" — always shown
344
+ * when freshness data is present to be honest about the frozen snapshot. */
345
+ .freshness-caption {
346
+ font-size: 0.76rem;
347
+ color: var(--fg-muted);
348
+ margin-top: 0.75rem;
349
+ font-style: italic;
350
+ }
351
+
352
+ /* Corrupt-state banner — shown globally (and in the health pane) when stateStatus === "corrupt". */
353
+ .corrupt-state-banner {
354
+ background: rgba(176, 0, 32, 0.08);
355
+ color: var(--missing);
356
+ border: 1px solid rgba(176, 0, 32, 0.2);
357
+ padding: 0.6rem 0.85rem;
358
+ border-radius: 6px;
359
+ margin-bottom: 1rem;
360
+ font-weight: 500;
361
+ }
362
+
363
+ /* Freshness filter — sidebar select control for filtering by freshness axis. */
364
+ .freshness-filter {
365
+ display: flex;
366
+ flex-direction: column;
367
+ gap: 0.25rem;
368
+ margin: 0.5rem 0 0.75rem;
369
+ }
370
+ .freshness-filter-label {
371
+ font-size: 0.72rem;
372
+ text-transform: uppercase;
373
+ letter-spacing: 0.05em;
374
+ color: var(--fg-muted);
375
+ }
376
+ .freshness-filter-select {
377
+ width: 100%;
378
+ padding: 0.3rem 0.5rem;
379
+ background: var(--bg);
380
+ color: var(--fg);
381
+ border: 1px solid var(--border);
382
+ border-radius: 6px;
383
+ font-size: 0.9rem;
384
+ }
385
+
317
386
  /* Graph pane */
318
387
  .graph-pane {
319
388
  width: 100%;
@@ -57,6 +57,8 @@ const HEALTH_METRIC_ROWS = [
57
57
  ["Saved queries", "queries"],
58
58
  ["Compiled sources", "sources"],
59
59
  ["Source files", "sourceFiles"],
60
+ ["Stale pages", "stale"],
61
+ ["Orphaned pages", "orphaned"],
60
62
  ["Pending reviews", "pendingReviews"],
61
63
  ];
62
64
 
@@ -268,6 +270,10 @@ async function renderHealthPane(main) {
268
270
  function buildHealthDashboard(health) {
269
271
  const wrap = document.createElement("section");
270
272
  wrap.className = "health-dashboard";
273
+ // The global banner (injected at bootstrap) covers every route including health;
274
+ // only add it here if bootstrap didn't already inject one (e.g. if /api/pages
275
+ // was not yet fetched when navigating directly to #/health).
276
+ prependBannerIfNeeded(wrap, health?.stateStatus);
271
277
  const rows = HEALTH_METRIC_ROWS.map(([label, key]) => [label, health?.[key] ?? 0]);
272
278
  const metrics = buildDefinitionList(rows);
273
279
  metrics.className = "metric-list";
@@ -276,6 +282,27 @@ function buildHealthDashboard(health) {
276
282
  return wrap;
277
283
  }
278
284
 
285
+ /** Prepend a corrupt-state banner to `container` if one is not already in the document. */
286
+ function prependBannerIfNeeded(container, stateStatus) {
287
+ if (stateStatus !== "corrupt") return;
288
+ if (document.querySelector(".corrupt-state-banner")) return;
289
+ container.prepend(buildCorruptStateBanner());
290
+ }
291
+
292
+ /**
293
+ * Build the corrupt-state warning banner. Displayed when `/api/health`
294
+ * reports `stateStatus === "corrupt"`, meaning the project's state.json
295
+ * could not be parsed at viewer startup and freshness data is unreliable.
296
+ */
297
+ function buildCorruptStateBanner() {
298
+ const banner = document.createElement("div");
299
+ banner.className = "corrupt-state-banner";
300
+ banner.setAttribute("role", "alert");
301
+ banner.textContent =
302
+ "Warning: state.json is corrupt. Freshness data is unavailable. Re-run `llmwiki compile` to restore.";
303
+ return banner;
304
+ }
305
+
279
306
  /** Render the lint summary, or a "lint has not been run yet" placeholder. */
280
307
  function buildLintBlock(lint) {
281
308
  const wrap = document.createElement("section");
@@ -305,6 +332,21 @@ function applyHomeEnvelope(envelope) {
305
332
  titleEl.textContent = projectTitle(envelope);
306
333
  renderSidebar(envelope?.pages || []);
307
334
  renderHome(envelope);
335
+ // Inject into .app-layout (outside <main>) so the banner persists across route changes.
336
+ injectGlobalCorruptBanner(envelope?.stateStatus);
337
+ }
338
+
339
+ /**
340
+ * Inject the corrupt-state banner into the app-layout container (above `main`)
341
+ * so it persists across route changes. Runs once at app bootstrap from the
342
+ * /api/pages envelope. No-ops when not corrupt or already injected.
343
+ */
344
+ function injectGlobalCorruptBanner(stateStatus) {
345
+ if (stateStatus !== "corrupt") return;
346
+ if (document.querySelector(".corrupt-state-banner")) return;
347
+ const layout = document.querySelector(".app-layout");
348
+ if (!layout) return;
349
+ layout.prepend(buildCorruptStateBanner());
308
350
  }
309
351
 
310
352
  /** Fetch /api/index and render the rendered HTML coming back from the server. */
@@ -467,6 +509,12 @@ function main() {
467
509
  const embedded = readEmbeddedIndex();
468
510
  renderSidebar(embedded.pages);
469
511
  wireSearch({ fetchJson });
512
+ // Ensure the corrupt-state banner appears on every entry route, not just home.
513
+ // injectGlobalCorruptBanner is idempotent, so the home route's own /api/pages
514
+ // fetch won't double-render if both settle.
515
+ void fetchJson("/api/pages")
516
+ .then((env) => injectGlobalCorruptBanner(env?.stateStatus))
517
+ .catch(() => {});
470
518
  window.addEventListener("hashchange", () => {
471
519
  void renderRoute();
472
520
  });
package/package.json CHANGED
@@ -1,8 +1,17 @@
1
1
  {
2
2
  "name": "llm-wiki-compiler",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "A knowledge compiler CLI — raw sources in, interlinked wiki out",
5
5
  "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ },
13
+ "./package.json": "./package.json"
14
+ },
6
15
  "bin": {
7
16
  "llmwiki": "dist/cli.js"
8
17
  },
@@ -16,7 +25,8 @@
16
25
  "test:watch": "vitest",
17
26
  "fallow:ci": "bash scripts/fallow-ci.sh",
18
27
  "prepublishOnly": "npm run release:check-docs:current && npm run build && npm pack --dry-run --ignore-scripts",
19
- "prepare": "husky"
28
+ "prepare": "husky",
29
+ "test:pack": "vitest run --config /dev/null test/sdk/packaging.test.ts"
20
30
  },
21
31
  "keywords": [
22
32
  "knowledge",
@@ -46,7 +56,8 @@
46
56
  "node": ">=24"
47
57
  },
48
58
  "dependencies": {
49
- "@anthropic-ai/sdk": "^0.39.0",
59
+ "@anthropic-ai/claude-agent-sdk": "^0.3.163",
60
+ "@anthropic-ai/sdk": "^0.101.0",
50
61
  "@modelcontextprotocol/sdk": "^1.29.0",
51
62
  "@mozilla/readability": "^0.5.0",
52
63
  "chokidar": "^4.0.0",
@@ -55,13 +66,13 @@
55
66
  "js-yaml": "^4.1.1",
56
67
  "jsdom": "^25.0.0",
57
68
  "markdown-it": "^14.1.1",
58
- "openai": "^4.0.0",
69
+ "openai": "^6.42.0",
59
70
  "p-limit": "^6.0.0",
60
71
  "pdf-parse": "^2.4.5",
61
72
  "sanitize-html": "^2.17.3",
62
73
  "turndown": "^7.2.0",
63
74
  "youtube-transcript": "^1.3.1",
64
- "zod": "^3.25.76"
75
+ "zod": "^4.4.3"
65
76
  },
66
77
  "devDependencies": {
67
78
  "@copilotkit/aimock": "^1.15.1",