harper-knowledge 0.1.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.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +276 -0
  3. package/config.yaml +17 -0
  4. package/dist/core/embeddings.d.ts +29 -0
  5. package/dist/core/embeddings.js +199 -0
  6. package/dist/core/entries.d.ts +85 -0
  7. package/dist/core/entries.js +235 -0
  8. package/dist/core/history.d.ts +30 -0
  9. package/dist/core/history.js +119 -0
  10. package/dist/core/search.d.ts +23 -0
  11. package/dist/core/search.js +306 -0
  12. package/dist/core/tags.d.ts +32 -0
  13. package/dist/core/tags.js +76 -0
  14. package/dist/core/triage.d.ts +55 -0
  15. package/dist/core/triage.js +126 -0
  16. package/dist/http-utils.d.ts +37 -0
  17. package/dist/http-utils.js +132 -0
  18. package/dist/index.d.ts +21 -0
  19. package/dist/index.js +76 -0
  20. package/dist/mcp/server.d.ts +24 -0
  21. package/dist/mcp/server.js +124 -0
  22. package/dist/mcp/tools.d.ts +13 -0
  23. package/dist/mcp/tools.js +497 -0
  24. package/dist/oauth/authorize.d.ts +27 -0
  25. package/dist/oauth/authorize.js +438 -0
  26. package/dist/oauth/github.d.ts +28 -0
  27. package/dist/oauth/github.js +62 -0
  28. package/dist/oauth/keys.d.ts +33 -0
  29. package/dist/oauth/keys.js +100 -0
  30. package/dist/oauth/metadata.d.ts +21 -0
  31. package/dist/oauth/metadata.js +55 -0
  32. package/dist/oauth/middleware.d.ts +22 -0
  33. package/dist/oauth/middleware.js +64 -0
  34. package/dist/oauth/register.d.ts +14 -0
  35. package/dist/oauth/register.js +83 -0
  36. package/dist/oauth/token.d.ts +15 -0
  37. package/dist/oauth/token.js +178 -0
  38. package/dist/oauth/validate.d.ts +30 -0
  39. package/dist/oauth/validate.js +52 -0
  40. package/dist/resources/HistoryResource.d.ts +38 -0
  41. package/dist/resources/HistoryResource.js +38 -0
  42. package/dist/resources/KnowledgeEntryResource.d.ts +64 -0
  43. package/dist/resources/KnowledgeEntryResource.js +157 -0
  44. package/dist/resources/QueryLogResource.d.ts +20 -0
  45. package/dist/resources/QueryLogResource.js +57 -0
  46. package/dist/resources/ServiceKeyResource.d.ts +51 -0
  47. package/dist/resources/ServiceKeyResource.js +132 -0
  48. package/dist/resources/TagResource.d.ts +25 -0
  49. package/dist/resources/TagResource.js +32 -0
  50. package/dist/resources/TriageResource.d.ts +51 -0
  51. package/dist/resources/TriageResource.js +107 -0
  52. package/dist/types.d.ts +317 -0
  53. package/dist/types.js +7 -0
  54. package/dist/webhooks/datadog.d.ts +26 -0
  55. package/dist/webhooks/datadog.js +120 -0
  56. package/dist/webhooks/github.d.ts +24 -0
  57. package/dist/webhooks/github.js +167 -0
  58. package/dist/webhooks/middleware.d.ts +14 -0
  59. package/dist/webhooks/middleware.js +161 -0
  60. package/dist/webhooks/types.d.ts +17 -0
  61. package/dist/webhooks/types.js +4 -0
  62. package/package.json +72 -0
  63. package/schema/knowledge.graphql +134 -0
  64. package/web/index.html +735 -0
  65. package/web/js/app.js +461 -0
  66. package/web/js/detail.js +223 -0
  67. package/web/js/editor.js +303 -0
  68. package/web/js/search.js +238 -0
  69. package/web/js/triage.js +305 -0
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Harper Knowledge Base — Entry Editor
3
+ *
4
+ * Create and edit knowledge base entries.
5
+ * Handles form rendering, validation, and API submission.
6
+ */
7
+
8
+ import { api, auth, navigate, escapeHtml, showLoginModal } from "./app.js";
9
+
10
+ /**
11
+ * Render the editor page.
12
+ * @param {HTMLElement} container
13
+ * @param {string|null} entryId - Existing entry ID for edit mode, null for create
14
+ */
15
+ export async function render(container, entryId) {
16
+ const isEdit = !!entryId;
17
+ let existing = null;
18
+ let triageData = null;
19
+
20
+ if (isEdit) {
21
+ container.innerHTML = '<div class="loading">Loading entry...</div>';
22
+ try {
23
+ existing = await api.get(`/Knowledge/${encodeURIComponent(entryId)}`);
24
+ if (!existing || existing.error) {
25
+ container.innerHTML = `<div class="empty-state"><h2>Entry not found</h2><p>Cannot edit: entry ${escapeHtml(entryId)} does not exist.</p></div>`;
26
+ return;
27
+ }
28
+ } catch (err) {
29
+ container.innerHTML = `<div class="status-message error">Failed to load entry: ${escapeHtml(err.message)}</div>`;
30
+ return;
31
+ }
32
+ }
33
+
34
+ // Check for triage accept data (pre-populate from triage queue)
35
+ if (!isEdit) {
36
+ try {
37
+ const raw = sessionStorage.getItem("triage-accept");
38
+ if (raw) {
39
+ triageData = JSON.parse(raw);
40
+ sessionStorage.removeItem("triage-accept");
41
+ // Pre-populate existing with triage summary
42
+ existing = {
43
+ title: triageData.summary || "",
44
+ content: triageData.summary || "",
45
+ source: triageData.source || "",
46
+ confidence: "ai-generated",
47
+ };
48
+ }
49
+ } catch {
50
+ // Ignore parse errors
51
+ }
52
+ }
53
+
54
+ container.innerHTML = renderForm(isEdit, existing, triageData);
55
+ wireEvents(container, isEdit, entryId, triageData);
56
+ }
57
+
58
+ function renderForm(isEdit, entry, triageData) {
59
+ const e = entry || {};
60
+
61
+ const triageBanner = triageData
62
+ ? `<div class="status-message" style="background: var(--amber-bg); color: var(--amber); border: 1px solid var(--amber); margin-bottom: 16px;">
63
+ Creating entry from triage item. Review and adjust the fields below, then submit.
64
+ </div>`
65
+ : "";
66
+
67
+ return `
68
+ <h2 style="margin-bottom: 20px;">${isEdit ? "Edit Entry" : "Add New Entry"}</h2>
69
+
70
+ ${triageBanner}
71
+ <div id="editor-status"></div>
72
+
73
+ <div class="form-group">
74
+ <label for="ed-title">Title</label>
75
+ <input type="text" id="ed-title" value="${escapeHtml(e.title || "")}" placeholder="Brief descriptive title">
76
+ </div>
77
+
78
+ <div class="form-group">
79
+ <label for="ed-content">Content</label>
80
+ <textarea id="ed-content" placeholder="Full knowledge entry content (supports plain text; markdown can be added later)">${escapeHtml(e.content || "")}</textarea>
81
+ </div>
82
+
83
+ <div class="form-row">
84
+ <div class="form-group">
85
+ <label for="ed-tags">Tags (comma-separated)</label>
86
+ <input type="text" id="ed-tags" value="${escapeHtml((e.tags || []).join(", "))}" placeholder="e.g., performance, clustering, lmdb">
87
+ <div id="ed-tag-chips" class="tags" style="margin-top: 6px;"></div>
88
+ </div>
89
+ <div class="form-group">
90
+ <label for="ed-confidence">Confidence</label>
91
+ <select id="ed-confidence">
92
+ <option value="ai-generated" ${e.confidence === "ai-generated" ? "selected" : ""}>ai-generated</option>
93
+ <option value="reviewed" ${e.confidence === "reviewed" ? "selected" : ""}>reviewed</option>
94
+ <option value="verified" ${e.confidence === "verified" ? "selected" : ""}>verified</option>
95
+ </select>
96
+ </div>
97
+ </div>
98
+
99
+ <div class="collapsible-header open" id="applies-toggle">
100
+ <span class="arrow" style="transform: rotate(90deg);">&#9654;</span>
101
+ Applicability
102
+ </div>
103
+ <div class="collapsible-body open" id="applies-panel">
104
+ <div class="form-row" style="margin-bottom: 12px;">
105
+ <div class="form-group">
106
+ <label for="ed-harper">Harper Version (semver range)</label>
107
+ <input type="text" id="ed-harper" value="${escapeHtml(e.appliesTo?.harper || "")}" placeholder="e.g., >=4.6.0">
108
+ </div>
109
+ <div class="form-group">
110
+ <label for="ed-storage">Storage Engine</label>
111
+ <select id="ed-storage">
112
+ <option value="">Any</option>
113
+ <option value="lmdb" ${e.appliesTo?.storageEngine === "lmdb" ? "selected" : ""}>lmdb</option>
114
+ <option value="rocksdb" ${e.appliesTo?.storageEngine === "rocksdb" ? "selected" : ""}>rocksdb</option>
115
+ </select>
116
+ </div>
117
+ </div>
118
+ <div class="form-row">
119
+ <div class="form-group">
120
+ <label for="ed-node">Node.js Version</label>
121
+ <input type="text" id="ed-node" value="${escapeHtml(e.appliesTo?.node || "")}" placeholder="e.g., >=22.0.0">
122
+ </div>
123
+ <div class="form-group">
124
+ <label for="ed-platform">Platform</label>
125
+ <select id="ed-platform">
126
+ <option value="">Any</option>
127
+ <option value="linux" ${e.appliesTo?.platform === "linux" ? "selected" : ""}>linux</option>
128
+ <option value="darwin" ${e.appliesTo?.platform === "darwin" ? "selected" : ""}>darwin</option>
129
+ <option value="win32" ${e.appliesTo?.platform === "win32" ? "selected" : ""}>win32</option>
130
+ </select>
131
+ </div>
132
+ </div>
133
+ </div>
134
+
135
+ <div class="form-row" style="margin-top: 16px;">
136
+ <div class="form-group">
137
+ <label for="ed-source">Source</label>
138
+ <input type="text" id="ed-source" value="${escapeHtml(e.source || "")}" placeholder="e.g., support-ticket, documentation, slack">
139
+ </div>
140
+ <div class="form-group">
141
+ <label for="ed-source-url">Source URL</label>
142
+ <input type="url" id="ed-source-url" value="${escapeHtml(e.sourceUrl || "")}" placeholder="https://...">
143
+ </div>
144
+ </div>
145
+
146
+ <div class="form-group">
147
+ <label for="ed-supersedes">Supersedes Entry ID</label>
148
+ <input type="text" id="ed-supersedes" value="${escapeHtml(e.supersedesId || "")}" placeholder="ID of the entry this supersedes (optional)">
149
+ </div>
150
+
151
+ <div style="display: flex; gap: 8px; margin-top: 24px;">
152
+ <button id="ed-submit" class="primary">${isEdit ? "Update Entry" : "Create Entry"}</button>
153
+ <button id="ed-cancel">Cancel</button>
154
+ </div>
155
+ `;
156
+ }
157
+
158
+ function wireEvents(container, isEdit, entryId, triageData) {
159
+ const tagsInput = container.querySelector("#ed-tags");
160
+ const tagChips = container.querySelector("#ed-tag-chips");
161
+ const submitBtn = container.querySelector("#ed-submit");
162
+ const cancelBtn = container.querySelector("#ed-cancel");
163
+ const appliesToggle = container.querySelector("#applies-toggle");
164
+ const appliesPanel = container.querySelector("#applies-panel");
165
+ const statusDiv = container.querySelector("#editor-status");
166
+
167
+ // Tag chips preview
168
+ const updateTagChips = () => {
169
+ const tags = parseTags(tagsInput.value);
170
+ tagChips.innerHTML = tags
171
+ .map((t) => `<span class="tag">${escapeHtml(t)}</span>`)
172
+ .join("");
173
+ };
174
+ tagsInput.addEventListener("input", updateTagChips);
175
+ updateTagChips();
176
+
177
+ // Collapsible applicability
178
+ appliesToggle.addEventListener("click", () => {
179
+ appliesToggle.classList.toggle("open");
180
+ appliesPanel.classList.toggle("open");
181
+ const arrow = appliesToggle.querySelector(".arrow");
182
+ arrow.style.transform = appliesToggle.classList.contains("open")
183
+ ? "rotate(90deg)"
184
+ : "";
185
+ });
186
+
187
+ // Cancel
188
+ cancelBtn.addEventListener("click", () => {
189
+ if (isEdit) {
190
+ navigate("entry", entryId);
191
+ } else {
192
+ navigate("search");
193
+ }
194
+ });
195
+
196
+ // Submit
197
+ submitBtn.addEventListener("click", async () => {
198
+ if (!auth.authenticated) {
199
+ showLoginModal();
200
+ return;
201
+ }
202
+
203
+ // Validate
204
+ const title = container.querySelector("#ed-title").value.trim();
205
+ const content = container.querySelector("#ed-content").value.trim();
206
+
207
+ if (!title) {
208
+ statusDiv.innerHTML =
209
+ '<div class="status-message error">Title is required.</div>';
210
+ return;
211
+ }
212
+ if (!content) {
213
+ statusDiv.innerHTML =
214
+ '<div class="status-message error">Content is required.</div>';
215
+ return;
216
+ }
217
+
218
+ // Build payload
219
+ const payload = {
220
+ title,
221
+ content,
222
+ tags: parseTags(container.querySelector("#ed-tags").value),
223
+ confidence: container.querySelector("#ed-confidence").value,
224
+ source: container.querySelector("#ed-source").value.trim() || undefined,
225
+ sourceUrl:
226
+ container.querySelector("#ed-source-url").value.trim() || undefined,
227
+ };
228
+
229
+ // Applicability
230
+ const appliesTo = {};
231
+ const harper = container.querySelector("#ed-harper").value.trim();
232
+ const storage = container.querySelector("#ed-storage").value;
233
+ const node = container.querySelector("#ed-node").value.trim();
234
+ const platform = container.querySelector("#ed-platform").value;
235
+
236
+ if (harper) appliesTo.harper = harper;
237
+ if (storage) appliesTo.storageEngine = storage;
238
+ if (node) appliesTo.node = node;
239
+ if (platform) appliesTo.platform = platform;
240
+
241
+ if (Object.keys(appliesTo).length > 0) {
242
+ payload.appliesTo = appliesTo;
243
+ }
244
+
245
+ // Supersedes
246
+ const supersedesId = container.querySelector("#ed-supersedes").value.trim();
247
+ if (supersedesId) {
248
+ payload.supersedesId = supersedesId;
249
+ }
250
+
251
+ // Submit
252
+ submitBtn.disabled = true;
253
+ submitBtn.textContent = isEdit ? "Updating..." : "Creating...";
254
+ statusDiv.innerHTML = "";
255
+
256
+ try {
257
+ let result;
258
+ if (isEdit) {
259
+ result = await api.put(
260
+ `/Knowledge/${encodeURIComponent(entryId)}`,
261
+ payload,
262
+ );
263
+ } else {
264
+ result = await api.post("/Knowledge/", payload);
265
+ }
266
+
267
+ const newId = result?.id || entryId;
268
+
269
+ // If this was a triage accept, update the triage item
270
+ if (triageData?.triageId && !isEdit) {
271
+ try {
272
+ await api.put(`/Triage/${encodeURIComponent(triageData.triageId)}`, {
273
+ action: "accepted",
274
+ linkedEntryId: newId,
275
+ });
276
+ } catch (triageErr) {
277
+ console.error("Failed to update triage item:", triageErr);
278
+ // Non-fatal: the entry was still created
279
+ }
280
+ }
281
+
282
+ statusDiv.innerHTML = `<div class="status-message success">${isEdit ? "Entry updated" : "Entry created"} successfully.</div>`;
283
+
284
+ // Navigate to the entry after a short delay
285
+ setTimeout(() => {
286
+ navigate("entry", newId);
287
+ }, 800);
288
+ } catch (err) {
289
+ console.error("Save error:", err);
290
+ statusDiv.innerHTML = `<div class="status-message error">Failed to save: ${escapeHtml(err.message)}</div>`;
291
+ submitBtn.disabled = false;
292
+ submitBtn.textContent = isEdit ? "Update Entry" : "Create Entry";
293
+ }
294
+ });
295
+ }
296
+
297
+ function parseTags(input) {
298
+ if (!input) return [];
299
+ return input
300
+ .split(",")
301
+ .map((t) => t.trim())
302
+ .filter((t) => t.length > 0);
303
+ }
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Harper Knowledge Base — Search & Browse Page
3
+ *
4
+ * Text search with tag filters, applicability context,
5
+ * and result display.
6
+ */
7
+
8
+ import { api, escapeHtml, confidenceBadge, tagChips, truncate } from "./app.js";
9
+
10
+ let allTags = [];
11
+ let activeTags = [];
12
+ let lastResults = [];
13
+
14
+ /**
15
+ * Render the search page.
16
+ * @param {HTMLElement} container
17
+ * @param {boolean} browseMode - If true, show all entries instead of search
18
+ */
19
+ export async function render(container, browseMode = false) {
20
+ container.innerHTML = `
21
+ <div class="search-bar">
22
+ <input type="text" id="search-input" placeholder="Search the knowledge base..." autofocus>
23
+ <button id="search-btn" class="primary">Search</button>
24
+ </div>
25
+
26
+ <div id="tag-filter" class="tags" style="margin-bottom: 12px;"></div>
27
+
28
+ <div class="collapsible-header" id="context-toggle">
29
+ <span class="arrow">&#9654;</span>
30
+ Applicability Context
31
+ </div>
32
+ <div class="collapsible-body" id="context-panel">
33
+ <div class="form-row" style="margin-bottom: 12px;">
34
+ <div class="form-group">
35
+ <label for="ctx-harper">Harper Version</label>
36
+ <input type="text" id="ctx-harper" placeholder="e.g., 4.6.0">
37
+ </div>
38
+ <div class="form-group">
39
+ <label for="ctx-storage">Storage Engine</label>
40
+ <select id="ctx-storage">
41
+ <option value="">Any</option>
42
+ <option value="lmdb">lmdb</option>
43
+ <option value="rocksdb">rocksdb</option>
44
+ </select>
45
+ </div>
46
+ </div>
47
+ <div class="form-row">
48
+ <div class="form-group">
49
+ <label for="ctx-node">Node.js Version</label>
50
+ <input type="text" id="ctx-node" placeholder="e.g., 22.0.0">
51
+ </div>
52
+ <div class="form-group">
53
+ <label for="ctx-platform">Platform</label>
54
+ <select id="ctx-platform">
55
+ <option value="">Any</option>
56
+ <option value="linux">linux</option>
57
+ <option value="darwin">darwin</option>
58
+ <option value="win32">win32</option>
59
+ </select>
60
+ </div>
61
+ </div>
62
+ </div>
63
+
64
+ <div id="results"></div>
65
+ `;
66
+
67
+ // Load tags
68
+ await loadTags(container);
69
+
70
+ // Wire up events
71
+ const searchInput = container.querySelector("#search-input");
72
+ const searchBtn = container.querySelector("#search-btn");
73
+ const contextToggle = container.querySelector("#context-toggle");
74
+ const contextPanel = container.querySelector("#context-panel");
75
+
76
+ searchBtn.addEventListener("click", () => doSearch(container));
77
+ searchInput.addEventListener("keydown", (e) => {
78
+ if (e.key === "Enter") doSearch(container);
79
+ });
80
+
81
+ contextToggle.addEventListener("click", () => {
82
+ contextToggle.classList.toggle("open");
83
+ contextPanel.classList.toggle("open");
84
+ });
85
+
86
+ // If browse mode, search with empty query to list entries
87
+ if (browseMode) {
88
+ searchInput.placeholder = "Filter entries...";
89
+ searchInput.value = "";
90
+ doSearch(container, true);
91
+ }
92
+ }
93
+
94
+ async function loadTags(container) {
95
+ try {
96
+ const data = await api.get("/KnowledgeTag/");
97
+ allTags = Array.isArray(data) ? data : [];
98
+ } catch {
99
+ allTags = [];
100
+ }
101
+
102
+ const tagFilter = container.querySelector("#tag-filter");
103
+ if (!tagFilter) return;
104
+
105
+ if (allTags.length === 0) {
106
+ tagFilter.innerHTML = "";
107
+ return;
108
+ }
109
+
110
+ tagFilter.innerHTML = allTags
111
+ .map(
112
+ (t) =>
113
+ `<span class="tag" data-tag="${escapeHtml(t.id)}" title="${escapeHtml(t.description || "")}">${escapeHtml(t.id)} <span style="opacity:0.5;">${t.entryCount || 0}</span></span>`,
114
+ )
115
+ .join("");
116
+
117
+ tagFilter.addEventListener("click", (e) => {
118
+ const tagEl = e.target.closest(".tag");
119
+ if (!tagEl) return;
120
+ const tagName = tagEl.dataset.tag;
121
+
122
+ if (activeTags.includes(tagName)) {
123
+ activeTags = activeTags.filter((t) => t !== tagName);
124
+ tagEl.classList.remove("active");
125
+ } else {
126
+ activeTags.push(tagName);
127
+ tagEl.classList.add("active");
128
+ }
129
+ });
130
+ }
131
+
132
+ async function doSearch(container, browseMode = false) {
133
+ const resultsDiv = container.querySelector("#results");
134
+ const query = container.querySelector("#search-input")?.value?.trim();
135
+
136
+ if (!query && !browseMode && activeTags.length === 0) {
137
+ resultsDiv.innerHTML = `<div class="empty-state"><h2>Enter a search query</h2><p>Type something to search the knowledge base, or switch to Browse to see all entries.</p></div>`;
138
+ return;
139
+ }
140
+
141
+ resultsDiv.innerHTML = '<div class="loading">Searching...</div>';
142
+
143
+ try {
144
+ // Build query params
145
+ const params = new URLSearchParams();
146
+ if (query) params.set("query", query);
147
+ if (activeTags.length > 0) params.set("tags", activeTags.join(","));
148
+
149
+ // Applicability context
150
+ const context = buildContext(container);
151
+ if (Object.keys(context).length > 0) {
152
+ params.set("context", JSON.stringify(context));
153
+ }
154
+
155
+ params.set("limit", "30");
156
+
157
+ const searchQuery = params.toString();
158
+
159
+ // If no query and browse mode, we still need a query param for the API
160
+ // The API requires a query, so for browse mode use a wildcard or similar
161
+ let data;
162
+ if (!query && browseMode) {
163
+ // For browsing without search, use a broad query
164
+ params.set("query", "*");
165
+ data = await api.get("/Knowledge/?" + params.toString());
166
+ } else {
167
+ data = await api.get("/Knowledge/?" + searchQuery);
168
+ }
169
+
170
+ lastResults = Array.isArray(data) ? data : [];
171
+
172
+ if (lastResults.length === 0) {
173
+ resultsDiv.innerHTML = `<div class="empty-state"><h2>No results found</h2><p>Try adjusting your search query or filters.</p></div>`;
174
+ return;
175
+ }
176
+
177
+ resultsDiv.innerHTML = `
178
+ <p style="color: var(--text-dim); font-size: 12px; margin-bottom: 12px;">${lastResults.length} result${lastResults.length === 1 ? "" : "s"}</p>
179
+ ${lastResults.map(renderResultCard).join("")}
180
+ `;
181
+ } catch (err) {
182
+ console.error("Search error:", err);
183
+ resultsDiv.innerHTML = `<div class="status-message error">Search failed: ${escapeHtml(err.message)}</div>`;
184
+ }
185
+ }
186
+
187
+ function buildContext(container) {
188
+ const context = {};
189
+ const harper = container.querySelector("#ctx-harper")?.value?.trim();
190
+ const storage = container.querySelector("#ctx-storage")?.value;
191
+ const node = container.querySelector("#ctx-node")?.value?.trim();
192
+ const platform = container.querySelector("#ctx-platform")?.value;
193
+
194
+ if (harper) context.harper = harper;
195
+ if (storage) context.storageEngine = storage;
196
+ if (node) context.node = node;
197
+ if (platform) context.platform = platform;
198
+
199
+ return context;
200
+ }
201
+
202
+ function renderResultCard(entry) {
203
+ const deprecatedClass = entry.deprecated ? " deprecated" : "";
204
+ const snippet = truncate(entry.content, 180);
205
+ const scoreStr =
206
+ entry.score != null
207
+ ? `<span class="score">${entry.score.toFixed(3)}</span>`
208
+ : "";
209
+ const matchStr = entry.matchType
210
+ ? `<span class="score">${escapeHtml(entry.matchType)}</span>`
211
+ : "";
212
+
213
+ return `
214
+ <div class="card${deprecatedClass}">
215
+ <div class="card-title">
216
+ <a href="#entry/${escapeHtml(entry.id)}">${escapeHtml(entry.title)}</a>
217
+ </div>
218
+ <div class="card-snippet">${escapeHtml(snippet)}</div>
219
+ <div class="card-meta">
220
+ ${confidenceBadge(entry.confidence)}
221
+ ${entry.tags?.length ? '<span class="sep">|</span>' + tagChips(entry.tags, activeTags) : ""}
222
+ ${entry.appliesTo ? renderAppliesTo(entry.appliesTo) : ""}
223
+ ${scoreStr || matchStr ? '<span class="sep">|</span>' + scoreStr + " " + matchStr : ""}
224
+ </div>
225
+ </div>
226
+ `;
227
+ }
228
+
229
+ function renderAppliesTo(appliesTo) {
230
+ if (!appliesTo) return "";
231
+ const parts = [];
232
+ if (appliesTo.harper) parts.push(`harper ${escapeHtml(appliesTo.harper)}`);
233
+ if (appliesTo.storageEngine) parts.push(escapeHtml(appliesTo.storageEngine));
234
+ if (appliesTo.node) parts.push(`node ${escapeHtml(appliesTo.node)}`);
235
+ if (appliesTo.platform) parts.push(escapeHtml(appliesTo.platform));
236
+ if (parts.length === 0) return "";
237
+ return `<span class="sep">|</span><span class="score">${parts.join(", ")}</span>`;
238
+ }