sdtk-brain-kit 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.
@@ -0,0 +1,1313 @@
1
+ "use strict";
2
+
3
+ const crypto = require("crypto");
4
+ const fs = require("fs");
5
+ const path = require("path");
6
+ const { CliError, ValidationError } = require("./errors");
7
+ const {
8
+ assertWikiWorkspaceWritePath,
9
+ getWikiReportsPath,
10
+ resolveProjectPath,
11
+ } = require("./wiki-paths");
12
+
13
+ const REPORT_PREFIX = "semantic-extraction-dry-run";
14
+ const SCHEMA_VERSION = 1;
15
+ const EXCLUDE_FRAGS = [
16
+ ".git",
17
+ ".brain",
18
+ ".brain-legacy-unused",
19
+ "node_modules",
20
+ ".venv",
21
+ "venv",
22
+ "dist",
23
+ "build",
24
+ "coverage",
25
+ ".next",
26
+ ".turbo",
27
+ ".cache",
28
+ "__pycache__",
29
+ ];
30
+
31
+ const CONCEPT_RULES = [
32
+ {
33
+ concept_id: "concept_self_hosted_project_management",
34
+ name: "self-hosted project management",
35
+ aliases: ["Trello alternative", "realtime collaboration", "project management"],
36
+ keywords: ["trello", "jira", "project management", "realtime collaboration", "collaboration"],
37
+ category: "project_management",
38
+ },
39
+ {
40
+ concept_id: "concept_agent_skills",
41
+ name: "agent skills",
42
+ aliases: ["AI agent skills", "skill pack", "coding agent skills"],
43
+ keywords: ["skill", "skills", "agent", "claude code", "codex", "cursor", "gemini"],
44
+ category: "agent_skills",
45
+ },
46
+ {
47
+ concept_id: "concept_secret_scanning",
48
+ name: "secret scanning",
49
+ aliases: ["API key detection", "token detection", "credential scanning"],
50
+ keywords: ["gitleaks", "secret", "token", "password", "api key"],
51
+ category: "secret_scanning",
52
+ },
53
+ {
54
+ concept_id: "concept_self_hosted_local_first_tools",
55
+ name: "self-hosted local-first tools",
56
+ aliases: ["local-first", "privacy", "self-hosted"],
57
+ keywords: ["self-hosted", "local", "privacy", "offline"],
58
+ category: "local_first_privacy",
59
+ },
60
+ {
61
+ concept_id: "concept_ai_media_generation",
62
+ name: "AI media generation",
63
+ aliases: ["AI video", "AI music", "generative media"],
64
+ keywords: ["video", "music", "suno", "lipdub", "comfyui", "ace-step"],
65
+ category: "ai_media",
66
+ },
67
+ {
68
+ concept_id: "concept_document_management",
69
+ name: "document management",
70
+ aliases: ["paperless", "PDF workflow", "document archive"],
71
+ keywords: ["paperless", "document", "pdf", "ocr", "archive"],
72
+ category: "document_management",
73
+ },
74
+ {
75
+ concept_id: "concept_developer_tooling",
76
+ name: "developer tooling",
77
+ aliases: ["open source developer tool", "CLI", "framework"],
78
+ keywords: ["github", "repo", "open-source", "mã nguồn", "cli", "framework", "developer"],
79
+ category: "developer_tooling",
80
+ },
81
+ ];
82
+
83
+ function toPosix(value) {
84
+ return String(value || "").replace(/\\/g, "/");
85
+ }
86
+
87
+ function isRemoteUrl(value) {
88
+ return /^(?:https?|ftp):\/\//i.test(String(value || ""));
89
+ }
90
+
91
+ function sha256(value) {
92
+ return crypto.createHash("sha256").update(String(value)).digest("hex");
93
+ }
94
+
95
+ function timestampStamp(date = new Date()) {
96
+ return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z").replace("T", "-");
97
+ }
98
+
99
+ function safeSlug(value, fallback = "item") {
100
+ const slug = String(value || "")
101
+ .normalize("NFKD")
102
+ .replace(/[\u0300-\u036f]/g, "")
103
+ .toLowerCase()
104
+ .replace(/https?:\/\/\S+/g, "")
105
+ .replace(/[^a-z0-9]+/g, "-")
106
+ .replace(/^-+|-+$/g, "")
107
+ .replace(/-+/g, "-")
108
+ .slice(0, 64)
109
+ .replace(/^-+|-+$/g, "");
110
+ return slug || fallback;
111
+ }
112
+
113
+ function normaliseExcludeFragment(frag) {
114
+ return toPosix(frag).replace(/^\/+|\/+$/g, "").toLowerCase().split("/").filter(Boolean);
115
+ }
116
+
117
+ function isExcluded(targetPath, sourceRoot) {
118
+ const relative = toPosix(path.relative(sourceRoot, targetPath)).toLowerCase();
119
+ const parts = relative.split("/").filter((part) => part && part !== ".");
120
+ for (const frag of EXCLUDE_FRAGS) {
121
+ const fragParts = normaliseExcludeFragment(frag);
122
+ if (fragParts.length === 0) continue;
123
+ if (fragParts.length === 1) {
124
+ if (parts.includes(fragParts[0])) return `exclude:${frag}`;
125
+ continue;
126
+ }
127
+ for (let idx = 0; idx <= parts.length - fragParts.length; idx += 1) {
128
+ if (parts.slice(idx, idx + fragParts.length).join("/") === fragParts.join("/")) {
129
+ return `exclude:${frag}`;
130
+ }
131
+ }
132
+ }
133
+ return null;
134
+ }
135
+
136
+ function collectMarkdownFiles(sourceRoot) {
137
+ const files = [];
138
+ const skipped = [];
139
+ let scanned = 0;
140
+
141
+ function visit(current) {
142
+ const stat = fs.statSync(current);
143
+ if (stat.isDirectory()) {
144
+ const excluded = isExcluded(current, sourceRoot);
145
+ if (excluded) return;
146
+ for (const child of fs.readdirSync(current).sort()) {
147
+ visit(path.join(current, child));
148
+ }
149
+ return;
150
+ }
151
+ if (!stat.isFile()) return;
152
+ if (!/\.md(?:arkdown)?$/i.test(current)) return;
153
+ scanned += 1;
154
+ const excluded = isExcluded(current, sourceRoot);
155
+ if (excluded) {
156
+ skipped.push({
157
+ path: toPosix(path.relative(sourceRoot, current)),
158
+ reason: excluded,
159
+ });
160
+ return;
161
+ }
162
+ files.push(current);
163
+ }
164
+
165
+ const stat = fs.statSync(sourceRoot);
166
+ if (stat.isFile()) {
167
+ scanned += /\.md(?:arkdown)?$/i.test(sourceRoot) ? 1 : 0;
168
+ if (/\.md(?:arkdown)?$/i.test(sourceRoot)) files.push(sourceRoot);
169
+ } else {
170
+ visit(sourceRoot);
171
+ }
172
+
173
+ return { files: files.sort((a, b) => toPosix(a).localeCompare(toPosix(b))), skipped, scanned };
174
+ }
175
+
176
+ function collectJsonFiles(sourceRoot) {
177
+ const files = [];
178
+ let scanned = 0;
179
+
180
+ function visit(current) {
181
+ const stat = fs.statSync(current);
182
+ if (stat.isDirectory()) {
183
+ const excluded = isExcluded(current, sourceRoot);
184
+ if (excluded) return;
185
+ for (const child of fs.readdirSync(current).sort()) {
186
+ visit(path.join(current, child));
187
+ }
188
+ return;
189
+ }
190
+ if (!stat.isFile()) return;
191
+ if (!/\.json$/i.test(current)) return;
192
+ scanned += 1;
193
+ const excluded = isExcluded(current, sourceRoot);
194
+ if (excluded) return;
195
+ files.push(current);
196
+ }
197
+
198
+ const stat = fs.statSync(sourceRoot);
199
+ if (stat.isFile()) {
200
+ scanned += /\.json$/i.test(sourceRoot) ? 1 : 0;
201
+ if (/\.json$/i.test(sourceRoot)) files.push(sourceRoot);
202
+ } else {
203
+ visit(sourceRoot);
204
+ }
205
+
206
+ return { files: files.sort((a, b) => toPosix(a).localeCompare(toPosix(b))), scanned };
207
+ }
208
+
209
+ function parseFrontmatterTitle(text) {
210
+ const lines = text.split(/\r?\n/);
211
+ if (!lines.length || lines[0].trim() !== "---") return "";
212
+ for (let idx = 1; idx < lines.length; idx += 1) {
213
+ const line = lines[idx];
214
+ if (line.trim() === "---") break;
215
+ const match = line.match(/^title:\s*(.+?)\s*$/i);
216
+ if (match) return match[1].trim().replace(/^["']|["']$/g, "");
217
+ }
218
+ return "";
219
+ }
220
+
221
+ function extractTitle(text, filePath) {
222
+ const frontmatterTitle = parseFrontmatterTitle(text);
223
+ if (frontmatterTitle) return frontmatterTitle;
224
+ const heading = text.match(/^#\s+(.+?)\s*$/m);
225
+ if (heading) return heading[1].trim();
226
+ return path.basename(filePath, path.extname(filePath)).replace(/[_-]+/g, " ").trim();
227
+ }
228
+
229
+ function detectMojibake(text) {
230
+ const matches = text.match(/�|Ã.|Â.|â€|ðŸ/gi) || [];
231
+ return {
232
+ hasMojibake: matches.length > 0,
233
+ score: Math.min(1, matches.length / Math.max(1, text.length / 500)),
234
+ };
235
+ }
236
+
237
+ function extractGithubRepos(text) {
238
+ const repos = [];
239
+ const seen = new Set();
240
+ const githubRegex = /(?:https?:\/\/)?(?:www\.)?github\.com\/([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\/([A-Za-z0-9._-]+)/gi;
241
+ let match;
242
+ while ((match = githubRegex.exec(text)) !== null) {
243
+ const owner = match[1];
244
+ const rawRepo = match[2].replace(/[).,;:]+$/g, "");
245
+ if (!rawRepo || rawRepo === "..." || rawRepo.includes("...")) continue;
246
+ const repo = rawRepo.replace(/\.git$/i, "");
247
+ const key = `${owner.toLowerCase()}/${repo.toLowerCase()}`;
248
+ if (seen.has(key)) continue;
249
+ seen.add(key);
250
+ repos.push({
251
+ owner,
252
+ repo,
253
+ github_url: `https://github.com/${owner}/${repo}`,
254
+ key,
255
+ });
256
+ }
257
+ return repos;
258
+ }
259
+
260
+ function parseGithubRepoUrl(value) {
261
+ const match = String(value || "").match(/^(?:https?:\/\/)?(?:www\.)?github\.com\/([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\/([A-Za-z0-9._-]+)(?:\.git)?(?:[/?#].*)?$/i);
262
+ if (!match) return null;
263
+ const owner = match[1];
264
+ const repo = match[2].replace(/[).,;:]+$/g, "").replace(/\.git$/i, "");
265
+ if (!repo || repo === "..." || repo.includes("...")) return null;
266
+ return {
267
+ owner,
268
+ repo,
269
+ github_url: `https://github.com/${owner}/${repo}`,
270
+ key: `${owner.toLowerCase()}/${repo.toLowerCase()}`,
271
+ };
272
+ }
273
+
274
+ function extractUnsupportedGithubItems(text) {
275
+ const items = [];
276
+ const invalidRegex = /github\.com\/(?:\.\.\.|[^\s)]+\.{3}[^\s)]*)/gi;
277
+ let match;
278
+ while ((match = invalidRegex.exec(text)) !== null) {
279
+ items.push(match[0]);
280
+ }
281
+ return [...new Set(items)];
282
+ }
283
+
284
+ function normalizeTopic(value) {
285
+ return safeSlug(String(value || "").replace(/_/g, " "), "topic");
286
+ }
287
+
288
+ function conceptFromTopic(topic) {
289
+ const name = String(topic || "").trim();
290
+ const slug = normalizeTopic(name);
291
+ if (!slug || slug === "topic") return null;
292
+ return {
293
+ concept_id: `concept_topic_${slug.replace(/-/g, "_")}`,
294
+ name: name.replace(/[_-]+/g, " "),
295
+ aliases: [name],
296
+ definition: `Local structured sources include topic evidence for ${name.replace(/[_-]+/g, " ")}.`,
297
+ related_entities: [],
298
+ source_refs: [],
299
+ provenance_refs: [],
300
+ confidence: 0.6,
301
+ confidence_tier: "medium",
302
+ target_page_path: `wiki/concepts/${slug}.md`,
303
+ };
304
+ }
305
+
306
+ function inferConcepts(text) {
307
+ const lower = text.toLowerCase();
308
+ return CONCEPT_RULES.filter((rule) => rule.keywords.some((keyword) => lower.includes(keyword.toLowerCase())));
309
+ }
310
+
311
+ function inferConceptsFromTopics(topics) {
312
+ return (Array.isArray(topics) ? topics : [])
313
+ .map(conceptFromTopic)
314
+ .filter(Boolean);
315
+ }
316
+
317
+ function categoryForSource(text, concepts) {
318
+ if (concepts.length > 0) return concepts[0].category;
319
+ const lower = text.toLowerCase();
320
+ if (lower.includes("github") || lower.includes("repo")) return "developer_tooling";
321
+ return "uncategorized";
322
+ }
323
+
324
+ function confidenceNumber(value) {
325
+ if (typeof value === "number" && Number.isFinite(value)) return Math.max(0, Math.min(1, value));
326
+ const text = String(value || "").trim().toLowerCase();
327
+ if (text === "high") return 0.85;
328
+ if (text === "medium") return 0.65;
329
+ if (text === "low") return 0.35;
330
+ if (text === "unsupported") return 0.1;
331
+ return 0.5;
332
+ }
333
+
334
+ function firstArray(value) {
335
+ if (Array.isArray(value)) return value;
336
+ if (!value || typeof value !== "object") return [];
337
+ for (const key of ["records", "repos", "repositories", "items", "data"]) {
338
+ if (Array.isArray(value[key])) return value[key];
339
+ }
340
+ return [value];
341
+ }
342
+
343
+ function normalizeJsonRepoRecord(raw) {
344
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
345
+ const repoUrl = String(raw.repo_url || raw.github_url || raw.url || "").trim();
346
+ const parsedRepo = parseGithubRepoUrl(repoUrl);
347
+ const owner = String(raw.owner || parsedRepo?.owner || "").trim();
348
+ const repoName = String(raw.repo_name || raw.name || raw.repo || parsedRepo?.repo || "").trim();
349
+ const topics = Array.isArray(raw.topics) ? raw.topics.map((topic) => String(topic).trim()).filter(Boolean) : [];
350
+ const snippet = String(raw.message_text_snippet || raw.snippet || raw.description || raw.summary || "").trim();
351
+ const sourceLink = String(raw.source_link || raw.source_url || raw.link || "").trim();
352
+ if (!repoUrl && !owner && !repoName && !snippet && topics.length === 0 && !sourceLink) return null;
353
+ return {
354
+ repo_url: parsedRepo ? parsedRepo.github_url : repoUrl || null,
355
+ owner,
356
+ repo_name: repoName,
357
+ message_text_snippet: snippet,
358
+ source_link: sourceLink || null,
359
+ topics,
360
+ confidence_raw: raw.confidence ?? null,
361
+ confidence: confidenceNumber(raw.confidence),
362
+ parsed_repo: parsedRepo || (owner && repoName ? {
363
+ owner,
364
+ repo: repoName,
365
+ github_url: repoUrl || `https://github.com/${owner}/${repoName}`,
366
+ key: `${owner.toLowerCase()}/${repoName.toLowerCase()}`,
367
+ } : null),
368
+ raw,
369
+ };
370
+ }
371
+
372
+ function confidenceTier(confidence) {
373
+ if (confidence >= 0.8) return "high";
374
+ if (confidence >= 0.5) return "medium";
375
+ if (confidence >= 0.2) return "low";
376
+ return "unsupported";
377
+ }
378
+
379
+ function makeSourceRootRef(sourceRoot) {
380
+ const display = toPosix(sourceRoot);
381
+ const label = safeSlug(path.basename(sourceRoot), "source-root");
382
+ return {
383
+ source_root_id: `root_${sha256(`source-root:v1:${display.toLowerCase()}`).slice(0, 12)}`,
384
+ source_root_label: label,
385
+ source_root_type: "external_local",
386
+ source_root_display_path: display,
387
+ normalization_version: "source-root:v1",
388
+ };
389
+ }
390
+
391
+ function buildProvenance({ sourceId, sourceHash, sourceRelativePath, sourceLogicalPath, line, generatedAt, confidence }) {
392
+ return {
393
+ provenance_id: `prov_${sourceId}_${String(line || 1).padStart(3, "0")}`,
394
+ source_id: sourceId,
395
+ source_hash: sourceHash,
396
+ source_relative_path: sourceRelativePath,
397
+ source_logical_path: sourceLogicalPath,
398
+ locator: {
399
+ type: "line_range",
400
+ start_line: line || 1,
401
+ end_line: line || 1,
402
+ heading: null,
403
+ },
404
+ evidence_quote_hash: sha256(`${sourceId}:${line || 1}`),
405
+ extractor: "sdtk-wiki.semantic-extract",
406
+ extractor_version: "bk132-dry-run",
407
+ generated_at: generatedAt,
408
+ confidence,
409
+ };
410
+ }
411
+
412
+ function lineOf(text, needle) {
413
+ const idx = text.indexOf(needle);
414
+ if (idx < 0) return 1;
415
+ return text.slice(0, idx).split(/\r?\n/).length;
416
+ }
417
+
418
+ function boundedText(value, maxLength = 700) {
419
+ const text = String(value || "").replace(/\s+/g, " ").trim();
420
+ if (text.length <= maxLength) return text;
421
+ return `${text.slice(0, maxLength - 1).trim()}...`;
422
+ }
423
+
424
+ function asArray(value) {
425
+ return Array.isArray(value) ? value : [];
426
+ }
427
+
428
+ function localRepoSnippet(text, repoUrl) {
429
+ const lines = String(text || "").split(/\r?\n/);
430
+ const index = lines.findIndex((line) => line.includes(repoUrl));
431
+ if (index < 0) return boundedText(text, 500);
432
+ const start = Math.max(0, index - 2);
433
+ const end = Math.min(lines.length, index + 4);
434
+ return boundedText(lines.slice(start, end).join(" "));
435
+ }
436
+
437
+ function pushUnique(array, value, keyFn = (item) => item) {
438
+ if (!value) return;
439
+ const key = keyFn(value);
440
+ if (!array.some((item) => keyFn(item) === key)) array.push(value);
441
+ }
442
+
443
+ function sourceRefsForEntity(entity) {
444
+ return [...new Set(asArray(entity && entity.source_refs).filter(Boolean).map(String))];
445
+ }
446
+
447
+ function decisionStrengthsForEntity(entity, conceptName) {
448
+ const strengths = [];
449
+ if (sourceRefsForEntity(entity).length > 1) strengths.push("mentioned by multiple local source records");
450
+ if (asArray(entity.topics).length > 0) strengths.push(`topic fit: ${asArray(entity.topics).slice(0, 3).join(", ")}`);
451
+ if (entity.category && entity.category !== "uncategorized") strengths.push(`category fit: ${entity.category}`);
452
+ if (entity.summary) strengths.push("has local snippet evidence");
453
+ return strengths.length > 0 ? strengths : [`local evidence connects this repository to ${conceptName}`];
454
+ }
455
+
456
+ function decisionCaveatsForEntity(entity) {
457
+ const caveats = ["local evidence only; verify externally before adoption"];
458
+ if (!entity.github_url) caveats.push("missing canonical repository URL");
459
+ if (["low", "unsupported"].includes(entity.confidence_tier)) caveats.push("low extraction confidence");
460
+ if (sourceRefsForEntity(entity).length <= 1) caveats.push("single local source reference");
461
+ return caveats;
462
+ }
463
+
464
+ function confidenceSummary(records) {
465
+ const counts = { high: 0, medium: 0, low: 0, unsupported: 0, unknown: 0 };
466
+ for (const record of asArray(records)) {
467
+ const tier = String(record.confidence_tier || confidenceTier(record.confidence) || "unknown").toLowerCase();
468
+ if (Object.prototype.hasOwnProperty.call(counts, tier)) counts[tier] += 1;
469
+ else counts.unknown += 1;
470
+ }
471
+ return counts;
472
+ }
473
+
474
+ function buildExtraction({ projectPath, sourceRoot }) {
475
+ const generatedAt = new Date().toISOString();
476
+ const sourceRootRef = makeSourceRootRef(sourceRoot);
477
+ const collected = collectMarkdownFiles(sourceRoot);
478
+ const collectedJson = collectJsonFiles(sourceRoot);
479
+ const sources = [];
480
+ const toolEntitiesById = new Map();
481
+ const conceptsById = new Map();
482
+ const claims = [];
483
+ const relations = [];
484
+ const provenance = [];
485
+ const sourceQualityFindings = [];
486
+ const unsupportedItems = [];
487
+ const sourceUrlUsage = new Map();
488
+
489
+ for (const filePath of collected.files) {
490
+ const text = fs.readFileSync(filePath, "utf-8");
491
+ const sourceHash = sha256(fs.readFileSync(filePath));
492
+ const stats = fs.statSync(filePath);
493
+ const sourceRelativePath = toPosix(path.relative(sourceRoot, filePath));
494
+ const sourceLogicalPath = `${sourceRootRef.source_root_label}/${sourceRelativePath}`;
495
+ const sourceDisplayPath = toPosix(filePath);
496
+ const sourceId = `src_${sha256(`local-md:v1:${sourceRootRef.source_root_id}:${sourceRelativePath.toLowerCase()}`).slice(0, 16)}`;
497
+ const title = extractTitle(text, filePath);
498
+ const repos = extractGithubRepos(text);
499
+ const unsupportedGithub = extractUnsupportedGithubItems(text);
500
+ const concepts = inferConcepts(`${title}\n${text}`);
501
+ const category = categoryForSource(`${title}\n${text}`, concepts);
502
+ const mojibake = detectMojibake(text);
503
+ const weakTitle = title.length < 6 || /^untitled|note|readme$/i.test(title);
504
+ const sourceUrl = repos.length > 0 ? repos[0].github_url : null;
505
+ const sourceSlug = `${safeSlug(title || sourceRelativePath, "source")}--${sourceId.slice(0, 8)}`;
506
+ const qualityFlags = [];
507
+ const qualityNotes = [];
508
+
509
+ if (mojibake.hasMojibake) {
510
+ qualityFlags.push("mojibake_detected");
511
+ qualityNotes.push("Potential mojibake or replacement characters detected.");
512
+ }
513
+ if (!sourceUrl) {
514
+ qualityFlags.push("missing_source_url");
515
+ qualityNotes.push("No valid GitHub/source URL was extracted.");
516
+ }
517
+ if (weakTitle) {
518
+ qualityFlags.push("weak_title");
519
+ qualityNotes.push("Title is missing, very short, or generic.");
520
+ }
521
+ if (repos.length === 0) {
522
+ qualityFlags.push("low_confidence_extraction");
523
+ }
524
+
525
+ const sourceRecord = {
526
+ source_id: sourceId,
527
+ source_root_id: sourceRootRef.source_root_id,
528
+ source_relative_path: sourceRelativePath,
529
+ source_logical_path: sourceLogicalPath,
530
+ source_display_path: sourceDisplayPath,
531
+ source_type: "markdown",
532
+ title,
533
+ source_url: sourceUrl,
534
+ source_hash: sourceHash,
535
+ size_bytes: stats.size,
536
+ modified_time: stats.mtime.toISOString(),
537
+ encoding_quality: mojibake.hasMojibake ? "suspect" : "clean",
538
+ source_quality: {
539
+ has_mojibake: mojibake.hasMojibake,
540
+ mojibake_score: Number(mojibake.score.toFixed(3)),
541
+ has_source_url: Boolean(sourceUrl),
542
+ weak_title: weakTitle,
543
+ duplicate_candidate: false,
544
+ duplicate_group_id: null,
545
+ low_confidence_extraction: repos.length === 0,
546
+ quality_flags: qualityFlags,
547
+ notes: qualityNotes,
548
+ },
549
+ provenance_refs: [],
550
+ target_page_path: `wiki/sources/${sourceSlug}.md`,
551
+ };
552
+
553
+ sources.push(sourceRecord);
554
+
555
+ if (sourceUrl) {
556
+ const existing = sourceUrlUsage.get(sourceUrl) || [];
557
+ existing.push(sourceRecord);
558
+ sourceUrlUsage.set(sourceUrl, existing);
559
+ }
560
+
561
+ for (const rawUnsupported of unsupportedGithub) {
562
+ unsupportedItems.push({
563
+ record_type: "unsupported_item",
564
+ item_id: `unsupported_${sourceId}_${String(unsupportedItems.length + 1).padStart(3, "0")}`,
565
+ source_id: sourceId,
566
+ reason: "unsupported_url_format",
567
+ raw_observation_summary: rawUnsupported,
568
+ confidence: 0.1,
569
+ confidence_tier: "unsupported",
570
+ provenance_refs: [],
571
+ });
572
+ if (!qualityFlags.includes("unsupported_url_format")) {
573
+ sourceRecord.source_quality.quality_flags.push("unsupported_url_format");
574
+ }
575
+ }
576
+
577
+ if (qualityFlags.length > 0) {
578
+ sourceQualityFindings.push({
579
+ finding_id: `sq_${sourceId}`,
580
+ source_id: sourceId,
581
+ source_relative_path: sourceRelativePath,
582
+ source_logical_path: sourceLogicalPath,
583
+ quality_flags: [...sourceRecord.source_quality.quality_flags],
584
+ confidence: repos.length === 0 ? 0.3 : 0.7,
585
+ confidence_tier: repos.length === 0 ? "low" : "medium",
586
+ notes: qualityNotes,
587
+ });
588
+ }
589
+
590
+ for (const repo of repos) {
591
+ const entityId = `tool_github_${safeSlug(repo.owner, "owner")}_${safeSlug(repo.repo, "repo")}`;
592
+ const prov = buildProvenance({
593
+ sourceId,
594
+ sourceHash,
595
+ sourceRelativePath,
596
+ sourceLogicalPath,
597
+ line: lineOf(text, repo.github_url),
598
+ generatedAt,
599
+ confidence: 0.9,
600
+ });
601
+ provenance.push(prov);
602
+ sourceRecord.provenance_refs.push(prov.provenance_id);
603
+
604
+ if (!toolEntitiesById.has(entityId)) {
605
+ toolEntitiesById.set(entityId, {
606
+ entity_id: entityId,
607
+ entity_type: "tool_entity",
608
+ name: repo.repo,
609
+ repo_owner: repo.owner,
610
+ repo_name: repo.repo,
611
+ github_url: repo.github_url,
612
+ category,
613
+ summary: `${repo.repo} is a locally sourced GitHub tool candidate in category ${category}.`,
614
+ confidence: 0.9,
615
+ confidence_tier: "high",
616
+ source_refs: [],
617
+ provenance_refs: [],
618
+ topics: concepts.map((concept) => concept.name),
619
+ source_links: [],
620
+ evidence_snippets: [],
621
+ discovery_sources: [],
622
+ evidence_records: [],
623
+ target_page_path: `wiki/entities/tools/${safeSlug(repo.repo, "tool")}--${entityId}.md`,
624
+ });
625
+ }
626
+ const entity = toolEntitiesById.get(entityId);
627
+ if (!entity.source_refs.includes(sourceId)) entity.source_refs.push(sourceId);
628
+ if (!entity.provenance_refs.includes(prov.provenance_id)) entity.provenance_refs.push(prov.provenance_id);
629
+ for (const concept of concepts) {
630
+ if (!entity.topics) entity.topics = [];
631
+ pushUnique(entity.topics, concept.name);
632
+ }
633
+ const snippet = localRepoSnippet(text, repo.github_url);
634
+ if (!entity.evidence_snippets) entity.evidence_snippets = [];
635
+ pushUnique(entity.evidence_snippets, snippet);
636
+ if (!entity.discovery_sources) entity.discovery_sources = [];
637
+ pushUnique(entity.discovery_sources, sourceLogicalPath);
638
+ if (!entity.evidence_records) entity.evidence_records = [];
639
+ pushUnique(entity.evidence_records, {
640
+ source_id: sourceId,
641
+ source_logical_path: sourceLogicalPath,
642
+ source_link: sourceUrl || null,
643
+ snippet,
644
+ topics: concepts.map((concept) => concept.name),
645
+ provenance_refs: [prov.provenance_id],
646
+ confidence: 0.9,
647
+ confidence_tier: "high",
648
+ }, (record) => `${record.source_id}:${record.source_link || ""}:${record.snippet}`);
649
+
650
+ claims.push({
651
+ claim_id: `claim_${sourceId}_${String(claims.length + 1).padStart(3, "0")}`,
652
+ text: `The local source presents ${repo.repo} as a ${category} tool or project.`,
653
+ subject_entity_id: entityId,
654
+ source_refs: [sourceId],
655
+ provenance_refs: [prov.provenance_id],
656
+ confidence: 0.75,
657
+ confidence_tier: "medium",
658
+ contested: false,
659
+ });
660
+
661
+ relations.push({
662
+ relation_id: `rel_${sourceId}_${String(relations.length + 1).padStart(3, "0")}`,
663
+ source_id: sourceId,
664
+ target_id: entityId,
665
+ relation_type: "source_mentions_entity",
666
+ evidence: "The local Markdown source includes a GitHub repository URL.",
667
+ source_refs: [sourceId],
668
+ provenance_refs: [prov.provenance_id],
669
+ confidence: 0.9,
670
+ confidence_tier: "high",
671
+ });
672
+ }
673
+
674
+ for (const conceptRule of concepts) {
675
+ if (!conceptsById.has(conceptRule.concept_id)) {
676
+ conceptsById.set(conceptRule.concept_id, {
677
+ concept_id: conceptRule.concept_id,
678
+ name: conceptRule.name,
679
+ aliases: conceptRule.aliases,
680
+ definition: `Local sources contain evidence related to ${conceptRule.name}.`,
681
+ related_entities: [],
682
+ source_refs: [],
683
+ provenance_refs: [],
684
+ confidence: 0.65,
685
+ confidence_tier: "medium",
686
+ target_page_path: `wiki/concepts/${safeSlug(conceptRule.name, "concept")}.md`,
687
+ });
688
+ }
689
+ const concept = conceptsById.get(conceptRule.concept_id);
690
+ if (!concept.source_refs.includes(sourceId)) concept.source_refs.push(sourceId);
691
+
692
+ for (const repo of repos) {
693
+ const entityId = `tool_github_${safeSlug(repo.owner, "owner")}_${safeSlug(repo.repo, "repo")}`;
694
+ if (!concept.related_entities.includes(entityId)) concept.related_entities.push(entityId);
695
+ relations.push({
696
+ relation_id: `rel_${sourceId}_${String(relations.length + 1).padStart(3, "0")}`,
697
+ source_id: entityId,
698
+ target_id: conceptRule.concept_id,
699
+ relation_type: "entity_implements_concept",
700
+ evidence: `The local source text matches ${conceptRule.name} keywords.`,
701
+ source_refs: [sourceId],
702
+ provenance_refs: [...sourceRecord.provenance_refs],
703
+ confidence: 0.7,
704
+ confidence_tier: "medium",
705
+ });
706
+ }
707
+ }
708
+ }
709
+
710
+ for (const filePath of collectedJson.files) {
711
+ const sourceRelativePath = toPosix(path.relative(sourceRoot, filePath));
712
+ const sourceDisplayPath = toPosix(filePath);
713
+ const sourceLogicalFilePath = `${sourceRootRef.source_root_label}/${sourceRelativePath}`;
714
+ const fileBytes = fs.readFileSync(filePath);
715
+ const fileHash = sha256(fileBytes);
716
+ const stats = fs.statSync(filePath);
717
+ let parsed;
718
+
719
+ try {
720
+ parsed = JSON.parse(fileBytes.toString("utf-8"));
721
+ } catch (error) {
722
+ const sourceId = `src_${sha256(`local-json-file:v1:${sourceRootRef.source_root_id}:${sourceRelativePath.toLowerCase()}`).slice(0, 16)}`;
723
+ const sourceRecord = {
724
+ source_id: sourceId,
725
+ source_root_id: sourceRootRef.source_root_id,
726
+ source_relative_path: sourceRelativePath,
727
+ source_logical_path: sourceLogicalFilePath,
728
+ source_display_path: sourceDisplayPath,
729
+ source_type: "json",
730
+ title: path.basename(filePath),
731
+ source_url: null,
732
+ source_hash: fileHash,
733
+ size_bytes: stats.size,
734
+ modified_time: stats.mtime.toISOString(),
735
+ encoding_quality: "unknown",
736
+ source_quality: {
737
+ has_mojibake: false,
738
+ mojibake_score: 0,
739
+ has_source_url: false,
740
+ weak_title: false,
741
+ duplicate_candidate: false,
742
+ duplicate_group_id: null,
743
+ low_confidence_extraction: true,
744
+ quality_flags: ["invalid_json"],
745
+ notes: [`Invalid JSON could not be parsed: ${error.message}`],
746
+ },
747
+ provenance_refs: [],
748
+ target_page_path: `wiki/sources/${safeSlug(path.basename(filePath), "json-source")}--${sourceId.slice(0, 8)}.md`,
749
+ };
750
+ sources.push(sourceRecord);
751
+ sourceQualityFindings.push({
752
+ finding_id: `sq_${sourceId}`,
753
+ source_id: sourceId,
754
+ source_relative_path: sourceRelativePath,
755
+ source_logical_path: sourceLogicalFilePath,
756
+ quality_flags: ["invalid_json"],
757
+ confidence: 0.1,
758
+ confidence_tier: "unsupported",
759
+ notes: sourceRecord.source_quality.notes,
760
+ });
761
+ unsupportedItems.push({
762
+ record_type: "unsupported_item",
763
+ item_id: `unsupported_${sourceId}_001`,
764
+ source_id: sourceId,
765
+ reason: "invalid_json",
766
+ raw_observation_summary: `Invalid JSON file: ${sourceRelativePath}`,
767
+ confidence: 0.1,
768
+ confidence_tier: "unsupported",
769
+ provenance_refs: [],
770
+ });
771
+ continue;
772
+ }
773
+
774
+ const rawRecords = firstArray(parsed);
775
+ const normalizedRecords = rawRecords.map(normalizeJsonRepoRecord).filter(Boolean);
776
+ if (normalizedRecords.length === 0) {
777
+ const sourceId = `src_${sha256(`local-json-empty:v1:${sourceRootRef.source_root_id}:${sourceRelativePath.toLowerCase()}`).slice(0, 16)}`;
778
+ const sourceRecord = {
779
+ source_id: sourceId,
780
+ source_root_id: sourceRootRef.source_root_id,
781
+ source_relative_path: sourceRelativePath,
782
+ source_logical_path: sourceLogicalFilePath,
783
+ source_display_path: sourceDisplayPath,
784
+ source_type: "json",
785
+ title: path.basename(filePath),
786
+ source_url: null,
787
+ source_hash: fileHash,
788
+ size_bytes: stats.size,
789
+ modified_time: stats.mtime.toISOString(),
790
+ encoding_quality: "clean",
791
+ source_quality: {
792
+ has_mojibake: false,
793
+ mojibake_score: 0,
794
+ has_source_url: false,
795
+ weak_title: false,
796
+ duplicate_candidate: false,
797
+ duplicate_group_id: null,
798
+ low_confidence_extraction: true,
799
+ quality_flags: ["empty_json_records"],
800
+ notes: ["JSON parsed successfully but contained no supported repository records."],
801
+ },
802
+ provenance_refs: [],
803
+ target_page_path: `wiki/sources/${safeSlug(path.basename(filePath), "json-source")}--${sourceId.slice(0, 8)}.md`,
804
+ };
805
+ sources.push(sourceRecord);
806
+ sourceQualityFindings.push({
807
+ finding_id: `sq_${sourceId}`,
808
+ source_id: sourceId,
809
+ source_relative_path: sourceRelativePath,
810
+ source_logical_path: sourceLogicalFilePath,
811
+ quality_flags: ["empty_json_records"],
812
+ confidence: 0.2,
813
+ confidence_tier: "unsupported",
814
+ notes: sourceRecord.source_quality.notes,
815
+ });
816
+ continue;
817
+ }
818
+
819
+ normalizedRecords.forEach((record, index) => {
820
+ const recordRef = `record-${String(index + 1).padStart(3, "0")}`;
821
+ const repoKey = record.repo_url || `${record.owner}/${record.repo_name}` || sha256(JSON.stringify(record.raw));
822
+ const sourceId = `src_${sha256(`local-json:v1:${sourceRootRef.source_root_id}:${sourceRelativePath.toLowerCase()}:${repoKey.toLowerCase()}`).slice(0, 16)}`;
823
+ const sourceHash = sha256(`${fileHash}:${sha256(JSON.stringify(record.raw))}`);
824
+ const title = record.repo_name || record.owner || `${path.basename(filePath)} ${recordRef}`;
825
+ const sourceLogicalPath = `${sourceLogicalFilePath}#${recordRef}`;
826
+ const sourceDisplayRecordPath = `${sourceDisplayPath}#${recordRef}`;
827
+ const mojibake = detectMojibake(record.message_text_snippet);
828
+ const weakTitle = title.length < 3;
829
+ const qualityFlags = [];
830
+ const qualityNotes = [];
831
+ const sourceUrl = record.repo_url || record.source_link || null;
832
+ const confidence = record.confidence;
833
+ const confidenceBand = confidenceTier(confidence);
834
+
835
+ if (mojibake.hasMojibake) {
836
+ qualityFlags.push("mojibake_detected");
837
+ qualityNotes.push("Potential mojibake or replacement characters detected in JSON snippet.");
838
+ }
839
+ if (!record.parsed_repo) {
840
+ qualityFlags.push("missing_repo_url");
841
+ qualityNotes.push("JSON record does not include a supported GitHub repository URL.");
842
+ }
843
+ if (!record.source_link) {
844
+ qualityFlags.push("missing_source_link");
845
+ qualityNotes.push("JSON record does not include a source_link.");
846
+ }
847
+ if (weakTitle) {
848
+ qualityFlags.push("weak_title");
849
+ qualityNotes.push("Repository title is missing or very short.");
850
+ }
851
+ if (confidence < 0.5) {
852
+ qualityFlags.push("low_confidence_extraction");
853
+ }
854
+
855
+ const sourceRecord = {
856
+ source_id: sourceId,
857
+ source_root_id: sourceRootRef.source_root_id,
858
+ source_relative_path: sourceRelativePath,
859
+ source_logical_path: sourceLogicalPath,
860
+ source_display_path: sourceDisplayRecordPath,
861
+ source_type: "json_record",
862
+ title,
863
+ source_url: sourceUrl,
864
+ source_hash: sourceHash,
865
+ size_bytes: stats.size,
866
+ modified_time: stats.mtime.toISOString(),
867
+ encoding_quality: mojibake.hasMojibake ? "suspect" : "clean",
868
+ source_record_locator: {
869
+ type: "json_record",
870
+ record_index: index,
871
+ record_ref: recordRef,
872
+ record_pointer: `/${index}`,
873
+ },
874
+ structured_fields: {
875
+ repo_url: record.repo_url,
876
+ owner: record.owner,
877
+ repo_name: record.repo_name,
878
+ message_text_snippet: record.message_text_snippet,
879
+ source_link: record.source_link,
880
+ topics: record.topics,
881
+ confidence: record.confidence_raw,
882
+ },
883
+ source_quality: {
884
+ has_mojibake: mojibake.hasMojibake,
885
+ mojibake_score: Number(mojibake.score.toFixed(3)),
886
+ has_source_url: Boolean(sourceUrl),
887
+ weak_title: weakTitle,
888
+ duplicate_candidate: false,
889
+ duplicate_group_id: null,
890
+ low_confidence_extraction: confidence < 0.5,
891
+ quality_flags: qualityFlags,
892
+ notes: qualityNotes,
893
+ },
894
+ provenance_refs: [],
895
+ target_page_path: `wiki/sources/${safeSlug(title || sourceRelativePath, "source")}--${sourceId.slice(0, 8)}.md`,
896
+ };
897
+
898
+ sources.push(sourceRecord);
899
+ if (record.repo_url) {
900
+ const existing = sourceUrlUsage.get(record.repo_url) || [];
901
+ existing.push(sourceRecord);
902
+ sourceUrlUsage.set(record.repo_url, existing);
903
+ }
904
+
905
+ if (qualityFlags.length > 0) {
906
+ sourceQualityFindings.push({
907
+ finding_id: `sq_${sourceId}`,
908
+ source_id: sourceId,
909
+ source_relative_path: sourceRelativePath,
910
+ source_logical_path: sourceLogicalPath,
911
+ quality_flags: [...qualityFlags],
912
+ confidence,
913
+ confidence_tier: confidenceBand,
914
+ notes: qualityNotes,
915
+ });
916
+ }
917
+
918
+ const concepts = [
919
+ ...inferConcepts(`${title}\n${record.message_text_snippet}\n${record.topics.join("\n")}`),
920
+ ...inferConceptsFromTopics(record.topics),
921
+ ];
922
+ const category = record.topics[0] ? normalizeTopic(record.topics[0]) : categoryForSource(record.message_text_snippet, concepts);
923
+
924
+ if (!record.parsed_repo) {
925
+ unsupportedItems.push({
926
+ record_type: "unsupported_item",
927
+ item_id: `unsupported_${sourceId}_001`,
928
+ source_id: sourceId,
929
+ reason: "missing_repo_url",
930
+ raw_observation_summary: `${sourceRelativePath}#${recordRef}`,
931
+ confidence: 0.2,
932
+ confidence_tier: "unsupported",
933
+ provenance_refs: [],
934
+ });
935
+ }
936
+
937
+ if (record.parsed_repo) {
938
+ const repo = record.parsed_repo;
939
+ const entityId = `tool_github_${safeSlug(repo.owner, "owner")}_${safeSlug(repo.repo, "repo")}`;
940
+ const prov = {
941
+ provenance_id: `prov_${sourceId}_${recordRef}`,
942
+ source_id: sourceId,
943
+ source_hash: sourceHash,
944
+ source_relative_path: sourceRelativePath,
945
+ source_logical_path: sourceLogicalPath,
946
+ locator: {
947
+ type: "json_record",
948
+ record_index: index,
949
+ record_ref: recordRef,
950
+ record_pointer: `/${index}`,
951
+ field: "repo_url",
952
+ },
953
+ evidence_quote_hash: sha256(`${sourceId}:${recordRef}:${repo.github_url}`),
954
+ extractor: "sdtk-wiki.semantic-extract",
955
+ extractor_version: "bk140-json-records",
956
+ generated_at: generatedAt,
957
+ confidence,
958
+ };
959
+ provenance.push(prov);
960
+ sourceRecord.provenance_refs.push(prov.provenance_id);
961
+
962
+ if (!toolEntitiesById.has(entityId)) {
963
+ toolEntitiesById.set(entityId, {
964
+ entity_id: entityId,
965
+ entity_type: "tool_entity",
966
+ name: repo.repo,
967
+ repo_owner: repo.owner,
968
+ repo_name: repo.repo,
969
+ github_url: repo.github_url,
970
+ category,
971
+ summary: record.message_text_snippet || `${repo.repo} is a locally sourced GitHub tool candidate in category ${category}.`,
972
+ confidence,
973
+ confidence_tier: confidenceBand,
974
+ source_refs: [],
975
+ provenance_refs: [],
976
+ topics: [...record.topics],
977
+ source_links: record.source_link ? [record.source_link] : [],
978
+ evidence_snippets: record.message_text_snippet ? [boundedText(record.message_text_snippet)] : [],
979
+ discovery_sources: [record.source_link, sourceLogicalPath].filter(Boolean),
980
+ evidence_records: [{
981
+ source_id: sourceId,
982
+ source_logical_path: sourceLogicalPath,
983
+ source_link: record.source_link || null,
984
+ snippet: boundedText(record.message_text_snippet),
985
+ topics: [...record.topics],
986
+ provenance_refs: [prov.provenance_id],
987
+ confidence,
988
+ confidence_tier: confidenceBand,
989
+ }],
990
+ target_page_path: `wiki/entities/tools/${safeSlug(repo.repo, "tool")}--${entityId}.md`,
991
+ });
992
+ }
993
+ const entity = toolEntitiesById.get(entityId);
994
+ if (!entity.source_refs.includes(sourceId)) entity.source_refs.push(sourceId);
995
+ if (!entity.provenance_refs.includes(prov.provenance_id)) entity.provenance_refs.push(prov.provenance_id);
996
+ for (const topic of record.topics) {
997
+ if (!entity.topics) entity.topics = [];
998
+ if (!entity.topics.includes(topic)) entity.topics.push(topic);
999
+ }
1000
+ if (record.source_link) {
1001
+ if (!entity.source_links) entity.source_links = [];
1002
+ if (!entity.source_links.includes(record.source_link)) entity.source_links.push(record.source_link);
1003
+ }
1004
+ if (record.message_text_snippet) {
1005
+ if (!entity.evidence_snippets) entity.evidence_snippets = [];
1006
+ pushUnique(entity.evidence_snippets, boundedText(record.message_text_snippet));
1007
+ }
1008
+ if (!entity.discovery_sources) entity.discovery_sources = [];
1009
+ pushUnique(entity.discovery_sources, record.source_link);
1010
+ pushUnique(entity.discovery_sources, sourceLogicalPath);
1011
+ if (!entity.evidence_records) entity.evidence_records = [];
1012
+ pushUnique(entity.evidence_records, {
1013
+ source_id: sourceId,
1014
+ source_logical_path: sourceLogicalPath,
1015
+ source_link: record.source_link || null,
1016
+ snippet: boundedText(record.message_text_snippet),
1017
+ topics: [...record.topics],
1018
+ provenance_refs: [prov.provenance_id],
1019
+ confidence,
1020
+ confidence_tier: confidenceBand,
1021
+ }, (item) => `${item.source_id}:${item.source_link || ""}:${item.snippet || ""}`);
1022
+
1023
+ claims.push({
1024
+ claim_id: `claim_${sourceId}_${String(claims.length + 1).padStart(3, "0")}`,
1025
+ text: `The local JSON record presents ${repo.repo} as a ${category} repository candidate.`,
1026
+ subject_entity_id: entityId,
1027
+ source_refs: [sourceId],
1028
+ provenance_refs: [prov.provenance_id],
1029
+ confidence,
1030
+ confidence_tier: confidenceBand,
1031
+ contested: false,
1032
+ });
1033
+
1034
+ relations.push({
1035
+ relation_id: `rel_${sourceId}_${String(relations.length + 1).padStart(3, "0")}`,
1036
+ source_id: sourceId,
1037
+ target_id: entityId,
1038
+ relation_type: "source_mentions_entity",
1039
+ evidence: "The local JSON record includes a GitHub repository URL.",
1040
+ source_refs: [sourceId],
1041
+ provenance_refs: [prov.provenance_id],
1042
+ confidence,
1043
+ confidence_tier: confidenceBand,
1044
+ });
1045
+
1046
+ for (const conceptRule of concepts) {
1047
+ if (!conceptsById.has(conceptRule.concept_id)) {
1048
+ conceptsById.set(conceptRule.concept_id, {
1049
+ ...conceptRule,
1050
+ related_entities: conceptRule.related_entities || [],
1051
+ source_refs: conceptRule.source_refs || [],
1052
+ provenance_refs: conceptRule.provenance_refs || [],
1053
+ confidence: conceptRule.confidence || 0.6,
1054
+ confidence_tier: conceptRule.confidence_tier || "medium",
1055
+ target_page_path: conceptRule.target_page_path || `wiki/concepts/${safeSlug(conceptRule.name, "concept")}.md`,
1056
+ });
1057
+ }
1058
+ const concept = conceptsById.get(conceptRule.concept_id);
1059
+ if (!concept.source_refs.includes(sourceId)) concept.source_refs.push(sourceId);
1060
+ if (!concept.provenance_refs.includes(prov.provenance_id)) concept.provenance_refs.push(prov.provenance_id);
1061
+ if (!concept.related_entities.includes(entityId)) concept.related_entities.push(entityId);
1062
+ relations.push({
1063
+ relation_id: `rel_${sourceId}_${String(relations.length + 1).padStart(3, "0")}`,
1064
+ source_id: entityId,
1065
+ target_id: conceptRule.concept_id,
1066
+ relation_type: "entity_implements_concept",
1067
+ evidence: "The local JSON record includes matching topics or semantic keywords.",
1068
+ source_refs: [sourceId],
1069
+ provenance_refs: [prov.provenance_id],
1070
+ confidence: Math.min(0.8, confidence + 0.05),
1071
+ confidence_tier: confidenceTier(Math.min(0.8, confidence + 0.05)),
1072
+ });
1073
+ }
1074
+ }
1075
+ });
1076
+ }
1077
+
1078
+ for (const [sourceUrl, sourceRecords] of sourceUrlUsage.entries()) {
1079
+ if (sourceRecords.length < 2) continue;
1080
+ const duplicateGroupId = `dup_${sha256(sourceUrl).slice(0, 12)}`;
1081
+ for (const record of sourceRecords) {
1082
+ record.source_quality.duplicate_candidate = true;
1083
+ record.source_quality.duplicate_group_id = duplicateGroupId;
1084
+ if (!record.source_quality.quality_flags.includes("duplicate_source_candidate")) {
1085
+ record.source_quality.quality_flags.push("duplicate_source_candidate");
1086
+ }
1087
+ sourceQualityFindings.push({
1088
+ finding_id: `sq_duplicate_${record.source_id}`,
1089
+ source_id: record.source_id,
1090
+ source_relative_path: record.source_relative_path,
1091
+ source_logical_path: record.source_logical_path,
1092
+ quality_flags: ["duplicate_source_candidate"],
1093
+ duplicate_group_id: duplicateGroupId,
1094
+ confidence: 0.8,
1095
+ confidence_tier: "high",
1096
+ notes: [`Duplicate source URL candidate: ${sourceUrl}`],
1097
+ });
1098
+ }
1099
+ }
1100
+
1101
+ const toolEntityValues = [...toolEntitiesById.values()];
1102
+ for (const entity of toolEntityValues) {
1103
+ const entityTopics = new Set(asArray(entity.topics).map((topic) => String(topic).toLowerCase()));
1104
+ const related = [];
1105
+ for (const candidate of toolEntityValues) {
1106
+ if (candidate.entity_id === entity.entity_id) continue;
1107
+ const sharedTopics = asArray(candidate.topics).filter((topic) => entityTopics.has(String(topic).toLowerCase()));
1108
+ const sameCategory = entity.category && candidate.category && entity.category === candidate.category;
1109
+ if (sharedTopics.length === 0 && !sameCategory) continue;
1110
+ related.push({
1111
+ entity_id: candidate.entity_id,
1112
+ name: candidate.name || candidate.repo_name || candidate.entity_id,
1113
+ github_url: candidate.github_url || null,
1114
+ shared_topics: sharedTopics,
1115
+ relation_hint: sharedTopics.length > 0 ? "shared_topic" : "same_category",
1116
+ });
1117
+ }
1118
+ entity.related_repos = related
1119
+ .sort((a, b) => b.shared_topics.length - a.shared_topics.length || a.entity_id.localeCompare(b.entity_id))
1120
+ .slice(0, 8);
1121
+ }
1122
+
1123
+ for (const concept of conceptsById.values()) {
1124
+ const relatedDetails = [];
1125
+ const axisCounts = new Map();
1126
+ for (const entityId of asArray(concept.related_entities)) {
1127
+ const entity = toolEntitiesById.get(entityId);
1128
+ if (!entity) continue;
1129
+ for (const topic of asArray(entity.topics)) {
1130
+ const key = String(topic || "").trim();
1131
+ if (key) axisCounts.set(key, (axisCounts.get(key) || 0) + 1);
1132
+ }
1133
+ if (entity.category) axisCounts.set(entity.category, (axisCounts.get(entity.category) || 0) + 1);
1134
+ relatedDetails.push({
1135
+ entity_id: entity.entity_id,
1136
+ name: entity.name || entity.repo_name || entity.entity_id,
1137
+ repo_owner: entity.repo_owner || null,
1138
+ repo_name: entity.repo_name || null,
1139
+ github_url: entity.github_url || null,
1140
+ category: entity.category || "uncategorized",
1141
+ topics: asArray(entity.topics).slice(0, 8),
1142
+ summary: entity.summary || "",
1143
+ target_page_path: entity.target_page_path || null,
1144
+ source_refs: sourceRefsForEntity(entity),
1145
+ confidence: entity.confidence,
1146
+ confidence_tier: entity.confidence_tier || confidenceTier(entity.confidence),
1147
+ });
1148
+ }
1149
+ concept.related_entity_details = relatedDetails
1150
+ .sort((a, b) => (b.source_refs.length - a.source_refs.length) || a.name.localeCompare(b.name))
1151
+ .slice(0, 12);
1152
+ concept.key_axes = [...axisCounts.entries()]
1153
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
1154
+ .slice(0, 8)
1155
+ .map(([name, count]) => ({ name, evidence_count: count }));
1156
+ concept.patterns = concept.key_axes.slice(0, 5).map((axis) => ({
1157
+ pattern: axis.name,
1158
+ evidence: `${axis.evidence_count} locally extracted tool candidate(s) are associated with this axis.`,
1159
+ }));
1160
+ }
1161
+
1162
+ const toolEntities = [...toolEntitiesById.values()].sort((a, b) => a.entity_id.localeCompare(b.entity_id));
1163
+ const concepts = [...conceptsById.values()].sort((a, b) => a.concept_id.localeCompare(b.concept_id));
1164
+ const comparisons = [];
1165
+ const syntheses = [];
1166
+
1167
+ for (const concept of concepts) {
1168
+ if (concept.related_entities.length < 2) continue;
1169
+ const topicSlug = safeSlug(concept.name, "topic");
1170
+ const comparedDetails = asArray(concept.related_entity_details).slice(0, 8);
1171
+ const decisionAxes = asArray(concept.key_axes).slice(0, 6);
1172
+ const matrixRows = comparedDetails.map((entity) => ({
1173
+ entity_id: entity.entity_id,
1174
+ name: entity.repo_owner && entity.repo_name ? `${entity.repo_owner}/${entity.repo_name}` : entity.name,
1175
+ github_url: entity.github_url || null,
1176
+ category: entity.category || "uncategorized",
1177
+ topics: asArray(entity.topics).slice(0, 6),
1178
+ strengths: decisionStrengthsForEntity(entity, concept.name),
1179
+ caveats: decisionCaveatsForEntity(entity),
1180
+ source_ref_count: sourceRefsForEntity(entity).length,
1181
+ source_confidence: entity.confidence_tier || confidenceTier(entity.confidence),
1182
+ local_recommendation: sourceRefsForEntity(entity).length > 1
1183
+ ? "shortlist for human review"
1184
+ : "keep as a candidate until more local evidence is available",
1185
+ }));
1186
+ const summary = `Local sources mention ${concept.related_entities.length} tool candidate(s) related to ${concept.name}. This page compares candidates for review, not verified ranking.`;
1187
+ const recommendations = matrixRows.slice(0, 3).map((row) => `${row.name}: ${row.local_recommendation}; strengths: ${row.strengths.slice(0, 2).join("; ")}.`);
1188
+ comparisons.push({
1189
+ comparison_id: `comparison_${topicSlug}_${sha256(concept.related_entities.join("|")).slice(0, 8)}`,
1190
+ topic: concept.name,
1191
+ compared_entities: concept.related_entities.slice(0, 8),
1192
+ compared_entity_details: comparedDetails,
1193
+ decision_axes: decisionAxes,
1194
+ matrix_rows: matrixRows,
1195
+ criteria: ["local evidence", "category/topic fit", "source confidence", "review caveats"],
1196
+ summary,
1197
+ recommendations,
1198
+ caveats: [
1199
+ "The comparison uses local source evidence only.",
1200
+ "Do not treat ordering as an external quality ranking.",
1201
+ "Verify license, maintenance, security, and ecosystem fit before adoption.",
1202
+ ],
1203
+ source_confidence_summary: confidenceSummary(matrixRows),
1204
+ source_refs: concept.source_refs,
1205
+ provenance_refs: concept.provenance_refs,
1206
+ confidence: 0.55,
1207
+ confidence_tier: "medium",
1208
+ target_page_path: `wiki/comparisons/${topicSlug}.md`,
1209
+ });
1210
+ syntheses.push({
1211
+ synthesis_id: `synthesis_${topicSlug}_${sha256(concept.source_refs.join("|")).slice(0, 8)}`,
1212
+ topic: concept.name,
1213
+ summary,
1214
+ landscape_axes: decisionAxes,
1215
+ candidate_tools: matrixRows,
1216
+ patterns: asArray(concept.patterns),
1217
+ related_comparison_path: `wiki/comparisons/${topicSlug}.md`,
1218
+ source_confidence_summary: confidenceSummary(matrixRows),
1219
+ recommendations: [
1220
+ ...recommendations,
1221
+ "Use this synthesis to select review candidates; defer adoption until external verification is complete.",
1222
+ ],
1223
+ caveats: [
1224
+ "Local extraction can include stale or incomplete source snippets.",
1225
+ "No web verification, GitHub API data, stars, licenses, or release cadence are claimed here.",
1226
+ "Human review should resolve topic fit and source-quality warnings before product decisions.",
1227
+ ],
1228
+ source_refs: concept.source_refs,
1229
+ provenance_refs: concept.provenance_refs,
1230
+ confidence: 0.55,
1231
+ confidence_tier: "medium",
1232
+ target_page_path: `wiki/syntheses/${topicSlug}.md`,
1233
+ });
1234
+ }
1235
+
1236
+ const lowConfidence = sourceQualityFindings.filter((finding) => ["low", "unsupported"].includes(finding.confidence_tier)).length;
1237
+
1238
+ return {
1239
+ schema_version: SCHEMA_VERSION,
1240
+ record_type: "sdtk_wiki_semantic_extraction",
1241
+ generated_at: generatedAt,
1242
+ project_path: projectPath,
1243
+ source_root_refs: [sourceRootRef],
1244
+ source_counts: {
1245
+ scanned: collected.scanned + collectedJson.scanned,
1246
+ indexed: sources.length,
1247
+ extracted: toolEntities.length,
1248
+ skipped: collected.skipped.length,
1249
+ low_confidence: lowConfidence,
1250
+ unsupported: unsupportedItems.length,
1251
+ },
1252
+ skipped_sources: collected.skipped,
1253
+ sources,
1254
+ tool_entities: toolEntities,
1255
+ concepts,
1256
+ claims,
1257
+ relations,
1258
+ comparisons,
1259
+ syntheses,
1260
+ source_quality_findings: sourceQualityFindings,
1261
+ unsupported_items: unsupportedItems,
1262
+ provenance,
1263
+ };
1264
+ }
1265
+
1266
+ function resolveSourceRoot(sourceRootArg) {
1267
+ if (!sourceRootArg) {
1268
+ throw new ValidationError("sdtk-brain wiki extract requires --source-root <path>. No project files were changed.");
1269
+ }
1270
+ if (isRemoteUrl(sourceRootArg)) {
1271
+ throw new ValidationError("Remote URL source roots are not supported for semantic extraction dry-run. No project files were changed.");
1272
+ }
1273
+ const resolved = path.resolve(sourceRootArg);
1274
+ if (!fs.existsSync(resolved)) {
1275
+ throw new ValidationError(`--source-root does not exist: ${resolved}. No project files were changed.`);
1276
+ }
1277
+ const stat = fs.statSync(resolved);
1278
+ if (!stat.isDirectory() && !stat.isFile()) {
1279
+ throw new ValidationError(`--source-root is not a file or directory: ${resolved}. No project files were changed.`);
1280
+ }
1281
+ return resolved;
1282
+ }
1283
+
1284
+ function runWikiExtractDryRun({ projectPath, sourceRootArg }) {
1285
+ const resolvedProjectPath = resolveProjectPath(projectPath || process.cwd());
1286
+ if (!fs.existsSync(resolvedProjectPath) || !fs.statSync(resolvedProjectPath).isDirectory()) {
1287
+ throw new ValidationError(`--project-path is not a valid directory: ${resolvedProjectPath}`);
1288
+ }
1289
+ const sourceRoot = resolveSourceRoot(sourceRootArg);
1290
+
1291
+ try {
1292
+ const reportsPath = getWikiReportsPath(resolvedProjectPath);
1293
+ assertWikiWorkspaceWritePath(reportsPath, resolvedProjectPath);
1294
+ const extraction = buildExtraction({ projectPath: resolvedProjectPath, sourceRoot });
1295
+ const reportPath = path.join(reportsPath, `${REPORT_PREFIX}-${timestampStamp(new Date(extraction.generated_at))}.json`);
1296
+ assertWikiWorkspaceWritePath(reportPath, resolvedProjectPath);
1297
+ fs.mkdirSync(reportsPath, { recursive: true });
1298
+ fs.writeFileSync(reportPath, JSON.stringify(extraction, null, 2) + "\n", "utf-8");
1299
+ return {
1300
+ reportPath,
1301
+ extraction,
1302
+ };
1303
+ } catch (error) {
1304
+ if (error instanceof CliError) throw error;
1305
+ throw new CliError(`Failed to write SDTK-BRAIN semantic extraction dry-run report: ${error.message}`);
1306
+ }
1307
+ }
1308
+
1309
+ module.exports = {
1310
+ REPORT_PREFIX,
1311
+ buildExtraction,
1312
+ runWikiExtractDryRun,
1313
+ };