@tyroneross/build-loop 0.30.3
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/.agents/plugins/marketplace.json +10 -0
- package/.claude-plugin/marketplace.json +38 -0
- package/.claude-plugin/plugin.json +30 -0
- package/.codex-plugin/plugin.json +41 -0
- package/.cursor/rules/build-loop-surface.mdc +22 -0
- package/AGENTS.md +498 -0
- package/LICENSE +202 -0
- package/README.md +403 -0
- package/agents/alignment-checker.md +124 -0
- package/agents/api-assessor.md +136 -0
- package/agents/architecture-scout.md +185 -0
- package/agents/assessment-orchestrator.md +163 -0
- package/agents/build-orchestrator.md +198 -0
- package/agents/database-assessor.md +133 -0
- package/agents/design-contract-specialist.md +239 -0
- package/agents/fact-checker.md +69 -0
- package/agents/fix-critique.md +161 -0
- package/agents/frontend-assessor.md +136 -0
- package/agents/implementer.md +185 -0
- package/agents/independent-auditor.md +143 -0
- package/agents/mock-scanner.md +78 -0
- package/agents/optimize-runner.md +112 -0
- package/agents/overfitting-reviewer.md +96 -0
- package/agents/performance-assessor.md +159 -0
- package/agents/plan-critic.md +85 -0
- package/agents/promotion-reviewer.md +121 -0
- package/agents/recurring-pattern-detector.md +248 -0
- package/agents/retrospective-synthesizer.md +109 -0
- package/agents/root-cause-investigator.md +285 -0
- package/agents/scope-auditor.md +121 -0
- package/agents/security-reviewer.md +151 -0
- package/agents/self-improvement-architect.md +125 -0
- package/agents/synthesis-critic.md +99 -0
- package/agents/transcript-pattern-miner.md +165 -0
- package/agents/ui-validator.md +193 -0
- package/codex-skills/build-loop/SKILL.md +20 -0
- package/codex-skills/debug-loop/SKILL.md +18 -0
- package/codex-skills/handoff/SKILL.md +22 -0
- package/codex-skills/knowledge/SKILL.md +21 -0
- package/codex-skills/optimize/SKILL.md +18 -0
- package/codex-skills/research/SKILL.md +18 -0
- package/commands/agent-rally-point.md +301 -0
- package/commands/assess.md +41 -0
- package/commands/debug.md +20 -0
- package/commands/debugger-detail.md +34 -0
- package/commands/debugger-scan.md +15 -0
- package/commands/debugger-status.md +20 -0
- package/commands/debugger.md +32 -0
- package/commands/handoff.md +113 -0
- package/commands/knowledge-review.md +16 -0
- package/commands/optimize.md +31 -0
- package/commands/plan-verify.md +39 -0
- package/commands/promote-experiment.md +129 -0
- package/commands/research.md +22 -0
- package/commands/run.md +12 -0
- package/commands/self-review.md +51 -0
- package/commands/start-prd.md +57 -0
- package/commands/test.md +34 -0
- package/dist/src/assessment-orchestrator.d.ts +78 -0
- package/dist/src/assessment-orchestrator.d.ts.map +1 -0
- package/dist/src/assessment-orchestrator.js +304 -0
- package/dist/src/assessment-orchestrator.js.map +1 -0
- package/dist/src/audit-miner.d.ts +21 -0
- package/dist/src/audit-miner.d.ts.map +1 -0
- package/dist/src/audit-miner.js +421 -0
- package/dist/src/audit-miner.js.map +1 -0
- package/dist/src/batch-operations.d.ts +30 -0
- package/dist/src/batch-operations.d.ts.map +1 -0
- package/dist/src/batch-operations.js +339 -0
- package/dist/src/batch-operations.js.map +1 -0
- package/dist/src/config.d.ts +83 -0
- package/dist/src/config.d.ts.map +1 -0
- package/dist/src/config.js +176 -0
- package/dist/src/config.js.map +1 -0
- package/dist/src/context-engine.d.ts +45 -0
- package/dist/src/context-engine.d.ts.map +1 -0
- package/dist/src/context-engine.js +135 -0
- package/dist/src/context-engine.js.map +1 -0
- package/dist/src/debug-wrapper.d.ts +58 -0
- package/dist/src/debug-wrapper.d.ts.map +1 -0
- package/dist/src/debug-wrapper.js +285 -0
- package/dist/src/debug-wrapper.js.map +1 -0
- package/dist/src/index.d.ts +31 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +149 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/interactive-verifier.d.ts +26 -0
- package/dist/src/interactive-verifier.d.ts.map +1 -0
- package/dist/src/interactive-verifier.js +435 -0
- package/dist/src/interactive-verifier.js.map +1 -0
- package/dist/src/lessons.d.ts +59 -0
- package/dist/src/lessons.d.ts.map +1 -0
- package/dist/src/lessons.js +172 -0
- package/dist/src/lessons.js.map +1 -0
- package/dist/src/log-reader.d.ts +39 -0
- package/dist/src/log-reader.d.ts.map +1 -0
- package/dist/src/log-reader.js +291 -0
- package/dist/src/log-reader.js.map +1 -0
- package/dist/src/logger.d.ts +59 -0
- package/dist/src/logger.d.ts.map +1 -0
- package/dist/src/logger.js +328 -0
- package/dist/src/logger.js.map +1 -0
- package/dist/src/parallel-retrieval.d.ts +52 -0
- package/dist/src/parallel-retrieval.d.ts.map +1 -0
- package/dist/src/parallel-retrieval.js +309 -0
- package/dist/src/parallel-retrieval.js.map +1 -0
- package/dist/src/pattern-extractor.d.ts +30 -0
- package/dist/src/pattern-extractor.d.ts.map +1 -0
- package/dist/src/pattern-extractor.js +386 -0
- package/dist/src/pattern-extractor.js.map +1 -0
- package/dist/src/result-aggregator.d.ts +44 -0
- package/dist/src/result-aggregator.d.ts.map +1 -0
- package/dist/src/result-aggregator.js +315 -0
- package/dist/src/result-aggregator.js.map +1 -0
- package/dist/src/retrieval.d.ts +139 -0
- package/dist/src/retrieval.d.ts.map +1 -0
- package/dist/src/retrieval.js +724 -0
- package/dist/src/retrieval.js.map +1 -0
- package/dist/src/setup/auto-setup.d.ts +15 -0
- package/dist/src/setup/auto-setup.d.ts.map +1 -0
- package/dist/src/setup/auto-setup.js +157 -0
- package/dist/src/setup/auto-setup.js.map +1 -0
- package/dist/src/setup/configure-hooks.d.ts +8 -0
- package/dist/src/setup/configure-hooks.d.ts.map +1 -0
- package/dist/src/setup/configure-hooks.js +16 -0
- package/dist/src/setup/configure-hooks.js.map +1 -0
- package/dist/src/setup/create-slash-commands.d.ts +8 -0
- package/dist/src/setup/create-slash-commands.d.ts.map +1 -0
- package/dist/src/setup/create-slash-commands.js +101 -0
- package/dist/src/setup/create-slash-commands.js.map +1 -0
- package/dist/src/setup/index.d.ts +8 -0
- package/dist/src/setup/index.d.ts.map +1 -0
- package/dist/src/setup/index.js +17 -0
- package/dist/src/setup/index.js.map +1 -0
- package/dist/src/setup/inject-claude-md.d.ts +10 -0
- package/dist/src/setup/inject-claude-md.d.ts.map +1 -0
- package/dist/src/setup/inject-claude-md.js +150 -0
- package/dist/src/setup/inject-claude-md.js.map +1 -0
- package/dist/src/setup/uninstall.d.ts +22 -0
- package/dist/src/setup/uninstall.d.ts.map +1 -0
- package/dist/src/setup/uninstall.js +235 -0
- package/dist/src/setup/uninstall.js.map +1 -0
- package/dist/src/storage.d.ts +192 -0
- package/dist/src/storage.d.ts.map +1 -0
- package/dist/src/storage.js +1037 -0
- package/dist/src/storage.js.map +1 -0
- package/dist/src/string-similarity.d.ts +8 -0
- package/dist/src/string-similarity.d.ts.map +1 -0
- package/dist/src/string-similarity.js +57 -0
- package/dist/src/string-similarity.js.map +1 -0
- package/dist/src/traces/adapters/browser.d.ts +69 -0
- package/dist/src/traces/adapters/browser.d.ts.map +1 -0
- package/dist/src/traces/adapters/browser.js +280 -0
- package/dist/src/traces/adapters/browser.js.map +1 -0
- package/dist/src/traces/adapters/index.d.ts +23 -0
- package/dist/src/traces/adapters/index.d.ts.map +1 -0
- package/dist/src/traces/adapters/index.js +67 -0
- package/dist/src/traces/adapters/index.js.map +1 -0
- package/dist/src/traces/adapters/langchain.d.ts +42 -0
- package/dist/src/traces/adapters/langchain.d.ts.map +1 -0
- package/dist/src/traces/adapters/langchain.js +151 -0
- package/dist/src/traces/adapters/langchain.js.map +1 -0
- package/dist/src/traces/adapters/opentelemetry.d.ts +46 -0
- package/dist/src/traces/adapters/opentelemetry.d.ts.map +1 -0
- package/dist/src/traces/adapters/opentelemetry.js +164 -0
- package/dist/src/traces/adapters/opentelemetry.js.map +1 -0
- package/dist/src/traces/adapters/sentry.d.ts +58 -0
- package/dist/src/traces/adapters/sentry.d.ts.map +1 -0
- package/dist/src/traces/adapters/sentry.js +212 -0
- package/dist/src/traces/adapters/sentry.js.map +1 -0
- package/dist/src/traces/index.d.ts +10 -0
- package/dist/src/traces/index.d.ts.map +1 -0
- package/dist/src/traces/index.js +45 -0
- package/dist/src/traces/index.js.map +1 -0
- package/dist/src/traces/storage.d.ts +78 -0
- package/dist/src/traces/storage.d.ts.map +1 -0
- package/dist/src/traces/storage.js +399 -0
- package/dist/src/traces/storage.js.map +1 -0
- package/dist/src/traces/summarizer.d.ts +83 -0
- package/dist/src/traces/summarizer.d.ts.map +1 -0
- package/dist/src/traces/summarizer.js +247 -0
- package/dist/src/traces/summarizer.js.map +1 -0
- package/dist/src/traces/types.d.ts +365 -0
- package/dist/src/traces/types.d.ts.map +1 -0
- package/dist/src/traces/types.js +14 -0
- package/dist/src/traces/types.js.map +1 -0
- package/dist/src/types.d.ts +495 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +27 -0
- package/dist/src/types.js.map +1 -0
- package/docs/agent-surface-policy.md +45 -0
- package/docs/memory-setup.md +226 -0
- package/hooks/_arch_scan_bg.py +88 -0
- package/hooks/_plugin_drift_check_bg.py +85 -0
- package/hooks/_session_start_lib.sh +42 -0
- package/hooks/git/pre-push +141 -0
- package/hooks/hooks.json +126 -0
- package/hooks/post-commit +30 -0
- package/hooks/pre-commit +29 -0
- package/hooks/pre-edit-apppulse.sh +6 -0
- package/hooks/pre-edit-architecture.sh +106 -0
- package/hooks/pre-edit-rally-point.sh +92 -0
- package/hooks/session-start-apppulse.sh +6 -0
- package/hooks/session-start-architecture.sh +10 -0
- package/hooks/session-start-git-hooks.sh +12 -0
- package/hooks/session-start-plugin-drift.sh +27 -0
- package/hooks/session-start-rally-point.sh +44 -0
- package/hooks/session-start-retrieval.sh +24 -0
- package/hooks/session-start-worktree-gc.sh +105 -0
- package/hooks/test_apppulse_hooks.sh +6 -0
- package/hooks/test_rally_point_hooks.sh +388 -0
- package/hooks/test_sec008_hooks.sh +66 -0
- package/package.json +111 -0
- package/scripts/README.md +301 -0
- package/scripts/_db_url.py +61 -0
- package/scripts/_paths.py +445 -0
- package/scripts/blm.py +162 -0
- package/scripts/blm_api.py +263 -0
- package/scripts/check_cache_sync.py +490 -0
- package/scripts/embed_backend.py +475 -0
- package/scripts/install_memory.py +556 -0
- package/scripts/lessons_index/__init__.py +77 -0
- package/scripts/lessons_index/__main__.py +135 -0
- package/scripts/lessons_index/ingest.py +288 -0
- package/scripts/lessons_index/query.py +285 -0
- package/scripts/lessons_index/schema.py +114 -0
- package/scripts/memory_context/__init__.py +610 -0
- package/scripts/memory_graph/__init__.py +393 -0
- package/scripts/project_resolver.py +172 -0
- package/scripts/prune_codex_plugin_cache.py +23 -0
- package/scripts/prune_plugin_cache.py +359 -0
- package/scripts/semantic_index/__init__.py +482 -0
- package/scripts/semantic_index/_bench_hybrid.py +175 -0
- package/scripts/semantic_index/backfill.py +263 -0
- package/scripts/semantic_index/hybrid.py +174 -0
- package/scripts/semantic_index/test_backfill.py +292 -0
- package/scripts/semantic_index/test_hybrid.py +541 -0
- package/scripts/sync_navgator_lessons.py +666 -0
- package/scripts/sync_plugin_cache.py +388 -0
- package/skills/agent-rally-point/SKILL.md +165 -0
- package/skills/agent-rally-watcher/SKILL.md +83 -0
- package/skills/api-registry-bridge/SKILL.md +59 -0
- package/skills/architecture/dead/SKILL.md +56 -0
- package/skills/architecture/impact/SKILL.md +88 -0
- package/skills/architecture/review/SKILL.md +227 -0
- package/skills/architecture/rules/SKILL.md +72 -0
- package/skills/architecture/scan/SKILL.md +79 -0
- package/skills/architecture/trace/SKILL.md +65 -0
- package/skills/attribution-standard/SKILL.md +122 -0
- package/skills/authentication/SKILL.md +81 -0
- package/skills/authentication/references/better-auth-magic-link.md +135 -0
- package/skills/authentication/references/better-auth-setup.md +224 -0
- package/skills/authentication/references/google-calendar-sync.md +278 -0
- package/skills/authentication/references/google-cloud-console.md +159 -0
- package/skills/authentication/references/google-geocoding-directions.md +126 -0
- package/skills/authentication/references/google-lessons-example-web-app.md +177 -0
- package/skills/authentication/references/google-maps.md +162 -0
- package/skills/authentication/references/google-oauth-setup.md +343 -0
- package/skills/authentication/references/google-places.md +173 -0
- package/skills/authentication/references/lessons-example-web-app-better-auth.md +126 -0
- package/skills/authentication/references/resend-email.md +227 -0
- package/skills/authentication/references/resend-otp-magic-link.md +153 -0
- package/skills/authentication/references/supabase-auth.md +172 -0
- package/skills/auto-decision-capture/SKILL.md +419 -0
- package/skills/build-loop/SKILL.md +516 -0
- package/skills/build-loop/detect-plugins.mjs +82 -0
- package/skills/build-loop/eval-guide.md +65 -0
- package/skills/build-loop/fallbacks.md +549 -0
- package/skills/build-loop/phases/fact-check.md +42 -0
- package/skills/build-loop/phases/ui-validation.md +267 -0
- package/skills/build-loop/references/apple-native-planning.md +439 -0
- package/skills/build-loop/references/capability-routing.md +181 -0
- package/skills/build-loop/references/codex-subagents.md +98 -0
- package/skills/build-loop/references/coordination.md +161 -0
- package/skills/build-loop/references/correction-aware-capture.md +177 -0
- package/skills/build-loop/references/independent-auditor.md +72 -0
- package/skills/build-loop/references/intent-capability-pack.md +170 -0
- package/skills/build-loop/references/intent-exploration-prompts.md +96 -0
- package/skills/build-loop/references/leadership.md +72 -0
- package/skills/build-loop/references/memory.md +284 -0
- package/skills/build-loop/references/modular-systems-pack.md +96 -0
- package/skills/build-loop/references/output-style.md +129 -0
- package/skills/build-loop/references/pay-it-forward-arch.md +98 -0
- package/skills/build-loop/references/phase-1-assess.md +218 -0
- package/skills/build-loop/references/phase-2-plan.md +77 -0
- package/skills/build-loop/references/phase-3-execute.md +47 -0
- package/skills/build-loop/references/phase-4-review.md +310 -0
- package/skills/build-loop/references/phase-5-iterate.md +71 -0
- package/skills/build-loop/references/phase-6-learn.md +56 -0
- package/skills/build-loop/references/recent-design-structures.md +274 -0
- package/skills/build-loop/references/refactor-history/ASSESSMENT.md +85 -0
- package/skills/build-loop/references/refactor-history/STANDALONE_TEST_RUN.md +149 -0
- package/skills/build-loop/references/refactor-history/scenarios/01-simple-bugfix.md +32 -0
- package/skills/build-loop/references/refactor-history/scenarios/02-ui-build-with-iteration.md +48 -0
- package/skills/build-loop/references/refactor-history/scenarios/03-multi-failure-escalation.md +60 -0
- package/skills/build-loop/references/refactor-history/scenarios/04-ui-build-ibr-absent.md +51 -0
- package/skills/build-loop/references/refactor-history/scenarios/05-refactor-navgator-absent.md +71 -0
- package/skills/build-loop/references/refactor-history/scenarios/06-recurring-bug-debugger-absent.md +52 -0
- package/skills/build-loop/references/refactor-history/trace-comparison.md +202 -0
- package/skills/build-loop/references/self-recursive-dev.md +77 -0
- package/skills/build-loop/references/self-review.md +232 -0
- package/skills/build-loop/references/ui-io-contract.md +116 -0
- package/skills/build-loop/scanners/audit-design-rules.mjs +476 -0
- package/skills/build-loop/scanners/require-visual-evidence.mjs +239 -0
- package/skills/build-loop/templates/backlog-item.md +35 -0
- package/skills/build-loop/templates/codex-worker-prompt.md +89 -0
- package/skills/build-loop/templates/ui-subagent-prompt.md +179 -0
- package/skills/build-loop/templates/ux-fix-plan.md +40 -0
- package/skills/building-with-deepagents/SKILL.md +195 -0
- package/skills/building-with-deepagents/references/anti-patterns.md +141 -0
- package/skills/building-with-deepagents/references/api-quick-reference.md +184 -0
- package/skills/capabilities/SKILL.md +91 -0
- package/skills/debug-loop/SKILL.md +278 -0
- package/skills/debug-loop/references/convergence-rules.md +145 -0
- package/skills/debug-loop/references/swiftui-macos-clunky-ui-debugger.html +823 -0
- package/skills/debugging/assess/SKILL.md +111 -0
- package/skills/debugging/debug-loop/SKILL.md +231 -0
- package/skills/debugging/memory/SKILL.md +147 -0
- package/skills/debugging/store/SKILL.md +153 -0
- package/skills/debugging-memory/SKILL.md +440 -0
- package/skills/debugging-memory/examples/incident-example.json +67 -0
- package/skills/debugging-memory/references/incident-documentation.md +280 -0
- package/skills/debugging-memory/references/ios-notification-alarm-playbook.md +300 -0
- package/skills/debugging-memory/references/pattern-extraction.md +163 -0
- package/skills/debugging-memory/references/subagent-integration.md +312 -0
- package/skills/defenseclaw-bridge/SKILL.md +156 -0
- package/skills/defenseclaw-bridge/references/dc-config-mapping.md +162 -0
- package/skills/defenseclaw-bridge/references/output-format.md +108 -0
- package/skills/handoff/SKILL.md +94 -0
- package/skills/ibr-bridge/SKILL.md +68 -0
- package/skills/knowledge/SKILL.md +127 -0
- package/skills/knowledge/references/recall-integration.md +83 -0
- package/skills/knowledge/templates/madr-minimal.md +56 -0
- package/skills/knowledge-review/SKILL.md +112 -0
- package/skills/logging-tracer/SKILL.md +202 -0
- package/skills/logging-tracer/references/log-analysis.md +128 -0
- package/skills/logging-tracer/references/stack-templates.md +274 -0
- package/skills/logging-tracer-bridge/SKILL.md +70 -0
- package/skills/mcp-builder/SKILL.md +278 -0
- package/skills/mcp-builder/references/mcp-security.md +106 -0
- package/skills/model-tiering/SKILL.md +216 -0
- package/skills/native-ax-driver/SKILL.md +220 -0
- package/skills/native-ax-driver/scripts/layout_fill.py +222 -0
- package/skills/native-ax-driver/scripts/native_driver.py +495 -0
- package/skills/native-ax-driver/swift/bl-ax-driver/Package.swift +20 -0
- package/skills/native-ax-driver/swift/bl-ax-driver/Sources/main.swift +542 -0
- package/skills/optimize/SKILL.md +202 -0
- package/skills/optimize/profiles.md +61 -0
- package/skills/plan-verify/SKILL.md +115 -0
- package/skills/plan-verify/test-fixtures/example-app-v20-findings.json +27 -0
- package/skills/plan-verify/test-fixtures/example-app-v20.md +42 -0
- package/skills/plan-verify/test-fixtures/example-app-v22-findings.json +15 -0
- package/skills/plan-verify/test-fixtures/example-app-v22.md +1608 -0
- package/skills/plan-verify/test-fixtures/unrelated-good-plan-findings.json +15 -0
- package/skills/plan-verify/test-fixtures/unrelated-good-plan.md +55 -0
- package/skills/plugin-builder/SKILL.md +329 -0
- package/skills/plugin-builder/references/authoritative-sources.md +104 -0
- package/skills/plugin-builder/references/build-loop-phase-guidance.md +94 -0
- package/skills/plugin-builder/references/components-guide.md +221 -0
- package/skills/plugin-builder/references/distribution.md +317 -0
- package/skills/plugin-builder/references/dual-host-claude-codex.md +220 -0
- package/skills/plugin-builder/references/hooks-reference.md +210 -0
- package/skills/plugin-builder/references/manifest-schema.md +111 -0
- package/skills/plugin-builder/references/plugin-hygiene-lessons.md +307 -0
- package/skills/plugin-tests/SKILL.md +94 -0
- package/skills/prd-bridge/SKILL.md +135 -0
- package/skills/research/SKILL.md +80 -0
- package/skills/security-methodology/SKILL.md +105 -0
- package/skills/security-methodology/references/cross-source-matrix.md +77 -0
- package/skills/security-methodology/references/mitre-atlas-starter.md +83 -0
- package/skills/security-methodology/references/nist-600-1-mapping.md +119 -0
- package/skills/security-methodology/references/owasp-agentic-top-10.md +157 -0
- package/skills/security-methodology/references/owasp-llm-top-10.md +144 -0
- package/skills/security-methodology/references/owasp-web-top-10.md +65 -0
- package/skills/self-improve/SKILL.md +298 -0
- package/skills/spec-writing/SKILL.md +548 -0
- package/skills/spec-writing/scripts/check_checklist.py +478 -0
- package/skills/sync-skills/SKILL.md +132 -0
- package/skills/telemetry/SKILL.md +66 -0
- package/skills/ui-design/SKILL.md +75 -0
- package/skills/ui-design/references/design-patterns-multi.md +165 -0
- package/skills/ui-design/references/design-preferences-from-owned-apps.md +150 -0
- package/skills/ui-design/references/evidence-capture-policy.md +55 -0
- package/skills/ui-design/references/ui-guidance-sources.md +330 -0
- package/skills/ui-design/references/universal-design-principles.alt.md +182 -0
- package/skills/ui-design/references/universal-design-principles.md +419 -0
- package/templates/memory/MEMORY.md.template +55 -0
- package/templates/memory/constitution.md.template +64 -0
- package/templates/memory/manifest.json +47 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# SPDX-FileCopyrightText: 2025-2026 Tyrone Ross, Jr <46267523+tyroneross@users.noreply.github.com>
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
"""CLI for the lessons_index package.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
python3 scripts/lessons_index ingest [--project X] [--db PATH]
|
|
8
|
+
python3 scripts/lessons_index query --goal "<text>" [--project X] [--limit N] [--json]
|
|
9
|
+
python3 scripts/lessons_index stats [--db PATH]
|
|
10
|
+
|
|
11
|
+
Default DB: $BUILD_LOOP_MEMORY_STORE_ROOT/indexes/lessons_index.db
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
HERE = Path(__file__).resolve().parent
|
|
21
|
+
_SCRIPTS_DIR = HERE.parent
|
|
22
|
+
for _p in (str(HERE), str(_SCRIPTS_DIR)):
|
|
23
|
+
if _p not in sys.path:
|
|
24
|
+
sys.path.insert(0, _p)
|
|
25
|
+
|
|
26
|
+
import lessons_index as li # noqa: E402 (intra-package via __init__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _cmd_ingest(args: argparse.Namespace) -> int:
|
|
30
|
+
project = args.project or None
|
|
31
|
+
db_path = args.db or None
|
|
32
|
+
result = li.ingest(project=project, db_path=db_path)
|
|
33
|
+
if args.json:
|
|
34
|
+
json.dump(result, sys.stdout, indent=2)
|
|
35
|
+
sys.stdout.write("\n")
|
|
36
|
+
else:
|
|
37
|
+
print(
|
|
38
|
+
f"ingest complete: upserted={result['upserted']} "
|
|
39
|
+
f"skipped={result['skipped']} errors={len(result['errors'])} "
|
|
40
|
+
f"scanned={result['total_scanned']}"
|
|
41
|
+
)
|
|
42
|
+
if result["errors"]:
|
|
43
|
+
for e in result["errors"]:
|
|
44
|
+
print(f" ERROR: {e['file']}: {e['error']}", file=sys.stderr)
|
|
45
|
+
return 1 if result["errors"] else 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _cmd_query(args: argparse.Namespace) -> int:
|
|
49
|
+
results = li.query(
|
|
50
|
+
args.goal,
|
|
51
|
+
project=args.project or None,
|
|
52
|
+
limit=args.limit,
|
|
53
|
+
db_path=args.db or None,
|
|
54
|
+
)
|
|
55
|
+
if args.json:
|
|
56
|
+
json.dump(results, sys.stdout, indent=2)
|
|
57
|
+
sys.stdout.write("\n")
|
|
58
|
+
else:
|
|
59
|
+
if not results:
|
|
60
|
+
print("No results.")
|
|
61
|
+
return 0
|
|
62
|
+
for i, r in enumerate(results, 1):
|
|
63
|
+
print(f"\n[{i}] {r['name']}")
|
|
64
|
+
if r["description"]:
|
|
65
|
+
print(f" {r['description']}")
|
|
66
|
+
if r["snippet"]:
|
|
67
|
+
print(f" {r['snippet']}")
|
|
68
|
+
print(f" lane={r['lane']} score={r['score']:.4f}")
|
|
69
|
+
print(f" {r['source_path']}")
|
|
70
|
+
return 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _cmd_stats(args: argparse.Namespace) -> int:
|
|
74
|
+
s = li.stats(db_path=args.db or None)
|
|
75
|
+
if args.json:
|
|
76
|
+
json.dump(s, sys.stdout, indent=2)
|
|
77
|
+
sys.stdout.write("\n")
|
|
78
|
+
else:
|
|
79
|
+
print(f"total_facts: {s['total_facts']}")
|
|
80
|
+
print(f"schema_version: {s['schema_version']}")
|
|
81
|
+
print(f"last_ingest_ts: {s['last_ingest_ts']}")
|
|
82
|
+
return 0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _db_arg(parser: argparse.ArgumentParser) -> None:
|
|
86
|
+
"""Add --db and --json to a subcommand parser."""
|
|
87
|
+
parser.add_argument(
|
|
88
|
+
"--db", default=None, metavar="PATH",
|
|
89
|
+
help="Override DB path (default: $MEMORY_STORE_ROOT/indexes/lessons_index.db)",
|
|
90
|
+
)
|
|
91
|
+
parser.add_argument("--json", action="store_true", help="Output JSON")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
95
|
+
p = argparse.ArgumentParser(
|
|
96
|
+
prog="python3 -m lessons_index",
|
|
97
|
+
description="Portable SQLite/FTS5 lessons index for build-loop.",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
101
|
+
|
|
102
|
+
# ingest
|
|
103
|
+
ing = sub.add_parser("ingest", help="Walk memory lanes and upsert into DB")
|
|
104
|
+
ing.add_argument("--project", default=None,
|
|
105
|
+
help="Project tag (default: top-level lanes)")
|
|
106
|
+
_db_arg(ing)
|
|
107
|
+
|
|
108
|
+
# query
|
|
109
|
+
qry = sub.add_parser("query", help="Query the index by goal text")
|
|
110
|
+
qry.add_argument("--goal", required=True, help="Free-text retrieval goal")
|
|
111
|
+
qry.add_argument("--project", default=None, help="Scope to project (+ _unscoped)")
|
|
112
|
+
qry.add_argument("--limit", type=int, default=5, help="Max results (default 5)")
|
|
113
|
+
_db_arg(qry)
|
|
114
|
+
|
|
115
|
+
# stats
|
|
116
|
+
sta = sub.add_parser("stats", help="Print index statistics")
|
|
117
|
+
_db_arg(sta)
|
|
118
|
+
|
|
119
|
+
return p
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def main(argv=None) -> int:
|
|
123
|
+
parser = _build_parser()
|
|
124
|
+
args = parser.parse_args(argv)
|
|
125
|
+
# Propagate --db and --json to subcommand args (they're on the parent).
|
|
126
|
+
dispatch = {
|
|
127
|
+
"ingest": _cmd_ingest,
|
|
128
|
+
"query": _cmd_query,
|
|
129
|
+
"stats": _cmd_stats,
|
|
130
|
+
}
|
|
131
|
+
return dispatch[args.cmd](args)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
if __name__ == "__main__":
|
|
135
|
+
sys.exit(main())
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# SPDX-FileCopyrightText: 2025-2026 Tyrone Ross, Jr <46267523+tyroneross@users.noreply.github.com>
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
"""Ingest markdown memory files into the SQLite lessons index.
|
|
5
|
+
|
|
6
|
+
Walks the memory lanes for a project (or top-level) and upserts facts
|
|
7
|
+
into the SQLite DB, skipping files whose sha256 is unchanged (incremental).
|
|
8
|
+
|
|
9
|
+
Embeddings are computed lazily: if the embed backend is available AND the
|
|
10
|
+
fact's sha256 is new/changed, a vector is stored in `embeddings`. If the
|
|
11
|
+
backend is unavailable (MLX not installed, Ollama down, etc.) ingest
|
|
12
|
+
continues silently in FTS-only mode — graceful degradation is mandatory.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import struct
|
|
21
|
+
import sys
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
HERE = Path(__file__).resolve().parent
|
|
27
|
+
_SCRIPTS_DIR = HERE.parent
|
|
28
|
+
# Insert scripts/ for _paths, embed_backend deps.
|
|
29
|
+
if str(_SCRIPTS_DIR) not in sys.path:
|
|
30
|
+
sys.path.insert(0, str(_SCRIPTS_DIR))
|
|
31
|
+
|
|
32
|
+
# Import open_db via importlib to avoid collision with write_decision/schema.py
|
|
33
|
+
# which is also named 'schema' and appears on sys.path in the full test suite.
|
|
34
|
+
import importlib.util as _iutil # noqa: E402
|
|
35
|
+
|
|
36
|
+
def _load_schema_module():
|
|
37
|
+
spec = _iutil.spec_from_file_location(
|
|
38
|
+
"lessons_index._schema", HERE / "schema.py"
|
|
39
|
+
)
|
|
40
|
+
mod = _iutil.module_from_spec(spec)
|
|
41
|
+
spec.loader.exec_module(mod)
|
|
42
|
+
return mod
|
|
43
|
+
|
|
44
|
+
_schema_mod = _load_schema_module()
|
|
45
|
+
open_db = _schema_mod.open_db
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# Frontmatter parser — reuse the same tiny YAML subset as memory_writer.py.
|
|
50
|
+
# Imported from write_decision/frontmatter if available, otherwise inline.
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
def _split_frontmatter(text: str) -> tuple[dict, str]:
|
|
54
|
+
"""Return (frontmatter_dict, body). Empty dict if no frontmatter.
|
|
55
|
+
|
|
56
|
+
Identical subset as memory_writer._split_frontmatter: handles key: value,
|
|
57
|
+
key: ["list"], booleans, null, int, float, quoted strings.
|
|
58
|
+
"""
|
|
59
|
+
if not text.startswith("---\n"):
|
|
60
|
+
return {}, text
|
|
61
|
+
end = text.find("\n---\n", 4)
|
|
62
|
+
if end == -1:
|
|
63
|
+
return {}, text
|
|
64
|
+
block = text[4:end]
|
|
65
|
+
body = text[end + 5:]
|
|
66
|
+
fm: dict[str, Any] = {}
|
|
67
|
+
for line in block.splitlines():
|
|
68
|
+
line = line.rstrip()
|
|
69
|
+
if not line or line.lstrip().startswith("#"):
|
|
70
|
+
continue
|
|
71
|
+
m = re.match(r"^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*)$", line)
|
|
72
|
+
if not m:
|
|
73
|
+
continue
|
|
74
|
+
fm[m.group(1)] = _coerce(m.group(2).strip())
|
|
75
|
+
return fm, body
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _coerce(val: str) -> Any:
|
|
79
|
+
if val == "" or val == "~" or val.lower() == "null":
|
|
80
|
+
return None
|
|
81
|
+
if val.lower() == "true":
|
|
82
|
+
return True
|
|
83
|
+
if val.lower() == "false":
|
|
84
|
+
return False
|
|
85
|
+
if val.startswith("[") and val.endswith("]"):
|
|
86
|
+
try:
|
|
87
|
+
return json.loads(val)
|
|
88
|
+
except json.JSONDecodeError:
|
|
89
|
+
return val
|
|
90
|
+
if (val.startswith('"') and val.endswith('"')) or (
|
|
91
|
+
val.startswith("'") and val.endswith("'")
|
|
92
|
+
):
|
|
93
|
+
return val[1:-1]
|
|
94
|
+
try:
|
|
95
|
+
return int(val)
|
|
96
|
+
except ValueError:
|
|
97
|
+
pass
|
|
98
|
+
try:
|
|
99
|
+
return float(val)
|
|
100
|
+
except ValueError:
|
|
101
|
+
pass
|
|
102
|
+
return val
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _sha256(text: str) -> str:
|
|
106
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _pack_floats(vec: list[float]) -> bytes:
|
|
110
|
+
"""Pack a list of float32 into a BLOB."""
|
|
111
|
+
return struct.pack(f"{len(vec)}f", *vec)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
# Embed backend — lazy import, fail-safe
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
def _try_embed(text: str) -> tuple[list[float] | None, str | None]:
|
|
119
|
+
"""Return (vector, model_name) or (None, None) if backend unavailable.
|
|
120
|
+
|
|
121
|
+
Checks EMBED_BACKEND_UNAVAILABLE env var first so tests can force the
|
|
122
|
+
no-embedding path without complex mocking.
|
|
123
|
+
"""
|
|
124
|
+
if os.environ.get("EMBED_BACKEND_UNAVAILABLE"):
|
|
125
|
+
return None, None
|
|
126
|
+
try:
|
|
127
|
+
import embed_backend as _eb # type: ignore # noqa: PLC0415
|
|
128
|
+
vec = _eb.embed(text)
|
|
129
|
+
model = _eb.active_model()
|
|
130
|
+
return vec, model
|
|
131
|
+
except Exception: # noqa: BLE001
|
|
132
|
+
return None, None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
# Lane discovery
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
def _lane_dirs(project: str | None) -> list[tuple[str, Path]]:
|
|
140
|
+
"""Return [(lane_name, directory)] pairs to walk for the given project.
|
|
141
|
+
|
|
142
|
+
Lane names correspond to the memory lane taxonomy:
|
|
143
|
+
lessons, decisions, product, architecture (per project or top-level).
|
|
144
|
+
"""
|
|
145
|
+
from _paths import ( # type: ignore # noqa: PLC0415
|
|
146
|
+
project_decisions_dir,
|
|
147
|
+
project_lessons_dir,
|
|
148
|
+
project_product_dir,
|
|
149
|
+
top_level_lessons_dir,
|
|
150
|
+
)
|
|
151
|
+
if project:
|
|
152
|
+
return [
|
|
153
|
+
("lessons", project_lessons_dir(project)),
|
|
154
|
+
("decisions", project_decisions_dir(project)),
|
|
155
|
+
("product", project_product_dir(project)),
|
|
156
|
+
]
|
|
157
|
+
else:
|
|
158
|
+
return [
|
|
159
|
+
("lessons", top_level_lessons_dir()),
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
# Core ingest
|
|
165
|
+
# ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
def ingest(
|
|
168
|
+
project: str | None = None,
|
|
169
|
+
db_path: str | Path | None = None,
|
|
170
|
+
) -> dict:
|
|
171
|
+
"""Walk memory lanes and upsert markdown → SQLite.
|
|
172
|
+
|
|
173
|
+
Returns a summary dict: {upserted, skipped, errors, total_scanned}.
|
|
174
|
+
|
|
175
|
+
Rules:
|
|
176
|
+
- Skip files whose sha256 is unchanged (incremental).
|
|
177
|
+
- Skip non-.md files and INDEX.md / MEMORY.md / TELEMETRY.jsonl.
|
|
178
|
+
- Embeddings: computed lazily; silently skipped if backend unavailable.
|
|
179
|
+
- Idempotent: safe to call multiple times.
|
|
180
|
+
"""
|
|
181
|
+
from _paths import memory_store_root # type: ignore # noqa: PLC0415
|
|
182
|
+
|
|
183
|
+
if db_path is None:
|
|
184
|
+
db_path = memory_store_root() / "indexes" / "lessons_index.db"
|
|
185
|
+
db_path = Path(db_path)
|
|
186
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
187
|
+
|
|
188
|
+
conn = open_db(db_path)
|
|
189
|
+
summary = {"upserted": 0, "skipped": 0, "errors": [], "total_scanned": 0}
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
lanes = _lane_dirs(project)
|
|
193
|
+
for lane_name, lane_dir in lanes:
|
|
194
|
+
if not lane_dir.exists():
|
|
195
|
+
continue
|
|
196
|
+
for md_path in sorted(lane_dir.glob("*.md")):
|
|
197
|
+
if md_path.name in {"INDEX.md", "MEMORY.md", "README.md"}:
|
|
198
|
+
continue
|
|
199
|
+
summary["total_scanned"] += 1
|
|
200
|
+
try:
|
|
201
|
+
_ingest_file(conn, md_path, lane_name,
|
|
202
|
+
project or "_unscoped", summary)
|
|
203
|
+
except Exception as exc: # noqa: BLE001
|
|
204
|
+
summary["errors"].append(
|
|
205
|
+
{"file": str(md_path), "error": str(exc)}
|
|
206
|
+
)
|
|
207
|
+
finally:
|
|
208
|
+
# Update last_ingest_ts.
|
|
209
|
+
now_ts = datetime.now(timezone.utc).isoformat()
|
|
210
|
+
conn.execute(
|
|
211
|
+
"INSERT OR REPLACE INTO meta(key, value) VALUES ('last_ingest_ts', ?)",
|
|
212
|
+
(now_ts,),
|
|
213
|
+
)
|
|
214
|
+
conn.commit()
|
|
215
|
+
conn.close()
|
|
216
|
+
|
|
217
|
+
return summary
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _ingest_file(
|
|
221
|
+
conn,
|
|
222
|
+
path: Path,
|
|
223
|
+
lane: str,
|
|
224
|
+
project: str,
|
|
225
|
+
summary: dict,
|
|
226
|
+
) -> None:
|
|
227
|
+
"""Upsert one markdown file into the DB."""
|
|
228
|
+
text = path.read_text(encoding="utf-8")
|
|
229
|
+
sha = _sha256(text)
|
|
230
|
+
source_path = str(path)
|
|
231
|
+
mtime = path.stat().st_mtime
|
|
232
|
+
|
|
233
|
+
# Check if sha256 is unchanged — skip if so.
|
|
234
|
+
row = conn.execute(
|
|
235
|
+
"SELECT id, sha256 FROM facts WHERE source_path = ?", (source_path,)
|
|
236
|
+
).fetchone()
|
|
237
|
+
|
|
238
|
+
if row is not None and row["sha256"] == sha:
|
|
239
|
+
summary["skipped"] += 1
|
|
240
|
+
return
|
|
241
|
+
|
|
242
|
+
# Parse frontmatter + body.
|
|
243
|
+
fm, body = _split_frontmatter(text)
|
|
244
|
+
name = str(fm.get("name") or path.stem)
|
|
245
|
+
description = str(fm.get("description") or "")
|
|
246
|
+
fm_json = json.dumps(fm, ensure_ascii=False, default=str)
|
|
247
|
+
|
|
248
|
+
# Upsert into facts.
|
|
249
|
+
if row is None:
|
|
250
|
+
# New row.
|
|
251
|
+
cur = conn.execute(
|
|
252
|
+
"""INSERT INTO facts
|
|
253
|
+
(lane, project, name, description, body, frontmatter_json,
|
|
254
|
+
source_path, mtime, sha256)
|
|
255
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
256
|
+
(lane, project, name, description, body.strip(),
|
|
257
|
+
fm_json, source_path, mtime, sha),
|
|
258
|
+
)
|
|
259
|
+
fact_id = cur.lastrowid
|
|
260
|
+
# FTS triggers handle INSERT automatically.
|
|
261
|
+
else:
|
|
262
|
+
fact_id = row["id"]
|
|
263
|
+
conn.execute(
|
|
264
|
+
"""UPDATE facts SET
|
|
265
|
+
lane=?, project=?, name=?, description=?, body=?,
|
|
266
|
+
frontmatter_json=?, mtime=?, sha256=?
|
|
267
|
+
WHERE id=?""",
|
|
268
|
+
(lane, project, name, description, body.strip(),
|
|
269
|
+
fm_json, mtime, sha, fact_id),
|
|
270
|
+
)
|
|
271
|
+
# FTS triggers handle UPDATE automatically.
|
|
272
|
+
|
|
273
|
+
conn.commit()
|
|
274
|
+
summary["upserted"] += 1
|
|
275
|
+
|
|
276
|
+
# Embeddings: lazy, skip on any failure.
|
|
277
|
+
vec, model = _try_embed(f"{name}\n{description}\n{body}")
|
|
278
|
+
if vec is not None and model is not None:
|
|
279
|
+
packed = _pack_floats(vec)
|
|
280
|
+
dim = len(vec)
|
|
281
|
+
conn.execute(
|
|
282
|
+
"""INSERT INTO embeddings(fact_id, model, dim, vec)
|
|
283
|
+
VALUES (?, ?, ?, ?)
|
|
284
|
+
ON CONFLICT(fact_id) DO UPDATE SET
|
|
285
|
+
model=excluded.model, dim=excluded.dim, vec=excluded.vec""",
|
|
286
|
+
(fact_id, model, dim, packed),
|
|
287
|
+
)
|
|
288
|
+
conn.commit()
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# SPDX-FileCopyrightText: 2025-2026 Tyrone Ross, Jr <46267523+tyroneross@users.noreply.github.com>
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
"""Query the SQLite lessons index with progressive disclosure.
|
|
5
|
+
|
|
6
|
+
query(goal_text, project, limit) flow:
|
|
7
|
+
1. FTS5 BM25 match on goal_text, scoped to project + _unscoped.
|
|
8
|
+
2. If embeddings table is populated AND embed backend is available,
|
|
9
|
+
compute goal embedding and cosine-rerank the BM25 candidates (hybrid).
|
|
10
|
+
3. Return top-N results as list of dicts.
|
|
11
|
+
|
|
12
|
+
Graceful degradation: if the embed backend is unavailable or the embeddings
|
|
13
|
+
table is empty, returns pure BM25 results with no error.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import math
|
|
18
|
+
import os
|
|
19
|
+
import struct
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
HERE = Path(__file__).resolve().parent
|
|
25
|
+
_SCRIPTS_DIR = HERE.parent
|
|
26
|
+
if str(_SCRIPTS_DIR) not in sys.path:
|
|
27
|
+
sys.path.insert(0, str(_SCRIPTS_DIR))
|
|
28
|
+
|
|
29
|
+
import importlib.util as _iutil # noqa: E402
|
|
30
|
+
|
|
31
|
+
def _load_schema_module():
|
|
32
|
+
spec = _iutil.spec_from_file_location(
|
|
33
|
+
"lessons_index._schema", HERE / "schema.py"
|
|
34
|
+
)
|
|
35
|
+
mod = _iutil.module_from_spec(spec)
|
|
36
|
+
spec.loader.exec_module(mod)
|
|
37
|
+
return mod
|
|
38
|
+
|
|
39
|
+
_schema_mod = _load_schema_module()
|
|
40
|
+
open_db = _schema_mod.open_db
|
|
41
|
+
|
|
42
|
+
_SNIPPET_TOKENS = 64 # chars for snippet extraction
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _snippet(text: str, query_words: list[str], max_chars: int = _SNIPPET_TOKENS * 4) -> str:
|
|
46
|
+
"""Return a short excerpt near the first query word hit."""
|
|
47
|
+
text_lower = text.lower()
|
|
48
|
+
best_pos = len(text)
|
|
49
|
+
for word in query_words:
|
|
50
|
+
pos = text_lower.find(word.lower())
|
|
51
|
+
if 0 <= pos < best_pos:
|
|
52
|
+
best_pos = pos
|
|
53
|
+
start = max(0, best_pos - 80)
|
|
54
|
+
end = min(len(text), start + max_chars)
|
|
55
|
+
excerpt = text[start:end].strip()
|
|
56
|
+
if start > 0:
|
|
57
|
+
excerpt = "…" + excerpt
|
|
58
|
+
if end < len(text):
|
|
59
|
+
excerpt = excerpt + "…"
|
|
60
|
+
return excerpt
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _cosine(a: list[float], b: list[float]) -> float:
|
|
64
|
+
dot = sum(x * y for x, y in zip(a, b))
|
|
65
|
+
mag_a = math.sqrt(sum(x * x for x in a))
|
|
66
|
+
mag_b = math.sqrt(sum(x * x for x in b))
|
|
67
|
+
if mag_a == 0 or mag_b == 0:
|
|
68
|
+
return 0.0
|
|
69
|
+
return dot / (mag_a * mag_b)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _unpack_floats(blob: bytes) -> list[float]:
|
|
73
|
+
n = len(blob) // 4
|
|
74
|
+
return list(struct.unpack(f"{n}f", blob))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _try_embed(text: str) -> list[float] | None:
|
|
78
|
+
"""Return embedding vector or None if backend unavailable."""
|
|
79
|
+
if os.environ.get("EMBED_BACKEND_UNAVAILABLE"):
|
|
80
|
+
return None
|
|
81
|
+
try:
|
|
82
|
+
import embed_backend as _eb # type: ignore # noqa: PLC0415
|
|
83
|
+
vec = _eb.embed(text)
|
|
84
|
+
return list(vec)
|
|
85
|
+
except Exception: # noqa: BLE001
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def query(
|
|
90
|
+
goal_text: str,
|
|
91
|
+
project: str | None = None,
|
|
92
|
+
limit: int = 5,
|
|
93
|
+
db_path: str | Path | None = None,
|
|
94
|
+
) -> list[dict[str, Any]]:
|
|
95
|
+
"""Return top-N lessons matching goal_text.
|
|
96
|
+
|
|
97
|
+
Scoping: includes rows where project matches AND rows where
|
|
98
|
+
project='_unscoped' (top-level lanes). When project is None, returns
|
|
99
|
+
results from all projects.
|
|
100
|
+
|
|
101
|
+
Each result dict: {name, description, snippet, score, source_path, lane}.
|
|
102
|
+
"""
|
|
103
|
+
from _paths import memory_store_root # type: ignore # noqa: PLC0415
|
|
104
|
+
|
|
105
|
+
if db_path is None:
|
|
106
|
+
db_path = memory_store_root() / "indexes" / "lessons_index.db"
|
|
107
|
+
db_path = Path(db_path)
|
|
108
|
+
|
|
109
|
+
if not db_path.exists():
|
|
110
|
+
return []
|
|
111
|
+
|
|
112
|
+
conn = open_db(db_path)
|
|
113
|
+
try:
|
|
114
|
+
return _run_query(conn, goal_text, project, limit)
|
|
115
|
+
finally:
|
|
116
|
+
conn.close()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _run_query(
|
|
120
|
+
conn,
|
|
121
|
+
goal_text: str,
|
|
122
|
+
project: str | None,
|
|
123
|
+
limit: int,
|
|
124
|
+
) -> list[dict[str, Any]]:
|
|
125
|
+
# Check if FTS table has any content.
|
|
126
|
+
fts_count = conn.execute("SELECT COUNT(*) FROM facts_fts").fetchone()[0]
|
|
127
|
+
if fts_count == 0:
|
|
128
|
+
# FTS table may be behind facts (e.g. if triggers weren't active).
|
|
129
|
+
# Rebuild from facts.
|
|
130
|
+
_rebuild_fts(conn)
|
|
131
|
+
fts_count = conn.execute("SELECT COUNT(*) FROM facts_fts").fetchone()[0]
|
|
132
|
+
if fts_count == 0:
|
|
133
|
+
return []
|
|
134
|
+
|
|
135
|
+
# Sanitize goal_text for FTS5: escape special chars, wrap in quotes for
|
|
136
|
+
# phrase matching fallback if the raw query fails.
|
|
137
|
+
fts_query = _sanitize_fts_query(goal_text)
|
|
138
|
+
|
|
139
|
+
# BM25 candidates: fetch up to limit*4 for reranking headroom.
|
|
140
|
+
candidates = _fts_candidates(conn, fts_query, project, limit * 4)
|
|
141
|
+
if not candidates:
|
|
142
|
+
# FTS found nothing — last resort LIKE on name + description.
|
|
143
|
+
candidates = _like_fallback(conn, goal_text, project, limit)
|
|
144
|
+
|
|
145
|
+
if not candidates:
|
|
146
|
+
return []
|
|
147
|
+
|
|
148
|
+
# Hybrid rerank if embeddings are available.
|
|
149
|
+
goal_vec = _try_embed(goal_text)
|
|
150
|
+
if goal_vec is not None and _embeddings_populated(conn):
|
|
151
|
+
candidates = _cosine_rerank(conn, candidates, goal_vec)
|
|
152
|
+
|
|
153
|
+
# Trim to limit.
|
|
154
|
+
results = candidates[:limit]
|
|
155
|
+
|
|
156
|
+
# Build output dicts.
|
|
157
|
+
query_words = [w for w in goal_text.lower().split() if len(w) > 2]
|
|
158
|
+
out = []
|
|
159
|
+
for row in results:
|
|
160
|
+
out.append({
|
|
161
|
+
"name": row["name"],
|
|
162
|
+
"description": row["description"],
|
|
163
|
+
"snippet": _snippet(row["body"], query_words),
|
|
164
|
+
"score": float(row.get("score", 0.0)),
|
|
165
|
+
"source_path": row["source_path"],
|
|
166
|
+
"lane": row["lane"],
|
|
167
|
+
})
|
|
168
|
+
return out
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _sanitize_fts_query(text: str) -> str:
|
|
172
|
+
"""Strip FTS5 syntax chars and return a safe query string."""
|
|
173
|
+
# Remove FTS5 special operators to avoid syntax errors on raw user input.
|
|
174
|
+
sanitized = text.replace('"', " ").replace("'", " ")
|
|
175
|
+
sanitized = sanitized.replace("^", " ").replace("*", " ")
|
|
176
|
+
# Collapse whitespace, take individual tokens.
|
|
177
|
+
tokens = [t for t in sanitized.split() if t]
|
|
178
|
+
if not tokens:
|
|
179
|
+
return '""'
|
|
180
|
+
# Simple OR: each token must match somewhere.
|
|
181
|
+
return " OR ".join(tokens)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _fts_candidates(conn, fts_query: str, project: str | None, n: int) -> list:
|
|
185
|
+
"""Run BM25 FTS5 query, return rows ordered by relevance."""
|
|
186
|
+
try:
|
|
187
|
+
if project is not None:
|
|
188
|
+
sql = """
|
|
189
|
+
SELECT f.id, f.lane, f.project, f.name, f.description,
|
|
190
|
+
f.body, f.source_path,
|
|
191
|
+
bm25(facts_fts) AS score
|
|
192
|
+
FROM facts_fts
|
|
193
|
+
JOIN facts f ON facts_fts.rowid = f.id
|
|
194
|
+
WHERE facts_fts MATCH ?
|
|
195
|
+
AND (f.project = ? OR f.project = '_unscoped')
|
|
196
|
+
ORDER BY score
|
|
197
|
+
LIMIT ?
|
|
198
|
+
"""
|
|
199
|
+
rows = conn.execute(sql, (fts_query, project, n)).fetchall()
|
|
200
|
+
else:
|
|
201
|
+
sql = """
|
|
202
|
+
SELECT f.id, f.lane, f.project, f.name, f.description,
|
|
203
|
+
f.body, f.source_path,
|
|
204
|
+
bm25(facts_fts) AS score
|
|
205
|
+
FROM facts_fts
|
|
206
|
+
JOIN facts f ON facts_fts.rowid = f.id
|
|
207
|
+
WHERE facts_fts MATCH ?
|
|
208
|
+
ORDER BY score
|
|
209
|
+
LIMIT ?
|
|
210
|
+
"""
|
|
211
|
+
rows = conn.execute(sql, (fts_query, n)).fetchall()
|
|
212
|
+
return [dict(r) for r in rows]
|
|
213
|
+
except Exception: # noqa: BLE001
|
|
214
|
+
return []
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _like_fallback(conn, text: str, project: str | None, n: int) -> list:
|
|
218
|
+
"""Simple LIKE fallback when FTS returns nothing."""
|
|
219
|
+
pattern = f"%{text[:50]}%"
|
|
220
|
+
try:
|
|
221
|
+
if project is not None:
|
|
222
|
+
sql = """
|
|
223
|
+
SELECT id, lane, project, name, description, body, source_path,
|
|
224
|
+
0.0 AS score
|
|
225
|
+
FROM facts
|
|
226
|
+
WHERE (name LIKE ? OR description LIKE ? OR body LIKE ?)
|
|
227
|
+
AND (project = ? OR project = '_unscoped')
|
|
228
|
+
LIMIT ?
|
|
229
|
+
"""
|
|
230
|
+
rows = conn.execute(sql, (pattern, pattern, pattern, project, n)).fetchall()
|
|
231
|
+
else:
|
|
232
|
+
sql = """
|
|
233
|
+
SELECT id, lane, project, name, description, body, source_path,
|
|
234
|
+
0.0 AS score
|
|
235
|
+
FROM facts
|
|
236
|
+
WHERE name LIKE ? OR description LIKE ? OR body LIKE ?
|
|
237
|
+
LIMIT ?
|
|
238
|
+
"""
|
|
239
|
+
rows = conn.execute(sql, (pattern, pattern, pattern, n)).fetchall()
|
|
240
|
+
return [dict(r) for r in rows]
|
|
241
|
+
except Exception: # noqa: BLE001
|
|
242
|
+
return []
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _embeddings_populated(conn) -> bool:
|
|
246
|
+
"""True if there are any rows in the embeddings table."""
|
|
247
|
+
count = conn.execute("SELECT COUNT(*) FROM embeddings").fetchone()[0]
|
|
248
|
+
return count > 0
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _cosine_rerank(conn, candidates: list[dict], goal_vec: list[float]) -> list[dict]:
|
|
252
|
+
"""Rerank BM25 candidates by cosine similarity of stored embeddings.
|
|
253
|
+
|
|
254
|
+
Returns candidates sorted by cosine similarity (descending). Candidates
|
|
255
|
+
without an embedding retain their BM25 rank at the end.
|
|
256
|
+
"""
|
|
257
|
+
ids = [r["id"] for r in candidates]
|
|
258
|
+
placeholders = ",".join("?" * len(ids))
|
|
259
|
+
emb_rows = conn.execute(
|
|
260
|
+
f"SELECT fact_id, vec FROM embeddings WHERE fact_id IN ({placeholders})",
|
|
261
|
+
ids,
|
|
262
|
+
).fetchall()
|
|
263
|
+
emb_map = {row["fact_id"]: _unpack_floats(row["vec"]) for row in emb_rows}
|
|
264
|
+
|
|
265
|
+
scored = []
|
|
266
|
+
unscored = []
|
|
267
|
+
for r in candidates:
|
|
268
|
+
if r["id"] in emb_map:
|
|
269
|
+
cos = _cosine(goal_vec, emb_map[r["id"]])
|
|
270
|
+
scored.append({**r, "score": cos})
|
|
271
|
+
else:
|
|
272
|
+
unscored.append(r)
|
|
273
|
+
|
|
274
|
+
scored.sort(key=lambda x: x["score"], reverse=True)
|
|
275
|
+
return scored + unscored
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _rebuild_fts(conn) -> None:
|
|
279
|
+
"""Populate FTS from the facts table (recovery path)."""
|
|
280
|
+
conn.execute("DELETE FROM facts_fts")
|
|
281
|
+
conn.execute(
|
|
282
|
+
"INSERT INTO facts_fts(rowid, name, description, body) "
|
|
283
|
+
"SELECT id, name, description, body FROM facts"
|
|
284
|
+
)
|
|
285
|
+
conn.commit()
|