@mseep/core 3.0.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.
- package/CHANGELOG.md +285 -0
- package/LICENSE +21 -0
- package/README.ja.md +14 -0
- package/README.ko.md +14 -0
- package/README.md +227 -0
- package/README.pt-BR.md +14 -0
- package/README.skills.md +50 -0
- package/README.uk.md +14 -0
- package/README.zh-CN.md +14 -0
- package/bin/booklib-mcp.js +458 -0
- package/bin/booklib.js +2394 -0
- package/bin/skills.cjs +1292 -0
- package/community/registry.json +1616 -0
- package/hooks/hooks.json +52 -0
- package/hooks/posttooluse-capture.mjs +67 -0
- package/hooks/posttooluse-contradict.mjs +76 -0
- package/hooks/posttooluse-imports.mjs +67 -0
- package/hooks/pretooluse-inject.mjs +82 -0
- package/hooks/suggest.js +153 -0
- package/lib/agent-detector.js +96 -0
- package/lib/config-loader.js +39 -0
- package/lib/conflict-resolver.js +148 -0
- package/lib/connectors/context7.js +167 -0
- package/lib/connectors/github.js +223 -0
- package/lib/connectors/local.js +120 -0
- package/lib/connectors/notion.js +436 -0
- package/lib/connectors/web.js +134 -0
- package/lib/context-builder.js +574 -0
- package/lib/discovery-engine.js +298 -0
- package/lib/doctor/hook-installer.js +83 -0
- package/lib/doctor/usage-tracker.js +87 -0
- package/lib/engine/auditor.js +103 -0
- package/lib/engine/auto-linker.js +177 -0
- package/lib/engine/bm25-index.js +178 -0
- package/lib/engine/capture.js +120 -0
- package/lib/engine/context-map.js +641 -0
- package/lib/engine/corrections.js +194 -0
- package/lib/engine/decision-checker.js +203 -0
- package/lib/engine/doctor.js +207 -0
- package/lib/engine/embedding-provider.js +72 -0
- package/lib/engine/gap-detector.js +138 -0
- package/lib/engine/gap-resolver.js +135 -0
- package/lib/engine/graph-injector.js +137 -0
- package/lib/engine/graph-search.js +183 -0
- package/lib/engine/graph.js +170 -0
- package/lib/engine/handoff.js +411 -0
- package/lib/engine/import-checker.js +249 -0
- package/lib/engine/import-parser.js +145 -0
- package/lib/engine/indexer.js +334 -0
- package/lib/engine/lookup-priority.js +15 -0
- package/lib/engine/parser.js +257 -0
- package/lib/engine/principle-extractor.js +116 -0
- package/lib/engine/project-analyzer.js +353 -0
- package/lib/engine/query-expander.js +42 -0
- package/lib/engine/reasoning-modes.js +353 -0
- package/lib/engine/registries.js +524 -0
- package/lib/engine/reranker.js +45 -0
- package/lib/engine/rrf.js +59 -0
- package/lib/engine/scanner.js +151 -0
- package/lib/engine/searcher.js +223 -0
- package/lib/engine/session-coordinator.js +291 -0
- package/lib/engine/session-manager.js +375 -0
- package/lib/engine/source-detector.js +240 -0
- package/lib/engine/source-manager.js +142 -0
- package/lib/engine/structured-response.js +47 -0
- package/lib/engine/synthesis-templates.js +364 -0
- package/lib/installer.js +70 -0
- package/lib/instinct-block.js +21 -0
- package/lib/mcp-config-writer.js +107 -0
- package/lib/paths.js +62 -0
- package/lib/project-initializer.js +856 -0
- package/lib/registry/skills.js +102 -0
- package/lib/registry-searcher.js +107 -0
- package/lib/rules/rules-manager.js +169 -0
- package/lib/skill-fetcher.js +333 -0
- package/lib/well-known-builder.js +74 -0
- package/lib/wizard/index.js +1389 -0
- package/lib/wizard/integration-detector.js +41 -0
- package/lib/wizard/project-detector.js +146 -0
- package/lib/wizard/prompt.js +221 -0
- package/lib/wizard/registry-embeddings.js +107 -0
- package/lib/wizard/skill-recommender.js +69 -0
- package/package.json +70 -0
- package/skills/animation-at-work/SKILL.md +270 -0
- package/skills/animation-at-work/assets/example_asset.txt +1 -0
- package/skills/animation-at-work/evals/evals.json +44 -0
- package/skills/animation-at-work/evals/results.json +13 -0
- package/skills/animation-at-work/examples/after.md +64 -0
- package/skills/animation-at-work/examples/before.md +35 -0
- package/skills/animation-at-work/references/api_reference.md +369 -0
- package/skills/animation-at-work/references/review-checklist.md +79 -0
- package/skills/animation-at-work/scripts/audit_animations.py +295 -0
- package/skills/animation-at-work/scripts/example.py +1 -0
- package/skills/booklib-mcp-guide/SKILL.md +129 -0
- package/skills/booklib-mcp-guide/evals/evals.json +37 -0
- package/skills/booklib-mcp-guide/examples/after.md +34 -0
- package/skills/booklib-mcp-guide/examples/before.md +27 -0
- package/skills/booklib-mcp-guide/references/tool-catalog.md +9 -0
- package/skills/clean-code-reviewer/SKILL.md +444 -0
- package/skills/clean-code-reviewer/audit.json +35 -0
- package/skills/clean-code-reviewer/evals/evals.json +185 -0
- package/skills/clean-code-reviewer/evals/results.json +13 -0
- package/skills/clean-code-reviewer/examples/after.md +48 -0
- package/skills/clean-code-reviewer/examples/before.md +33 -0
- package/skills/clean-code-reviewer/references/api_reference.md +158 -0
- package/skills/clean-code-reviewer/references/practices-catalog.md +282 -0
- package/skills/clean-code-reviewer/references/review-checklist.md +254 -0
- package/skills/clean-code-reviewer/scripts/pre-review.py +206 -0
- package/skills/data-intensive-patterns/SKILL.md +267 -0
- package/skills/data-intensive-patterns/assets/example_asset.txt +1 -0
- package/skills/data-intensive-patterns/evals/evals.json +54 -0
- package/skills/data-intensive-patterns/evals/results.json +13 -0
- package/skills/data-intensive-patterns/examples/after.md +61 -0
- package/skills/data-intensive-patterns/examples/before.md +38 -0
- package/skills/data-intensive-patterns/references/api_reference.md +34 -0
- package/skills/data-intensive-patterns/references/patterns-catalog.md +551 -0
- package/skills/data-intensive-patterns/references/review-checklist.md +193 -0
- package/skills/data-intensive-patterns/scripts/adr.py +213 -0
- package/skills/data-intensive-patterns/scripts/example.py +1 -0
- package/skills/data-pipelines/SKILL.md +259 -0
- package/skills/data-pipelines/assets/example_asset.txt +1 -0
- package/skills/data-pipelines/evals/evals.json +45 -0
- package/skills/data-pipelines/evals/results.json +13 -0
- package/skills/data-pipelines/examples/after.md +97 -0
- package/skills/data-pipelines/examples/before.md +37 -0
- package/skills/data-pipelines/references/api_reference.md +301 -0
- package/skills/data-pipelines/references/review-checklist.md +181 -0
- package/skills/data-pipelines/scripts/example.py +1 -0
- package/skills/data-pipelines/scripts/new_pipeline.py +444 -0
- package/skills/design-patterns/SKILL.md +271 -0
- package/skills/design-patterns/assets/example_asset.txt +1 -0
- package/skills/design-patterns/evals/evals.json +46 -0
- package/skills/design-patterns/evals/results.json +13 -0
- package/skills/design-patterns/examples/after.md +52 -0
- package/skills/design-patterns/examples/before.md +29 -0
- package/skills/design-patterns/references/api_reference.md +1 -0
- package/skills/design-patterns/references/patterns-catalog.md +726 -0
- package/skills/design-patterns/references/review-checklist.md +173 -0
- package/skills/design-patterns/scripts/example.py +1 -0
- package/skills/design-patterns/scripts/scaffold.py +807 -0
- package/skills/domain-driven-design/SKILL.md +142 -0
- package/skills/domain-driven-design/assets/example_asset.txt +1 -0
- package/skills/domain-driven-design/evals/evals.json +48 -0
- package/skills/domain-driven-design/evals/results.json +13 -0
- package/skills/domain-driven-design/examples/after.md +80 -0
- package/skills/domain-driven-design/examples/before.md +43 -0
- package/skills/domain-driven-design/references/api_reference.md +1 -0
- package/skills/domain-driven-design/references/patterns-catalog.md +545 -0
- package/skills/domain-driven-design/references/review-checklist.md +158 -0
- package/skills/domain-driven-design/scripts/example.py +1 -0
- package/skills/domain-driven-design/scripts/scaffold.py +421 -0
- package/skills/effective-java/SKILL.md +227 -0
- package/skills/effective-java/assets/example_asset.txt +1 -0
- package/skills/effective-java/evals/evals.json +46 -0
- package/skills/effective-java/evals/results.json +13 -0
- package/skills/effective-java/examples/after.md +83 -0
- package/skills/effective-java/examples/before.md +37 -0
- package/skills/effective-java/references/api_reference.md +1 -0
- package/skills/effective-java/references/items-catalog.md +955 -0
- package/skills/effective-java/references/review-checklist.md +216 -0
- package/skills/effective-java/scripts/checkstyle_setup.py +211 -0
- package/skills/effective-java/scripts/example.py +1 -0
- package/skills/effective-kotlin/SKILL.md +271 -0
- package/skills/effective-kotlin/assets/example_asset.txt +1 -0
- package/skills/effective-kotlin/audit.json +29 -0
- package/skills/effective-kotlin/evals/evals.json +45 -0
- package/skills/effective-kotlin/evals/results.json +13 -0
- package/skills/effective-kotlin/examples/after.md +36 -0
- package/skills/effective-kotlin/examples/before.md +38 -0
- package/skills/effective-kotlin/references/api_reference.md +1 -0
- package/skills/effective-kotlin/references/practices-catalog.md +1228 -0
- package/skills/effective-kotlin/references/review-checklist.md +126 -0
- package/skills/effective-kotlin/scripts/example.py +1 -0
- package/skills/effective-python/SKILL.md +441 -0
- package/skills/effective-python/evals/evals.json +44 -0
- package/skills/effective-python/evals/results.json +13 -0
- package/skills/effective-python/examples/after.md +56 -0
- package/skills/effective-python/examples/before.md +40 -0
- package/skills/effective-python/ref-01-pythonic-thinking.md +202 -0
- package/skills/effective-python/ref-02-lists-and-dicts.md +146 -0
- package/skills/effective-python/ref-03-functions.md +186 -0
- package/skills/effective-python/ref-04-comprehensions-generators.md +211 -0
- package/skills/effective-python/ref-05-classes-interfaces.md +188 -0
- package/skills/effective-python/ref-06-metaclasses-attributes.md +209 -0
- package/skills/effective-python/ref-07-concurrency.md +213 -0
- package/skills/effective-python/ref-08-robustness-performance.md +248 -0
- package/skills/effective-python/ref-09-testing-debugging.md +253 -0
- package/skills/effective-python/ref-10-collaboration.md +175 -0
- package/skills/effective-python/references/api_reference.md +218 -0
- package/skills/effective-python/references/practices-catalog.md +483 -0
- package/skills/effective-python/references/review-checklist.md +190 -0
- package/skills/effective-python/scripts/lint.py +173 -0
- package/skills/effective-typescript/SKILL.md +262 -0
- package/skills/effective-typescript/audit.json +29 -0
- package/skills/effective-typescript/evals/evals.json +37 -0
- package/skills/effective-typescript/evals/results.json +13 -0
- package/skills/effective-typescript/examples/after.md +70 -0
- package/skills/effective-typescript/examples/before.md +47 -0
- package/skills/effective-typescript/references/api_reference.md +118 -0
- package/skills/effective-typescript/references/practices-catalog.md +371 -0
- package/skills/effective-typescript/scripts/review.py +169 -0
- package/skills/kotlin-in-action/SKILL.md +261 -0
- package/skills/kotlin-in-action/assets/example_asset.txt +1 -0
- package/skills/kotlin-in-action/evals/evals.json +43 -0
- package/skills/kotlin-in-action/evals/results.json +13 -0
- package/skills/kotlin-in-action/examples/after.md +53 -0
- package/skills/kotlin-in-action/examples/before.md +39 -0
- package/skills/kotlin-in-action/references/api_reference.md +1 -0
- package/skills/kotlin-in-action/references/practices-catalog.md +436 -0
- package/skills/kotlin-in-action/references/review-checklist.md +204 -0
- package/skills/kotlin-in-action/scripts/example.py +1 -0
- package/skills/kotlin-in-action/scripts/setup_detekt.py +224 -0
- package/skills/lean-startup/SKILL.md +160 -0
- package/skills/lean-startup/assets/example_asset.txt +1 -0
- package/skills/lean-startup/evals/evals.json +43 -0
- package/skills/lean-startup/evals/results.json +13 -0
- package/skills/lean-startup/examples/after.md +80 -0
- package/skills/lean-startup/examples/before.md +34 -0
- package/skills/lean-startup/references/api_reference.md +319 -0
- package/skills/lean-startup/references/review-checklist.md +137 -0
- package/skills/lean-startup/scripts/example.py +1 -0
- package/skills/lean-startup/scripts/new_experiment.py +286 -0
- package/skills/microservices-patterns/SKILL.md +384 -0
- package/skills/microservices-patterns/evals/evals.json +45 -0
- package/skills/microservices-patterns/evals/results.json +13 -0
- package/skills/microservices-patterns/examples/after.md +69 -0
- package/skills/microservices-patterns/examples/before.md +40 -0
- package/skills/microservices-patterns/references/patterns-catalog.md +391 -0
- package/skills/microservices-patterns/references/review-checklist.md +169 -0
- package/skills/microservices-patterns/scripts/new_service.py +583 -0
- package/skills/programming-with-rust/SKILL.md +209 -0
- package/skills/programming-with-rust/evals/evals.json +37 -0
- package/skills/programming-with-rust/evals/results.json +13 -0
- package/skills/programming-with-rust/examples/after.md +107 -0
- package/skills/programming-with-rust/examples/before.md +59 -0
- package/skills/programming-with-rust/references/api_reference.md +152 -0
- package/skills/programming-with-rust/references/practices-catalog.md +335 -0
- package/skills/programming-with-rust/scripts/review.py +142 -0
- package/skills/refactoring-ui/SKILL.md +362 -0
- package/skills/refactoring-ui/assets/example_asset.txt +1 -0
- package/skills/refactoring-ui/evals/evals.json +45 -0
- package/skills/refactoring-ui/evals/results.json +13 -0
- package/skills/refactoring-ui/examples/after.md +85 -0
- package/skills/refactoring-ui/examples/before.md +58 -0
- package/skills/refactoring-ui/references/api_reference.md +355 -0
- package/skills/refactoring-ui/references/review-checklist.md +114 -0
- package/skills/refactoring-ui/scripts/audit_css.py +250 -0
- package/skills/refactoring-ui/scripts/example.py +1 -0
- package/skills/rust-in-action/SKILL.md +350 -0
- package/skills/rust-in-action/evals/evals.json +38 -0
- package/skills/rust-in-action/evals/results.json +13 -0
- package/skills/rust-in-action/examples/after.md +156 -0
- package/skills/rust-in-action/examples/before.md +56 -0
- package/skills/rust-in-action/references/practices-catalog.md +346 -0
- package/skills/rust-in-action/scripts/review.py +147 -0
- package/skills/skill-router/SKILL.md +186 -0
- package/skills/skill-router/evals/evals.json +38 -0
- package/skills/skill-router/evals/results.json +13 -0
- package/skills/skill-router/examples/after.md +63 -0
- package/skills/skill-router/examples/before.md +39 -0
- package/skills/skill-router/references/api_reference.md +24 -0
- package/skills/skill-router/references/routing-heuristics.md +89 -0
- package/skills/skill-router/references/skill-catalog.md +174 -0
- package/skills/skill-router/scripts/route.py +266 -0
- package/skills/spring-boot-in-action/SKILL.md +340 -0
- package/skills/spring-boot-in-action/evals/evals.json +39 -0
- package/skills/spring-boot-in-action/evals/results.json +13 -0
- package/skills/spring-boot-in-action/examples/after.md +185 -0
- package/skills/spring-boot-in-action/examples/before.md +84 -0
- package/skills/spring-boot-in-action/references/practices-catalog.md +403 -0
- package/skills/spring-boot-in-action/scripts/review.py +184 -0
- package/skills/storytelling-with-data/SKILL.md +241 -0
- package/skills/storytelling-with-data/assets/example_asset.txt +1 -0
- package/skills/storytelling-with-data/evals/evals.json +47 -0
- package/skills/storytelling-with-data/evals/results.json +13 -0
- package/skills/storytelling-with-data/examples/after.md +50 -0
- package/skills/storytelling-with-data/examples/before.md +33 -0
- package/skills/storytelling-with-data/references/api_reference.md +379 -0
- package/skills/storytelling-with-data/references/review-checklist.md +111 -0
- package/skills/storytelling-with-data/scripts/chart_review.py +301 -0
- package/skills/storytelling-with-data/scripts/example.py +1 -0
- package/skills/system-design-interview/SKILL.md +233 -0
- package/skills/system-design-interview/assets/example_asset.txt +1 -0
- package/skills/system-design-interview/evals/evals.json +46 -0
- package/skills/system-design-interview/evals/results.json +13 -0
- package/skills/system-design-interview/examples/after.md +94 -0
- package/skills/system-design-interview/examples/before.md +27 -0
- package/skills/system-design-interview/references/api_reference.md +582 -0
- package/skills/system-design-interview/references/review-checklist.md +201 -0
- package/skills/system-design-interview/scripts/example.py +1 -0
- package/skills/system-design-interview/scripts/new_design.py +421 -0
- package/skills/using-asyncio-python/SKILL.md +290 -0
- package/skills/using-asyncio-python/assets/example_asset.txt +1 -0
- package/skills/using-asyncio-python/evals/evals.json +43 -0
- package/skills/using-asyncio-python/evals/results.json +13 -0
- package/skills/using-asyncio-python/examples/after.md +68 -0
- package/skills/using-asyncio-python/examples/before.md +39 -0
- package/skills/using-asyncio-python/references/api_reference.md +267 -0
- package/skills/using-asyncio-python/references/review-checklist.md +149 -0
- package/skills/using-asyncio-python/scripts/check_blocking.py +270 -0
- package/skills/using-asyncio-python/scripts/example.py +1 -0
- package/skills/web-scraping-python/SKILL.md +280 -0
- package/skills/web-scraping-python/assets/example_asset.txt +1 -0
- package/skills/web-scraping-python/evals/evals.json +46 -0
- package/skills/web-scraping-python/evals/results.json +13 -0
- package/skills/web-scraping-python/examples/after.md +109 -0
- package/skills/web-scraping-python/examples/before.md +40 -0
- package/skills/web-scraping-python/references/api_reference.md +393 -0
- package/skills/web-scraping-python/references/review-checklist.md +163 -0
- package/skills/web-scraping-python/scripts/example.py +1 -0
- package/skills/web-scraping-python/scripts/new_scraper.py +231 -0
- package/skills/writing-plans/audit.json +34 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves conflicts between skill suggestions or indexed chunks.
|
|
3
|
+
*
|
|
4
|
+
* Resolution rules (applied per topic group):
|
|
5
|
+
* specificity delta >= 2 OR score delta >= 0.2 → auto-resolve (higher wins, rationale attached)
|
|
6
|
+
* otherwise → escalate (add to conflicts list)
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* const resolver = new ConflictResolver(registryArray);
|
|
10
|
+
* const { winners, suppressed, conflicts } = resolver.resolveSkills(suggestions);
|
|
11
|
+
* const { winners, suppressed, conflicts } = resolver.resolveChunks(searchResults);
|
|
12
|
+
*/
|
|
13
|
+
export class ConflictResolver {
|
|
14
|
+
/**
|
|
15
|
+
* @param {Array<{name: string, specificity?: number, topic?: string, stars?: number}>} registry
|
|
16
|
+
*/
|
|
17
|
+
constructor(registry = []) {
|
|
18
|
+
this._byName = new Map(registry.map(s => [s.name, s]));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Resolves conflicts between registry-level skill suggestions.
|
|
23
|
+
* @param {object[]} skills — each must have .name
|
|
24
|
+
*/
|
|
25
|
+
resolveSkills(skills) {
|
|
26
|
+
return this._resolve(
|
|
27
|
+
skills,
|
|
28
|
+
s => s.name,
|
|
29
|
+
s => this._meta(s.name, s)
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolves conflicts between semantic-search chunks.
|
|
35
|
+
* @param {Array<{score: number, text: string, metadata: object}>} chunks
|
|
36
|
+
*/
|
|
37
|
+
resolveChunks(chunks) {
|
|
38
|
+
// Deduplicate by skill name — keep highest-scored chunk per skill
|
|
39
|
+
const bySkill = new Map();
|
|
40
|
+
for (const c of chunks) {
|
|
41
|
+
const raw = c.metadata?.name ?? c.metadata?.filePath ?? 'unknown';
|
|
42
|
+
// Normalise to skill name: take first path segment (handles reference file paths like
|
|
43
|
+
// "clean-code-reviewer/references/review-checklist.md" → "clean-code-reviewer")
|
|
44
|
+
const skillName = raw.includes('/') ? raw.split('/')[0] : raw;
|
|
45
|
+
const existing = bySkill.get(skillName);
|
|
46
|
+
if (!existing || (c.score ?? 0) > (existing.score ?? 0)) {
|
|
47
|
+
bySkill.set(skillName, c);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const deduped = [...bySkill.values()];
|
|
51
|
+
return this._resolve(
|
|
52
|
+
deduped,
|
|
53
|
+
c => c.metadata?.name ?? c.metadata?.filePath ?? 'unknown',
|
|
54
|
+
c => this._meta(c.metadata?.name, c)
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── Private ────────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
/** Look up registry metadata, falling back to inline values or defaults. */
|
|
61
|
+
_meta(name, item = {}) {
|
|
62
|
+
const reg = this._byName.get(name);
|
|
63
|
+
return {
|
|
64
|
+
specificity: reg?.specificity ?? item?.specificity ?? 5,
|
|
65
|
+
topic: reg?.topic ?? item?.topic ?? name ?? 'unknown',
|
|
66
|
+
stars: reg?.stars ?? item?.stars ?? 0,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
_resolve(items, getName, getMeta) {
|
|
71
|
+
// Group by topic
|
|
72
|
+
const byTopic = new Map();
|
|
73
|
+
for (const item of items) {
|
|
74
|
+
const { topic } = getMeta(item);
|
|
75
|
+
if (!byTopic.has(topic)) byTopic.set(topic, []);
|
|
76
|
+
byTopic.get(topic).push(item);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const winners = [];
|
|
80
|
+
const suppressed = [];
|
|
81
|
+
const conflicts = [];
|
|
82
|
+
|
|
83
|
+
for (const [, candidates] of byTopic) {
|
|
84
|
+
if (candidates.length === 1) {
|
|
85
|
+
winners.push({ ...candidates[0], _decision: 'auto', _rationale: null });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Sort: specificity desc → score desc → stars desc
|
|
90
|
+
const sorted = [...candidates].sort((a, b) => {
|
|
91
|
+
const ma = getMeta(a), mb = getMeta(b);
|
|
92
|
+
if (mb.specificity !== ma.specificity) return mb.specificity - ma.specificity;
|
|
93
|
+
if ((b.score ?? 0) !== (a.score ?? 0)) return (b.score ?? 0) - (a.score ?? 0);
|
|
94
|
+
return (mb.stars ?? 0) - (ma.stars ?? 0);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const best = sorted[0];
|
|
98
|
+
const bestMeta = getMeta(best);
|
|
99
|
+
let bestAdded = false;
|
|
100
|
+
|
|
101
|
+
for (const runner of sorted.slice(1)) {
|
|
102
|
+
const runnerMeta = getMeta(runner);
|
|
103
|
+
const dSpec = bestMeta.specificity - runnerMeta.specificity;
|
|
104
|
+
const dScore = (best.score ?? 0) - (runner.score ?? 0);
|
|
105
|
+
|
|
106
|
+
if (dSpec >= 2 || dScore >= 0.2) {
|
|
107
|
+
// Clear winner — auto-resolve silently
|
|
108
|
+
const reason = dSpec >= 2
|
|
109
|
+
? `more specific (${bestMeta.specificity} vs ${runnerMeta.specificity})`
|
|
110
|
+
: `higher relevance (${(best.score ?? 0).toFixed(2)} vs ${(runner.score ?? 0).toFixed(2)})`;
|
|
111
|
+
|
|
112
|
+
if (!bestAdded) {
|
|
113
|
+
winners.push({
|
|
114
|
+
...best,
|
|
115
|
+
_decision: 'auto',
|
|
116
|
+
_rationale: `chosen over \`${getName(runner)}\` — ${reason}`,
|
|
117
|
+
});
|
|
118
|
+
bestAdded = true;
|
|
119
|
+
}
|
|
120
|
+
suppressed.push({ ...runner, _decision: 'suppressed', _rationale: `\`${getName(best)}\` preferred — ${reason}` });
|
|
121
|
+
} else {
|
|
122
|
+
// Genuine conflict — escalate to human
|
|
123
|
+
if (!conflicts.find(c => c.options.some(o => o.name === getName(best)))) {
|
|
124
|
+
conflicts.push({
|
|
125
|
+
topic: bestMeta.topic,
|
|
126
|
+
options: sorted.map(s => ({
|
|
127
|
+
name: getName(s),
|
|
128
|
+
specificity: getMeta(s).specificity,
|
|
129
|
+
score: s.score ?? null,
|
|
130
|
+
})),
|
|
131
|
+
message:
|
|
132
|
+
`\`${getName(best)}\` vs \`${getName(runner)}\` — both equally applicable` +
|
|
133
|
+
` (specificity ${bestMeta.specificity} vs ${runnerMeta.specificity}).` +
|
|
134
|
+
` Which should guide this decision?`,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Add best as winner if no conflict was raised for it
|
|
141
|
+
if (!bestAdded && !conflicts.some(c => c.options.some(o => o.name === getName(best)))) {
|
|
142
|
+
winners.push({ ...best, _decision: 'auto', _rationale: null });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { winners, suppressed, conflicts };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
const CONTEXT7_API = 'https://context7.com/api';
|
|
5
|
+
const TIMEOUT_MS = 10000;
|
|
6
|
+
|
|
7
|
+
export class Context7Connector {
|
|
8
|
+
constructor(opts = {}) {
|
|
9
|
+
this.apiKey = opts.apiKey ?? process.env.CONTEXT7_API_KEY;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Context7 works without an API key (rate-limited).
|
|
14
|
+
* With a key, higher rate limits apply.
|
|
15
|
+
* @returns {{ ok: boolean }}
|
|
16
|
+
*/
|
|
17
|
+
checkAuth() {
|
|
18
|
+
return { ok: true };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Search for a library by name.
|
|
23
|
+
* @param {string} libraryName - e.g., "next", "react", "stripe"
|
|
24
|
+
* @param {string} [query] - optional query for relevance ranking
|
|
25
|
+
* @returns {Promise<Array<{ id: string, name: string, description: string, versions?: string[] }> | null>}
|
|
26
|
+
*/
|
|
27
|
+
async searchLibrary(libraryName, query) {
|
|
28
|
+
const params = new URLSearchParams({
|
|
29
|
+
libraryName,
|
|
30
|
+
query: query ?? libraryName,
|
|
31
|
+
});
|
|
32
|
+
const data = await this._apiGet(`/v2/libs/search?${params}`);
|
|
33
|
+
if (!data?.results) return null;
|
|
34
|
+
return data.results.map(r => ({
|
|
35
|
+
id: r.id ?? r.libraryId,
|
|
36
|
+
name: r.name ?? r.title ?? libraryName,
|
|
37
|
+
description: r.description ?? '',
|
|
38
|
+
versions: r.versions ?? [],
|
|
39
|
+
totalSnippets: r.totalSnippets ?? r.snippetCount ?? 0,
|
|
40
|
+
}));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Fetch documentation for a library and save as markdown files.
|
|
45
|
+
* @param {string} libraryId - Context7 library ID (e.g., "/vercel/next.js")
|
|
46
|
+
* @param {string} query - what to search for in the docs
|
|
47
|
+
* @param {string} outputDir - where to save markdown
|
|
48
|
+
* @returns {Promise<{ pageCount: number }>}
|
|
49
|
+
*/
|
|
50
|
+
async fetchDocs(libraryId, query, outputDir) {
|
|
51
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
52
|
+
|
|
53
|
+
// Request text format — returns rich markdown with code examples.
|
|
54
|
+
// JSON format returns a thin parsed version with less content.
|
|
55
|
+
const params = new URLSearchParams({ libraryId, query });
|
|
56
|
+
const text = await this._apiGetText(`/v2/context?${params}`);
|
|
57
|
+
|
|
58
|
+
if (!text || text.trim().length < 50) return { pageCount: 0 };
|
|
59
|
+
|
|
60
|
+
// Split by Context7's section separator into individual docs
|
|
61
|
+
const sections = text.split(/^-{20,}$/m).filter(s => s.trim());
|
|
62
|
+
|
|
63
|
+
if (sections.length === 0) {
|
|
64
|
+
// Single doc — save as-is
|
|
65
|
+
fs.writeFileSync(path.join(outputDir, 'docs.md'), text.trim() + '\n');
|
|
66
|
+
return { pageCount: 1 };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
let count = 0;
|
|
71
|
+
for (const section of sections) {
|
|
72
|
+
const trimmed = section.trim();
|
|
73
|
+
if (!trimmed) continue;
|
|
74
|
+
// Extract title from first heading or first line
|
|
75
|
+
const titleMatch = trimmed.match(/^###?\s+(.+)/m);
|
|
76
|
+
const title = titleMatch ? titleMatch[1].slice(0, 80) : `section-${count}`;
|
|
77
|
+
const filename = this._sanitize(title) + '.md';
|
|
78
|
+
fs.writeFileSync(path.join(outputDir, filename), trimmed + '\n');
|
|
79
|
+
count++;
|
|
80
|
+
}
|
|
81
|
+
return { pageCount: count };
|
|
82
|
+
} catch (err) {
|
|
83
|
+
fs.rmSync(outputDir, { recursive: true, force: true });
|
|
84
|
+
throw err;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Resolve a package name to a Context7 library and fetch its docs.
|
|
90
|
+
* Convenience method combining searchLibrary + fetchDocs.
|
|
91
|
+
* @param {string} packageName - e.g., "next", "react"
|
|
92
|
+
* @param {string} outputDir
|
|
93
|
+
* @param {string} [query] - what aspect to fetch docs for
|
|
94
|
+
* @returns {Promise<{ resolved: boolean, libraryId?: string, pageCount: number }>}
|
|
95
|
+
*/
|
|
96
|
+
async resolveAndFetch(packageName, outputDir, query) {
|
|
97
|
+
const libraries = await this.searchLibrary(
|
|
98
|
+
packageName,
|
|
99
|
+
query ?? `${packageName} API documentation`,
|
|
100
|
+
);
|
|
101
|
+
if (!libraries || libraries.length === 0) {
|
|
102
|
+
return { resolved: false, pageCount: 0 };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const best = libraries[0];
|
|
106
|
+
const result = await this.fetchDocs(
|
|
107
|
+
best.id,
|
|
108
|
+
query ?? `${packageName} usage guide API`,
|
|
109
|
+
outputDir,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
return { resolved: true, libraryId: best.id, pageCount: result.pageCount };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** GET request to Context7 API. */
|
|
116
|
+
async _apiGet(endpoint) {
|
|
117
|
+
const controller = new AbortController();
|
|
118
|
+
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
119
|
+
try {
|
|
120
|
+
const res = await fetch(`${CONTEXT7_API}${endpoint}`, {
|
|
121
|
+
signal: controller.signal,
|
|
122
|
+
headers: {
|
|
123
|
+
...(this.apiKey ? { 'Authorization': `Bearer ${this.apiKey}` } : {}),
|
|
124
|
+
'X-Context7-Source': 'booklib',
|
|
125
|
+
'User-Agent': 'BookLib/1.0',
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
if (!res.ok) return null;
|
|
129
|
+
return await res.json();
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
} finally {
|
|
133
|
+
clearTimeout(timeout);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** GET request returning raw text (for /v2/context text format). */
|
|
138
|
+
async _apiGetText(endpoint) {
|
|
139
|
+
const controller = new AbortController();
|
|
140
|
+
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
141
|
+
try {
|
|
142
|
+
const res = await fetch(`${CONTEXT7_API}${endpoint}`, {
|
|
143
|
+
signal: controller.signal,
|
|
144
|
+
headers: {
|
|
145
|
+
...(this.apiKey ? { 'Authorization': `Bearer ${this.apiKey}` } : {}),
|
|
146
|
+
'X-Context7-Source': 'booklib',
|
|
147
|
+
'User-Agent': 'BookLib/3.0',
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
if (!res.ok) return null;
|
|
151
|
+
return await res.text();
|
|
152
|
+
} catch {
|
|
153
|
+
return null;
|
|
154
|
+
} finally {
|
|
155
|
+
clearTimeout(timeout);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Sanitize a string for use as a filename. */
|
|
160
|
+
_sanitize(str) {
|
|
161
|
+
return str
|
|
162
|
+
.replace(/[/\\:*?"<>|]/g, '_')
|
|
163
|
+
.replace(/\s+/g, '-')
|
|
164
|
+
.slice(0, 80)
|
|
165
|
+
.toLowerCase();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
const RATE_MS = 1000;
|
|
6
|
+
|
|
7
|
+
export class GitHubConnector {
|
|
8
|
+
constructor(opts = {}) {
|
|
9
|
+
this.rateMs = opts.rateMs ?? RATE_MS;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Check if gh CLI is installed and authenticated.
|
|
14
|
+
* @returns {{ ok: boolean, error?: string }}
|
|
15
|
+
*/
|
|
16
|
+
checkAuth() {
|
|
17
|
+
try {
|
|
18
|
+
execFileSync('gh', ['auth', 'status'], { stdio: 'pipe' });
|
|
19
|
+
return { ok: true };
|
|
20
|
+
} catch {
|
|
21
|
+
return {
|
|
22
|
+
ok: false,
|
|
23
|
+
error: 'gh CLI not authenticated. Install: https://cli.github.com then run: gh auth login',
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Fetch releases from a GitHub repo and save as markdown.
|
|
30
|
+
* @param {string} repo - owner/repo format
|
|
31
|
+
* @param {string} outputDir - where to save markdown files
|
|
32
|
+
* @param {object} [opts]
|
|
33
|
+
* @param {number} [opts.limit=30] - max releases to fetch
|
|
34
|
+
* @param {string} [opts.since] - ISO date, only fetch releases after this
|
|
35
|
+
* @returns {Promise<{ pageCount: number, releases: string[] }>}
|
|
36
|
+
*/
|
|
37
|
+
async fetchReleases(repo, outputDir, opts = {}) {
|
|
38
|
+
const { limit = 30, since } = opts;
|
|
39
|
+
this._validateRepo(repo);
|
|
40
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
41
|
+
|
|
42
|
+
const data = this._ghApi(`/repos/${repo}/releases?per_page=${Math.min(limit, 100)}`);
|
|
43
|
+
let releases;
|
|
44
|
+
try {
|
|
45
|
+
releases = JSON.parse(data);
|
|
46
|
+
} catch {
|
|
47
|
+
return { pageCount: 0, releases: [] };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!Array.isArray(releases)) return { pageCount: 0, releases: [] };
|
|
51
|
+
|
|
52
|
+
const saved = [];
|
|
53
|
+
for (const release of releases) {
|
|
54
|
+
if (since && new Date(release.published_at) <= new Date(since)) continue;
|
|
55
|
+
|
|
56
|
+
const tag = release.tag_name.replace(/[/\\:*?"<>|]/g, '_');
|
|
57
|
+
const filename = `release-${tag}.md`;
|
|
58
|
+
const content = this._formatRelease(release);
|
|
59
|
+
fs.writeFileSync(path.join(outputDir, filename), content);
|
|
60
|
+
saved.push(filename);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { pageCount: saved.length, releases: saved };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Fetch wiki pages by cloning the wiki git repo.
|
|
68
|
+
* GitHub wikis live at <repo>.wiki.git — no REST API endpoint exists.
|
|
69
|
+
* @param {string} repo - owner/repo format
|
|
70
|
+
* @param {string} outputDir
|
|
71
|
+
* @returns {Promise<{ pageCount: number }>}
|
|
72
|
+
*/
|
|
73
|
+
async fetchWiki(repo, outputDir) {
|
|
74
|
+
this._validateRepo(repo);
|
|
75
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
76
|
+
|
|
77
|
+
const wikiUrl = `https://github.com/${repo}.wiki.git`;
|
|
78
|
+
try {
|
|
79
|
+
// Array form prevents shell injection via outputDir
|
|
80
|
+
execFileSync('git', ['clone', '--depth', '1', wikiUrl, outputDir], {
|
|
81
|
+
stdio: 'pipe',
|
|
82
|
+
timeout: 30000,
|
|
83
|
+
});
|
|
84
|
+
} catch (err) {
|
|
85
|
+
const msg = err.stderr?.toString() ?? '';
|
|
86
|
+
if (msg.includes('not found') || msg.includes('not exist')) {
|
|
87
|
+
return { pageCount: 0 };
|
|
88
|
+
}
|
|
89
|
+
throw new Error(`Wiki clone failed: ${msg.slice(0, 200)}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Remove .git directory — we only want the content files
|
|
93
|
+
const gitDir = path.join(outputDir, '.git');
|
|
94
|
+
if (fs.existsSync(gitDir)) {
|
|
95
|
+
fs.rmSync(gitDir, { recursive: true, force: true });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const files = fs.readdirSync(outputDir).filter(f => f.endsWith('.md'));
|
|
99
|
+
return { pageCount: files.length };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Fetch discussion threads via GraphQL API.
|
|
104
|
+
* @param {string} repo - owner/repo format
|
|
105
|
+
* @param {string} outputDir
|
|
106
|
+
* @param {object} [opts]
|
|
107
|
+
* @param {number} [opts.limit=20] - max discussions
|
|
108
|
+
* @param {string} [opts.category] - filter by category name
|
|
109
|
+
* @returns {Promise<{ pageCount: number }>}
|
|
110
|
+
*/
|
|
111
|
+
async fetchDiscussions(repo, outputDir, opts = {}) {
|
|
112
|
+
const { limit = 20, category } = opts;
|
|
113
|
+
this._validateRepo(repo);
|
|
114
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
115
|
+
|
|
116
|
+
const [owner, name] = repo.split('/');
|
|
117
|
+
const count = Math.min(limit, 100);
|
|
118
|
+
|
|
119
|
+
// Parameterized GraphQL query — variables passed via -f to prevent injection
|
|
120
|
+
const query = `query($owner: String!, $name: String!, $count: Int!) {
|
|
121
|
+
repository(owner: $owner, name: $name) {
|
|
122
|
+
discussions(first: $count, orderBy: {field: UPDATED_AT, direction: DESC}) {
|
|
123
|
+
nodes {
|
|
124
|
+
number title body createdAt updatedAt
|
|
125
|
+
category { name }
|
|
126
|
+
answer { body author { login } }
|
|
127
|
+
comments(first: 10) { nodes { body author { login } } }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}`;
|
|
132
|
+
|
|
133
|
+
let data;
|
|
134
|
+
try {
|
|
135
|
+
const raw = execFileSync('gh', [
|
|
136
|
+
'api', 'graphql',
|
|
137
|
+
'-f', `query=${query}`,
|
|
138
|
+
'-f', `owner=${owner}`,
|
|
139
|
+
'-f', `name=${name}`,
|
|
140
|
+
'-F', `count=${count}`,
|
|
141
|
+
], { stdio: 'pipe', timeout: 30000 });
|
|
142
|
+
data = JSON.parse(raw.toString());
|
|
143
|
+
} catch (err) {
|
|
144
|
+
const msg = err.stderr?.toString().slice(0, 200) ?? err.message;
|
|
145
|
+
console.error(`GitHub discussions fetch failed: ${msg}`);
|
|
146
|
+
return { pageCount: 0 };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const discussions = data?.data?.repository?.discussions?.nodes ?? [];
|
|
150
|
+
let saved = 0;
|
|
151
|
+
|
|
152
|
+
for (const disc of discussions) {
|
|
153
|
+
if (category && disc.category?.name !== category) continue;
|
|
154
|
+
|
|
155
|
+
const filename = `discussion-${disc.number}.md`;
|
|
156
|
+
const content = this._formatDiscussion(disc);
|
|
157
|
+
fs.writeFileSync(path.join(outputDir, filename), content);
|
|
158
|
+
saved++;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return { pageCount: saved };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Format a release object as markdown. */
|
|
165
|
+
_formatRelease(release) {
|
|
166
|
+
const lines = [
|
|
167
|
+
`# ${release.name || release.tag_name}`,
|
|
168
|
+
'',
|
|
169
|
+
`**Tag:** ${release.tag_name}`,
|
|
170
|
+
`**Published:** ${release.published_at?.split('T')[0] ?? 'unknown'}`,
|
|
171
|
+
release.prerelease ? '**Pre-release**' : '',
|
|
172
|
+
'',
|
|
173
|
+
release.body ?? '_No release notes._',
|
|
174
|
+
];
|
|
175
|
+
return lines.filter(l => l !== '').join('\n') + '\n';
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Format a discussion as markdown with comments. */
|
|
179
|
+
_formatDiscussion(disc) {
|
|
180
|
+
const lines = [
|
|
181
|
+
`# ${disc.title}`,
|
|
182
|
+
'',
|
|
183
|
+
`**Discussion #${disc.number}** — ${disc.category?.name ?? 'General'}`,
|
|
184
|
+
`**Created:** ${disc.createdAt?.split('T')[0] ?? 'unknown'}`,
|
|
185
|
+
'',
|
|
186
|
+
disc.body ?? '',
|
|
187
|
+
];
|
|
188
|
+
|
|
189
|
+
if (disc.answer) {
|
|
190
|
+
lines.push('', '---', `## Accepted Answer (by ${disc.answer.author?.login ?? 'unknown'})`, '', disc.answer.body);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const comments = disc.comments?.nodes ?? [];
|
|
194
|
+
if (comments.length > 0) {
|
|
195
|
+
lines.push('', '---', '## Comments', '');
|
|
196
|
+
for (const c of comments) {
|
|
197
|
+
lines.push(`**${c.author?.login ?? 'unknown'}:**`, c.body, '');
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return lines.join('\n') + '\n';
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Validate repo format is owner/name. */
|
|
205
|
+
_validateRepo(repo) {
|
|
206
|
+
if (!repo || !/^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/.test(repo)) {
|
|
207
|
+
throw new Error(`Invalid repo format: "${repo}". Use owner/repo (e.g., facebook/react)`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Call gh api and return stdout — array form prevents shell injection. */
|
|
212
|
+
_ghApi(endpoint) {
|
|
213
|
+
try {
|
|
214
|
+
return execFileSync('gh', ['api', endpoint], {
|
|
215
|
+
stdio: 'pipe',
|
|
216
|
+
timeout: 15000,
|
|
217
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
218
|
+
}).toString();
|
|
219
|
+
} catch (err) {
|
|
220
|
+
throw new Error(`GitHub API error: ${err.stderr?.toString().slice(0, 200) ?? err.message}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Local filesystem connector for BookLib.
|
|
6
|
+
* Walks directories, applies include/exclude filters, tracks mtimes for
|
|
7
|
+
* incremental re-indexing, and optionally watches for changes.
|
|
8
|
+
*/
|
|
9
|
+
export class LocalConnector {
|
|
10
|
+
constructor(opts = {}) {
|
|
11
|
+
this.include = opts.include ?? ['*.md', '*.mdx', '*.txt', '*.rst', '*.adoc'];
|
|
12
|
+
this.exclude = opts.exclude ?? ['node_modules', '.git', '.booklib'];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* List files matching include/exclude filters.
|
|
17
|
+
* @param {string} dirPath - Absolute directory path to walk.
|
|
18
|
+
* @returns {string[]} Sorted absolute file paths.
|
|
19
|
+
*/
|
|
20
|
+
listFiles(dirPath) {
|
|
21
|
+
const results = [];
|
|
22
|
+
this._walk(dirPath, dirPath, results);
|
|
23
|
+
return results.sort();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Get mtimes for all matching files, keyed by relative path.
|
|
28
|
+
* @param {string} dirPath
|
|
29
|
+
* @returns {Object} { [relativePath]: mtimeMs }
|
|
30
|
+
*/
|
|
31
|
+
getFileMtimes(dirPath) {
|
|
32
|
+
const files = this.listFiles(dirPath);
|
|
33
|
+
const mtimes = {};
|
|
34
|
+
for (const f of files) {
|
|
35
|
+
mtimes[path.relative(dirPath, f)] = fs.statSync(f).mtimeMs;
|
|
36
|
+
}
|
|
37
|
+
return mtimes;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Find files that changed since last index.
|
|
42
|
+
* @param {string} dirPath
|
|
43
|
+
* @param {Object} previousMtimes - { [relativePath]: mtimeMs } from last index.
|
|
44
|
+
* @returns {{ changed: string[], removed: string[], currentMtimes: Object }}
|
|
45
|
+
*/
|
|
46
|
+
findChanges(dirPath, previousMtimes = {}) {
|
|
47
|
+
const currentMtimes = this.getFileMtimes(dirPath);
|
|
48
|
+
const changed = [];
|
|
49
|
+
const removed = [];
|
|
50
|
+
|
|
51
|
+
for (const [rel, mtime] of Object.entries(currentMtimes)) {
|
|
52
|
+
if (!previousMtimes[rel] || previousMtimes[rel] < mtime) {
|
|
53
|
+
changed.push(path.join(dirPath, rel));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
for (const rel of Object.keys(previousMtimes)) {
|
|
58
|
+
if (!currentMtimes[rel]) {
|
|
59
|
+
removed.push(rel);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { changed, removed, currentMtimes };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Watch directory for changes, calling callback on each matching file event.
|
|
68
|
+
* @param {string} dirPath
|
|
69
|
+
* @param {function} onChange - Called with (eventType, filename).
|
|
70
|
+
* @returns {fs.FSWatcher}
|
|
71
|
+
*/
|
|
72
|
+
watch(dirPath, onChange) {
|
|
73
|
+
console.log(`Watching ${dirPath} for changes (Ctrl+C to stop)...`);
|
|
74
|
+
let debounceTimer = null;
|
|
75
|
+
const pending = new Set();
|
|
76
|
+
|
|
77
|
+
const watcher = fs.watch(dirPath, { recursive: true }, (eventType, filename) => {
|
|
78
|
+
if (!filename) return;
|
|
79
|
+
if (!this._matchesFilters(filename)) return;
|
|
80
|
+
pending.add(filename);
|
|
81
|
+
|
|
82
|
+
// Debounce: wait 500ms after last event before calling onChange
|
|
83
|
+
clearTimeout(debounceTimer);
|
|
84
|
+
debounceTimer = setTimeout(() => {
|
|
85
|
+
const files = [...pending];
|
|
86
|
+
pending.clear();
|
|
87
|
+
onChange(eventType, files);
|
|
88
|
+
}, 500);
|
|
89
|
+
});
|
|
90
|
+
return watcher;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Check if a filename matches include patterns and does not match exclude.
|
|
95
|
+
* @param {string} filename
|
|
96
|
+
* @returns {boolean}
|
|
97
|
+
*/
|
|
98
|
+
_matchesFilters(filename) {
|
|
99
|
+
for (const excl of this.exclude) {
|
|
100
|
+
if (filename.includes(excl)) return false;
|
|
101
|
+
}
|
|
102
|
+
const ext = path.extname(filename).toLowerCase();
|
|
103
|
+
const includeExts = this.include.map(p => p.startsWith('*.') ? p.slice(1) : p);
|
|
104
|
+
return includeExts.some(e => ext === e);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Recursively walk a directory, collecting matching files. */
|
|
108
|
+
_walk(currentDir, rootDir, results) {
|
|
109
|
+
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
110
|
+
for (const entry of entries) {
|
|
111
|
+
const fullPath = path.join(currentDir, entry.name);
|
|
112
|
+
if (entry.isDirectory()) {
|
|
113
|
+
if (this.exclude.some(excl => entry.name === excl)) continue;
|
|
114
|
+
this._walk(fullPath, rootDir, results);
|
|
115
|
+
} else if (entry.isFile() && this._matchesFilters(entry.name)) {
|
|
116
|
+
results.push(fullPath);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|