openclaw-hybrid-memory 2026.5.310 → 2026.6.10
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/api/plugin-runtime.ts +2 -0
- package/backends/facts-db/contradictions.ts +1 -1
- package/cli/cmd-extract-directives.ts +225 -11
- package/cli/cmd-extract-proposals.ts +5 -6
- package/cli/cmd-extract-reinforcement.ts +71 -0
- package/cli/cmd-feedback.ts +15 -9
- package/cli/commands/manage/register-reflection-pipeline.ts +247 -13
- package/cli/commands/manage/register-storage-maintenance.ts +224 -15
- package/cli/commands/manage/storage-stats-helpers.ts +13 -2
- package/cli/context.ts +9 -19
- package/cli/distill.ts +31 -1
- package/cli/register.ts +28 -38
- package/dist/api/plugin-runtime.js.map +1 -1
- package/dist/backends/agent-health-store.js.map +1 -1
- package/dist/backends/apitap-store.js.map +1 -1
- package/dist/backends/audit-store.js.map +1 -1
- package/dist/backends/base-sqlite-store.js.map +1 -1
- package/dist/backends/cost-tracker.js.map +1 -1
- package/dist/backends/credentials-db.js +2 -3
- package/dist/backends/credentials-db.js.map +1 -1
- package/dist/backends/crystallization-store.js.map +1 -1
- package/dist/backends/edict-store.js.map +1 -1
- package/dist/backends/event-bus.js.map +1 -1
- package/dist/backends/event-log.js.map +1 -1
- package/dist/backends/facts-db/cache-manager.js.map +1 -1
- package/dist/backends/facts-db/clusters.js.map +1 -1
- package/dist/backends/facts-db/contradictions.js +1 -1
- package/dist/backends/facts-db/contradictions.js.map +1 -1
- package/dist/backends/facts-db/crud.js.map +1 -1
- package/dist/backends/facts-db/db-connection.js.map +1 -1
- package/dist/backends/facts-db/entity-autolink.js.map +1 -1
- package/dist/backends/facts-db/entity-layer.js.map +1 -1
- package/dist/backends/facts-db/episodes.js.map +1 -1
- package/dist/backends/facts-db/fact-queries.js.map +1 -1
- package/dist/backends/facts-db/fact-read-queries.js.map +1 -1
- package/dist/backends/facts-db/facts-db-layer1.js.map +1 -1
- package/dist/backends/facts-db/facts-db-layer2.js.map +1 -1
- package/dist/backends/facts-db/facts-db-layer3.js.map +1 -1
- package/dist/backends/facts-db/fts-text.js.map +1 -1
- package/dist/backends/facts-db/generated-skills/policy.js.map +1 -1
- package/dist/backends/facts-db/generated-skills.js.map +1 -1
- package/dist/backends/facts-db/housekeeping.js.map +1 -1
- package/dist/backends/facts-db/links.js.map +1 -1
- package/dist/backends/facts-db/maintenance.js.map +1 -1
- package/dist/backends/facts-db/procedures/crud.js.map +1 -1
- package/dist/backends/facts-db/procedures/internal.js.map +1 -1
- package/dist/backends/facts-db/procedures/promotion.js.map +1 -1
- package/dist/backends/facts-db/procedures/search.js.map +1 -1
- package/dist/backends/facts-db/procedures/stats.js.map +1 -1
- package/dist/backends/facts-db/reinforcement.js.map +1 -1
- package/dist/backends/facts-db/row-mapper.js.map +1 -1
- package/dist/backends/facts-db/scan-cursors.js.map +1 -1
- package/dist/backends/facts-db/schema-bootstrap.js.map +1 -1
- package/dist/backends/facts-db/scope-sql.js.map +1 -1
- package/dist/backends/facts-db/search.js.map +1 -1
- package/dist/backends/facts-db/stats.js.map +1 -1
- package/dist/backends/facts-db/types.js.map +1 -1
- package/dist/backends/facts-db/variants.js.map +1 -1
- package/dist/backends/identity-reflection-store.js.map +1 -1
- package/dist/backends/issue-store.js.map +1 -1
- package/dist/backends/learnings-db.js.map +1 -1
- package/dist/backends/migrations/facts-migrations.js.map +1 -1
- package/dist/backends/migrations/procedures.js.map +1 -1
- package/dist/backends/narratives-db.js.map +1 -1
- package/dist/backends/persona-state-store.js.map +1 -1
- package/dist/backends/proposals-db.js.map +1 -1
- package/dist/backends/scope-filter-sql.js.map +1 -1
- package/dist/backends/sqlite-schema-meta.js.map +1 -1
- package/dist/backends/tool-proposal-store.js.map +1 -1
- package/dist/backends/vector-db/constants.js.map +1 -1
- package/dist/backends/vector-db/path-utils.js.map +1 -1
- package/dist/backends/vector-db/runtime-locks.js.map +1 -1
- package/dist/backends/vector-db/vector-db-class.js.map +1 -1
- package/dist/backends/wal.js.map +1 -1
- package/dist/backends/workflow-store.js.map +1 -1
- package/dist/benchmark/shadow-eval.js.map +1 -1
- package/dist/cli/active-tasks.js.map +1 -1
- package/dist/cli/backup.js.map +1 -1
- package/dist/cli/benchmark.js.map +1 -1
- package/dist/cli/cmd-backfill.js.map +1 -1
- package/dist/cli/cmd-config.js.map +1 -1
- package/dist/cli/cmd-credentials.js.map +1 -1
- package/dist/cli/cmd-demo.js.map +1 -1
- package/dist/cli/cmd-distill.js.map +1 -1
- package/dist/cli/cmd-doctor.js.map +1 -1
- package/dist/cli/cmd-examples.js.map +1 -1
- package/dist/cli/cmd-extract-daily.js.map +1 -1
- package/dist/cli/cmd-extract-directives.js +141 -10
- package/dist/cli/cmd-extract-directives.js.map +1 -1
- package/dist/cli/cmd-extract-procedures.js.map +1 -1
- package/dist/cli/cmd-extract-proposals.js +3 -2
- package/dist/cli/cmd-extract-proposals.js.map +1 -1
- package/dist/cli/cmd-extract-reinforcement.js +39 -0
- package/dist/cli/cmd-extract-reinforcement.js.map +1 -1
- package/dist/cli/cmd-extract-sessions.js.map +1 -1
- package/dist/cli/cmd-feedback.js +9 -4
- package/dist/cli/cmd-feedback.js.map +1 -1
- package/dist/cli/cmd-health.js.map +1 -1
- package/dist/cli/cmd-providers.js.map +1 -1
- package/dist/cli/cmd-selfcorrection.js.map +1 -1
- package/dist/cli/cmd-setup.js.map +1 -1
- package/dist/cli/cmd-status.js.map +1 -1
- package/dist/cli/cmd-store.js.map +1 -1
- package/dist/cli/cmd-user-friendly.js.map +1 -1
- package/dist/cli/cmd-verify.js.map +1 -1
- package/dist/cli/commands/manage/bindings.js.map +1 -1
- package/dist/cli/commands/manage/dream-cycle-followup.js.map +1 -1
- package/dist/cli/commands/manage/maintenance-heartbeat.js.map +1 -1
- package/dist/cli/commands/manage/register-agents-audit-runall.js.map +1 -1
- package/dist/cli/commands/manage/register-analyze-maintenance-logs.js.map +1 -1
- package/dist/cli/commands/manage/register-budget-proposals.js.map +1 -1
- package/dist/cli/commands/manage/register-config-cli.js.map +1 -1
- package/dist/cli/commands/manage/register-corrections-and-pipeline.js.map +1 -1
- package/dist/cli/commands/manage/register-corrections.js.map +1 -1
- package/dist/cli/commands/manage/register-council.js.map +1 -1
- package/dist/cli/commands/manage/register-credentials-scope.js.map +1 -1
- package/dist/cli/commands/manage/register-digest.js.map +1 -1
- package/dist/cli/commands/manage/register-lifecycle.js.map +1 -1
- package/dist/cli/commands/manage/register-procedure-lifecycle.js.map +1 -1
- package/dist/cli/commands/manage/register-reconcile-cron-ledgers.js.map +1 -1
- package/dist/cli/commands/manage/register-reflection-pipeline.js +144 -7
- package/dist/cli/commands/manage/register-reflection-pipeline.js.map +1 -1
- package/dist/cli/commands/manage/register-self-correction-feedback.js.map +1 -1
- package/dist/cli/commands/manage/register-storage-and-stats.js.map +1 -1
- package/dist/cli/commands/manage/register-storage-entities-decay.js.map +1 -1
- package/dist/cli/commands/manage/register-storage-graph-audit.js.map +1 -1
- package/dist/cli/commands/manage/register-storage-maintenance.js +152 -9
- package/dist/cli/commands/manage/register-storage-maintenance.js.map +1 -1
- package/dist/cli/commands/manage/register-validate-cron-exit.js.map +1 -1
- package/dist/cli/commands/manage/storage-stats-helpers.js +10 -3
- package/dist/cli/commands/manage/storage-stats-helpers.js.map +1 -1
- package/dist/cli/commands/register-manage-commands.js.map +1 -1
- package/dist/cli/config-feature-summaries.js.map +1 -1
- package/dist/cli/config-output-sink.js.map +1 -1
- package/dist/cli/distill-session-jsonl.js.map +1 -1
- package/dist/cli/distill.js +10 -1
- package/dist/cli/distill.js.map +1 -1
- package/dist/cli/global-verbose.js.map +1 -1
- package/dist/cli/goals.js.map +1 -1
- package/dist/cli/hybrid-mem-commander-utils.js.map +1 -1
- package/dist/cli/install/config-merge.js.map +1 -1
- package/dist/cli/install/cron-jobs.js.map +1 -1
- package/dist/cli/install/embedding-detect.js.map +1 -1
- package/dist/cli/install/run-install.js.map +1 -1
- package/dist/cli/install/workspace.js.map +1 -1
- package/dist/cli/proposals.js.map +1 -1
- package/dist/cli/register.js.map +1 -1
- package/dist/cli/shared.js.map +1 -1
- package/dist/cli/skills.js.map +1 -1
- package/dist/cli/task-queue-status.js.map +1 -1
- package/dist/cli/verified.js.map +1 -1
- package/dist/cli/verify/fact-count.js.map +1 -1
- package/dist/cli/verify/openclaw-config.js.map +1 -1
- package/dist/cli/verify/plugin-config-credentials.js.map +1 -1
- package/dist/cli/verify/sections/config-cron.js.map +1 -1
- package/dist/cli/verify/sections/embeddings.js.map +1 -1
- package/dist/cli/verify/sections/infrastructure.js.map +1 -1
- package/dist/cli/verify/sections/llm-models.js.map +1 -1
- package/dist/cli/verify/sections/reconcile.js.map +1 -1
- package/dist/cli/verify/verify-run-state.js.map +1 -1
- package/dist/cli/verify-llm-azure-auth.js.map +1 -1
- package/dist/cli/verify.js.map +1 -1
- package/dist/config/hybrid-schema.js.map +1 -1
- package/dist/config/index.js.map +1 -1
- package/dist/config/maintenance-fallback-policy.js.map +1 -1
- package/dist/config/parsers/capture.js.map +1 -1
- package/dist/config/parsers/core.js.map +1 -1
- package/dist/config/parsers/features.js.map +1 -1
- package/dist/config/parsers/index.js.map +1 -1
- package/dist/config/parsers/maintenance.js.map +1 -1
- package/dist/config/parsers/retrieval.js.map +1 -1
- package/dist/config/parsers/sensors.js.map +1 -1
- package/dist/config/skill-sections.js.map +1 -1
- package/dist/config/skill-size-limits.js.map +1 -1
- package/dist/config/types/agents.js.map +1 -1
- package/dist/config/types/bootstrap.js.map +1 -1
- package/dist/config/types/core.js.map +1 -1
- package/dist/config/types/index.js.map +1 -1
- package/dist/config/utils.js.map +1 -1
- package/dist/index-help.js.map +1 -1
- package/dist/index-testing-exports.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/lifecycle/hook-resolution-api.js.map +1 -1
- package/dist/lifecycle/hooks.js +0 -1
- package/dist/lifecycle/hooks.js.map +1 -1
- package/dist/lifecycle/resolve-agent-id.js.map +1 -1
- package/dist/lifecycle/session-state.js.map +1 -1
- package/dist/lifecycle/stage-active-task.js.map +1 -1
- package/dist/lifecycle/stage-auth-failure.js.map +1 -1
- package/dist/lifecycle/stage-capture/run-capture.js.map +1 -1
- package/dist/lifecycle/stage-capture.js.map +1 -1
- package/dist/lifecycle/stage-cleanup.js.map +1 -1
- package/dist/lifecycle/stage-credential-hint.js.map +1 -1
- package/dist/lifecycle/stage-frustration.js.map +1 -1
- package/dist/lifecycle/stage-goal-stewardship.js.map +1 -1
- package/dist/lifecycle/stage-goal-subagent.js.map +1 -1
- package/dist/lifecycle/stage-injection.js +1 -1
- package/dist/lifecycle/stage-injection.js.map +1 -1
- package/dist/lifecycle/stage-recall/run-recall.js.map +1 -1
- package/dist/lifecycle/stage-recall.js.map +1 -1
- package/dist/lifecycle/stage-setup.js.map +1 -1
- package/dist/routes/dashboard/collectors.js.map +1 -1
- package/dist/routes/dashboard/html.js.map +1 -1
- package/dist/routes/dashboard/server.js.map +1 -1
- package/dist/routes/dashboard-graph.js.map +1 -1
- package/dist/routes/graphql-resolvers.js.map +1 -1
- package/dist/routes/graphql-server.js.map +1 -1
- package/dist/services/active-task-checkpoint.js.map +1 -1
- package/dist/services/active-task-injection.js.map +1 -1
- package/dist/services/active-task.js.map +1 -1
- package/dist/services/adaptive-catch-up-pacing.js +25 -0
- package/dist/services/adaptive-catch-up-pacing.js.map +1 -0
- package/dist/services/adaptive-maintenance-llm.js.map +1 -1
- package/dist/services/adaptive-model-limits.js.map +1 -1
- package/dist/services/ambient-retrieval.js.map +1 -1
- package/dist/services/apitap-service.js.map +1 -1
- package/dist/services/audit-health-exit-info.js.map +1 -1
- package/dist/services/audit-health-json.js.map +1 -1
- package/dist/services/auth-failure-detect.js.map +1 -1
- package/dist/services/auto-capture.js.map +1 -1
- package/dist/services/auto-classifier.js.map +1 -1
- package/dist/services/auto-skills-audit.js.map +1 -1
- package/dist/services/bootstrap-optional.js.map +1 -1
- package/dist/services/bootstrap-priority.js.map +1 -1
- package/dist/services/bootstrap.js.map +1 -1
- package/dist/services/capture-provenance.js.map +1 -1
- package/dist/services/capture-utils.js.map +1 -1
- package/dist/services/chat.js +22 -3
- package/dist/services/chat.js.map +1 -1
- package/dist/services/classification-scope.js.map +1 -1
- package/dist/services/classification.js.map +1 -1
- package/dist/services/cli-sql-dump.js.map +1 -1
- package/dist/services/consolidation.js.map +1 -1
- package/dist/services/context-audit.js +1 -1
- package/dist/services/context-audit.js.map +1 -1
- package/dist/services/context-budget.js.map +1 -1
- package/dist/services/context-engine.js.map +1 -1
- package/dist/services/contextual-variants.js.map +1 -1
- package/dist/services/continuous-verifier.js.map +1 -1
- package/dist/services/contradiction-adjudicator.js.map +1 -1
- package/dist/services/cost-context.js.map +1 -1
- package/dist/services/cost-feature-labels.js.map +1 -1
- package/dist/services/credential-migration.js.map +1 -1
- package/dist/services/credential-scanner.js.map +1 -1
- package/dist/services/credential-validation.js.map +1 -1
- package/dist/services/cron-exit-validator.js.map +1 -1
- package/dist/services/cron-guard.js.map +1 -1
- package/dist/services/cron-job-bash-harness.js +52 -5
- package/dist/services/cron-job-bash-harness.js.map +1 -1
- package/dist/services/cron-maintenance-reconciler.js +1 -3
- package/dist/services/cron-maintenance-reconciler.js.map +1 -1
- package/dist/services/cross-agent-learning.js.map +1 -1
- package/dist/services/crystallization-proposer.js.map +1 -1
- package/dist/services/dedupe-policy.js.map +1 -1
- package/dist/services/deprecated-cron-commands.js.map +1 -1
- package/dist/services/directive-extract.js.map +1 -1
- package/dist/services/document-chunker.js.map +1 -1
- package/dist/services/document-grader.js.map +1 -1
- package/dist/services/dream-cycle.js.map +1 -1
- package/dist/services/embedding-migration.js.map +1 -1
- package/dist/services/embedding-registry.js.map +1 -1
- package/dist/services/embeddings/chain-provider.js.map +1 -1
- package/dist/services/embeddings/factory.js.map +1 -1
- package/dist/services/embeddings/fallback-provider.js.map +1 -1
- package/dist/services/embeddings/ollama-provider.js.map +1 -1
- package/dist/services/embeddings/onnx-provider.js.map +1 -1
- package/dist/services/embeddings/openai-provider.js.map +1 -1
- package/dist/services/embeddings/shared.js +3 -3
- package/dist/services/embeddings/shared.js.map +1 -1
- package/dist/services/embeddings/types.js.map +1 -1
- package/dist/services/entity-enrichment-adaptive.js +128 -0
- package/dist/services/entity-enrichment-adaptive.js.map +1 -0
- package/dist/services/entity-enrichment-cli.js +389 -42
- package/dist/services/entity-enrichment-cli.js.map +1 -1
- package/dist/services/entity-enrichment.js +31 -5
- package/dist/services/entity-enrichment.js.map +1 -1
- package/dist/services/error-reporter/noisy-errors.js.map +1 -1
- package/dist/services/error-reporter/sanitize.js.map +1 -1
- package/dist/services/error-reporter.js.map +1 -1
- package/dist/services/event-hub-repair.js.map +1 -1
- package/dist/services/export-memory.js.map +1 -1
- package/dist/services/fact-extraction.js.map +1 -1
- package/dist/services/feedback-effectiveness.js.map +1 -1
- package/dist/services/find-duplicates.js.map +1 -1
- package/dist/services/frustration-detector.js.map +1 -1
- package/dist/services/fts-search.js.map +1 -1
- package/dist/services/gap-detector.js.map +1 -1
- package/dist/services/generated-skill-lifecycle.js.map +1 -1
- package/dist/services/generated-skill-validation.js.map +1 -1
- package/dist/services/goal-active-task-mirror.js.map +1 -1
- package/dist/services/goal-circuit-breaker.js.map +1 -1
- package/dist/services/goal-health.js.map +1 -1
- package/dist/services/goal-registry.js.map +1 -1
- package/dist/services/goal-stewardship-heartbeat.js.map +1 -1
- package/dist/services/goal-stewardship-llm-triage.js.map +1 -1
- package/dist/services/goal-stewardship-verify-cron.js.map +1 -1
- package/dist/services/goal-stewardship.js.map +1 -1
- package/dist/services/goal-subagent.js.map +1 -1
- package/dist/services/graph-retrieval.js.map +1 -1
- package/dist/services/humanizer-score.js.map +1 -1
- package/dist/services/hybrid-mem-cron-default-job-steps.js.map +1 -1
- package/dist/services/hyde-helper.js.map +1 -1
- package/dist/services/identity-reflection.js.map +1 -1
- package/dist/services/implicit-feedback-extract.js.map +1 -1
- package/dist/services/index.js.map +1 -1
- package/dist/services/ingest-utils.js.map +1 -1
- package/dist/services/intent-template.js.map +1 -1
- package/dist/services/json-array-parser.js.map +1 -1
- package/dist/services/knowledge-gaps.js.map +1 -1
- package/dist/services/language-keywords-build.js.map +1 -1
- package/dist/services/lifecycle/github-adapter.js.map +1 -1
- package/dist/services/llm-rate-limit-headers.js +1 -2
- package/dist/services/llm-rate-limit-headers.js.map +1 -1
- package/dist/services/maintenance-auto-fix.js.map +1 -1
- package/dist/services/maintenance-log-analyzer.js +7 -1
- package/dist/services/maintenance-log-analyzer.js.map +1 -1
- package/dist/services/maintenance-timestamp.js.map +1 -1
- package/dist/services/memory-diagnostics.js.map +1 -1
- package/dist/services/memory-index.js.map +1 -1
- package/dist/services/merge-results.js.map +1 -1
- package/dist/services/model-capabilities.js.map +1 -1
- package/dist/services/model-pricing.js.map +1 -1
- package/dist/services/narrative-recall.js.map +1 -1
- package/dist/services/openclaw-session-artifact.js.map +1 -1
- package/dist/services/passive-observer.js.map +1 -1
- package/dist/services/pattern-detector-hash.js.map +1 -1
- package/dist/services/pattern-detector.js.map +1 -1
- package/dist/services/pending-autopilot/foundation.js.map +1 -1
- package/dist/services/pending-autopilot/redaction.js.map +1 -1
- package/dist/services/pending-autopilot/store.js.map +1 -1
- package/dist/services/pending-autopilot/types.js.map +1 -1
- package/dist/services/pending-digest-autopilot-cron.js.map +1 -1
- package/dist/services/pending-digest-autopilot.js.map +1 -1
- package/dist/services/pending-review-digest.js.map +1 -1
- package/dist/services/persona-proposal-triage.js.map +1 -1
- package/dist/services/persona-state-promotion.js.map +1 -1
- package/dist/services/post-compaction-recall.js.map +1 -1
- package/dist/services/pre-consolidation-flush.js.map +1 -1
- package/dist/services/pre-finalization-guard.js.map +1 -1
- package/dist/services/procedure-cluster.js.map +1 -1
- package/dist/services/procedure-extractor.js.map +1 -1
- package/dist/services/procedure-promotion/duplicate-skill-cache.js.map +1 -1
- package/dist/services/procedure-promotion-policy.js.map +1 -1
- package/dist/services/procedure-selection-metrics.js.map +1 -1
- package/dist/services/procedure-skill-eval.js.map +1 -1
- package/dist/services/procedure-skill-generator.js.map +1 -1
- package/dist/services/procedure-skill-recipe.js.map +1 -1
- package/dist/services/procedure-skill-shrink.js.map +1 -1
- package/dist/services/procedure-skill-workflow.js.map +1 -1
- package/dist/services/provenance.js.map +1 -1
- package/dist/services/public-export-bundle.js.map +1 -1
- package/dist/services/python-bridge.js.map +1 -1
- package/dist/services/query-expander.js.map +1 -1
- package/dist/services/query-validator.js.map +1 -1
- package/dist/services/recall-pipeline.js.map +1 -1
- package/dist/services/recall-timing.js.map +1 -1
- package/dist/services/recent-http-attempts.js.map +1 -1
- package/dist/services/reflection/shared.js.map +1 -1
- package/dist/services/reflection.js.map +1 -1
- package/dist/services/reinforcement-extract.js.map +1 -1
- package/dist/services/reranker.js.map +1 -1
- package/dist/services/responses-adapter.js.map +1 -1
- package/dist/services/retrieval-aliases.js.map +1 -1
- package/dist/services/retrieval-mode-policy.js.map +1 -1
- package/dist/services/retrieval-orchestrator/packing.js.map +1 -1
- package/dist/services/retrieval-orchestrator.d.ts +2 -3
- package/dist/services/retrieval-orchestrator.js.map +1 -1
- package/dist/services/rrf-fusion.js.map +1 -1
- package/dist/services/self-correction-extract.js.map +1 -1
- package/dist/services/session-observability.js.map +1 -1
- package/dist/services/session-pre-filter.js.map +1 -1
- package/dist/services/shortest-path.js.map +1 -1
- package/dist/services/skill-allowed-tools.js.map +1 -1
- package/dist/services/skill-creator-validator.js.map +1 -1
- package/dist/services/skill-crystallizer-helpers.js.map +1 -1
- package/dist/services/skill-crystallizer.js.map +1 -1
- package/dist/services/skill-description-builder.js.map +1 -1
- package/dist/services/skill-eval-synthesizer.js.map +1 -1
- package/dist/services/skill-examples-builder.js.map +1 -1
- package/dist/services/skill-frontmatter.js.map +1 -1
- package/dist/services/skill-name-validator.js.map +1 -1
- package/dist/services/skill-prompt-injection.js.map +1 -1
- package/dist/services/skill-reference-sidecar.js.map +1 -1
- package/dist/services/skill-script-bundler.js.map +1 -1
- package/dist/services/skill-validator.js.map +1 -1
- package/dist/services/startup-memory-attribution.js.map +1 -1
- package/dist/services/task-hygiene.js.map +1 -1
- package/dist/services/task-ledger/canonical.js.map +1 -1
- package/dist/services/task-ledger-facts.js.map +1 -1
- package/dist/services/task-queue-leases.js.map +1 -1
- package/dist/services/task-queue-watchdog.js.map +1 -1
- package/dist/services/tool-effectiveness.js.map +1 -1
- package/dist/services/tool-proposer.js.map +1 -1
- package/dist/services/tools-md-section.js.map +1 -1
- package/dist/services/topic-clusters.js.map +1 -1
- package/dist/services/trajectory-tracker.js.map +1 -1
- package/dist/services/vector-backend-observability.js.map +1 -1
- package/dist/services/vector-lifecycle-audit.js.map +1 -1
- package/dist/services/vector-maintenance.js.map +1 -1
- package/dist/services/vector-search.js.map +1 -1
- package/dist/services/verification-store.js.map +1 -1
- package/dist/services/verified-fact-triage.js.map +1 -1
- package/dist/services/wal-helpers.js.map +1 -1
- package/dist/services/workflow-tracker.js.map +1 -1
- package/dist/setup/bootstrap-databases.js.map +1 -1
- package/dist/setup/cli-context/cli-services.js.map +1 -1
- package/dist/setup/cli-context/help-text.js.map +1 -1
- package/dist/setup/cli-context/metadata.js.map +1 -1
- package/dist/setup/cli-context/register-cli-with-help.js.map +1 -1
- package/dist/setup/cli-context/register-full.js.map +1 -1
- package/dist/setup/cli-context/register-help.js.map +1 -1
- package/dist/setup/cost-instrumentation.js.map +1 -1
- package/dist/setup/hybrid-memory-generation-state.js.map +1 -1
- package/dist/setup/hybrid-memory-reload-coordinator.js +13 -13
- package/dist/setup/hybrid-memory-reload-coordinator.js.map +1 -1
- package/dist/setup/plugin-service.js.map +1 -1
- package/dist/setup/provider-router.js.map +1 -1
- package/dist/setup/register-context-engine.js.map +1 -1
- package/dist/setup/register-hooks.js.map +1 -1
- package/dist/setup/register-plugin.js +25 -21
- package/dist/setup/register-plugin.js.map +1 -1
- package/dist/setup/register-tools.js.map +1 -1
- package/dist/setup/reregister-policy.js +2 -2
- package/dist/setup/reregister-policy.js.map +1 -1
- package/dist/setup/tool-installers.js.map +1 -1
- package/dist/setup/workspace-bootstrap.js.map +1 -1
- package/dist/src/worker/narratives.js.map +1 -1
- package/dist/tools/apitap-tools.js.map +1 -1
- package/dist/tools/credential-tools.js.map +1 -1
- package/dist/tools/crystallization-tools.js.map +1 -1
- package/dist/tools/dashboard-routes.js.map +1 -1
- package/dist/tools/document-tools.js.map +1 -1
- package/dist/tools/goal-tools.js.map +1 -1
- package/dist/tools/graph-tools.js.map +1 -1
- package/dist/tools/issue-tools.js.map +1 -1
- package/dist/tools/memory/build-runtime.js.map +1 -1
- package/dist/tools/memory/helpers.js.map +1 -1
- package/dist/tools/memory/register-checkpoint-tools.js.map +1 -1
- package/dist/tools/memory/register-directory-tools.js.map +1 -1
- package/dist/tools/memory/register-edict-tools.js.map +1 -1
- package/dist/tools/memory/register-episode-tools.js.map +1 -1
- package/dist/tools/memory/register-recall-tools.js.map +1 -1
- package/dist/tools/memory/register-store-tools.js.map +1 -1
- package/dist/tools/memory-tools.js.map +1 -1
- package/dist/tools/persona-tools.js.map +1 -1
- package/dist/tools/provenance-tools.js.map +1 -1
- package/dist/tools/public-api-routes.js.map +1 -1
- package/dist/tools/safe-register-http-route.js.map +1 -1
- package/dist/tools/self-extension-tools.js.map +1 -1
- package/dist/tools/task-hygiene-tools.js.map +1 -1
- package/dist/tools/utility-tools.js.map +1 -1
- package/dist/tools/verification-tools.js.map +1 -1
- package/dist/tools/workflow-tools.js.map +1 -1
- package/dist/types/issue-types.js.map +1 -1
- package/dist/types/learnings-types.js.map +1 -1
- package/dist/types/memory.js.map +1 -1
- package/dist/utils/apim-gateway-fetch.js.map +1 -1
- package/dist/utils/atomic-write.js.map +1 -1
- package/dist/utils/auth-failover.js.map +1 -1
- package/dist/utils/auth.js.map +1 -1
- package/dist/utils/compaction-model-watchdog.js.map +1 -1
- package/dist/utils/consolidation-controls.js.map +1 -1
- package/dist/utils/constants.js.map +1 -1
- package/dist/utils/date-detector.js.map +1 -1
- package/dist/utils/dates.js.map +1 -1
- package/dist/utils/decay.js.map +1 -1
- package/dist/utils/duration.js.map +1 -1
- package/dist/utils/embed-call.js.map +1 -1
- package/dist/utils/entity-lookup-resolve.js.map +1 -1
- package/dist/utils/entity-mention-quality.js.map +1 -1
- package/dist/utils/entity-stopwords.js.map +1 -1
- package/dist/utils/env-manager.js.map +1 -1
- package/dist/utils/error-tracking.js.map +1 -1
- package/dist/utils/event-loop-yield.js.map +1 -1
- package/dist/utils/extract-last-user-message.js.map +1 -1
- package/dist/utils/extraction-from-template.js.map +1 -1
- package/dist/utils/fact-embeddings.js.map +1 -1
- package/dist/utils/file-snapshot.js.map +1 -1
- package/dist/utils/format.js.map +1 -1
- package/dist/utils/fs.js.map +1 -1
- package/dist/utils/gh-repo-arg.js.map +1 -1
- package/dist/utils/hybrid-mem-json-cli.js.map +1 -1
- package/dist/utils/language-keywords.js.map +1 -1
- package/dist/utils/lifecycle-generation.js.map +1 -1
- package/dist/utils/llm-json-array.js.map +1 -1
- package/dist/utils/llm-selection.js.map +1 -1
- package/dist/utils/logger.js.map +1 -1
- package/dist/utils/model-provider-family.js.map +1 -1
- package/dist/utils/model-tier.js.map +1 -1
- package/dist/utils/openclaw-agent-defaults.js.map +1 -1
- package/dist/utils/path.js.map +1 -1
- package/dist/utils/plugin-root.js.map +1 -1
- package/dist/utils/plugin-update-check.js.map +1 -1
- package/dist/utils/procedure-risk.js.map +1 -1
- package/dist/utils/progress-indicators.js.map +1 -1
- package/dist/utils/prompt-loader.js.map +1 -1
- package/dist/utils/provenance.js.map +1 -1
- package/dist/utils/provider-detection.js.map +1 -1
- package/dist/utils/registration-superseded.js.map +1 -1
- package/dist/utils/salience.js.map +1 -1
- package/dist/utils/sanitize-messages.js.map +1 -1
- package/dist/utils/scope-filter.js.map +1 -1
- package/dist/utils/skill-discovery.js.map +1 -1
- package/dist/utils/sqlite-file-perms.js.map +1 -1
- package/dist/utils/sqlite-outcome-compat.js.map +1 -1
- package/dist/utils/sqlite-transaction.js.map +1 -1
- package/dist/utils/stable-stringify.js.map +1 -1
- package/dist/utils/subagent-ended-utils.js.map +1 -1
- package/dist/utils/tags.js.map +1 -1
- package/dist/utils/text.js.map +1 -1
- package/dist/utils/timeout.js.map +1 -1
- package/dist/utils/typebox.js.map +1 -1
- package/dist/utils/version-check.js.map +1 -1
- package/dist/utils/wal-replay.js.map +1 -1
- package/dist/versionInfo.js.map +1 -1
- package/index.ts +2 -2
- package/lifecycle/hooks.ts +0 -1
- package/npm-shrinkwrap.json +487 -186
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -2
- package/services/adaptive-catch-up-pacing.ts +28 -0
- package/services/chat.ts +34 -1
- package/services/cron-job-bash-harness.ts +52 -5
- package/services/embeddings/shared.ts +5 -2
- package/services/entity-enrichment-adaptive.ts +245 -0
- package/services/entity-enrichment-cli.ts +553 -47
- package/services/entity-enrichment.ts +43 -2
- package/services/llm-rate-limit-headers.ts +1 -4
- package/services/maintenance-log-analyzer.ts +13 -9
- package/services/reinforcement-extract.ts +19 -0
- package/setup/hybrid-memory-reload-coordinator.ts +26 -0
- package/setup/register-plugin.ts +62 -32
- package/setup/reregister-policy.ts +10 -5
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"task-queue-watchdog.js","names":["execFileCb"],"sources":["../../services/task-queue-watchdog.ts"],"sourcesContent":["/**\n * Task Queue Watchdog — Issue #631\n *\n * Detects stale/broken autonomous queue runs and self-heals by:\n * - Validating the PID in current.json is still alive\n * - Verifying the referenced branch/worktree still exists\n * - Detecting max-runtime exceeded entries\n * - Clearing or quarantining stale queue entries safely\n * - Emitting structured log events when intervening\n * - Optionally attaching retry metadata\n *\n * Addresses Engineering Goal 1: Rock-Solid Stability\n * Addresses Product Goal 4: Autonomous Maintenance\n */\n\nimport { existsSync } from \"node:fs\";\nimport { mkdir, readdir, unlink, writeFile } from \"node:fs/promises\";\nimport { homedir } from \"node:os\";\nimport { dirname, join } from \"node:path\";\nimport { promisify } from \"node:util\";\nimport { readJsonFile } from \"../utils/fs.js\";\nimport { execFile as execFileCb } from \"../utils/process-runner.js\";\nimport { stableStringify } from \"../utils/stable-stringify.js\";\nimport { capturePluginError } from \"./error-reporter.js\";\nimport { expireDispatchLeases, transitionDispatchLease } from \"./task-queue-leases.js\";\n\nconst execFile = promisify(execFileCb);\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface TaskQueueWatchdogConfig {\n /** Directory containing current.json and history/. Defaults to ~/.openclaw/workspace/state/task-queue */\n stateDir?: string;\n /** Root of the git repository — used for branch/worktree verification. */\n repoDir?: string;\n /** Maximum task runtime in milliseconds before the entry is flagged stale. Default: 4 hours */\n maxRuntimeMs?: number;\n /** Maximum retry attempts before an entry is quarantined permanently. Default: 2 */\n maxRetries?: number;\n /** Whether to requeue (add retry metadata) after clearing. Default: false */\n enableRequeue?: boolean;\n /** If true, treat a missing branch as stale (requires repoDir). Default: true */\n checkBranch?: boolean;\n}\n\ntype WatchdogAction =\n | \"no-current\" // No active current.json found\n | \"ok\" // Entry is healthy, no action needed\n | \"cleared\" // Stale entry moved to history (will be retried)\n | \"quarantined\"; // Entry exceeded retry limit; moved to quarantine\n\ninterface WatchdogResult {\n /** What the watchdog decided to do */\n action: WatchdogAction;\n /** Human-readable explanation for the action */\n reason?: string;\n /** The task queue item that was inspected */\n item?: TaskQueueItem;\n /** Absolute path of the history/quarantine file written */\n historyPath?: string;\n /** True when retry metadata was attached */\n requeued?: boolean;\n}\n\n/** Subset of TaskQueueItem used internally — avoids importing from dashboard-server */\nexport interface TaskQueueItem {\n issue?: number;\n title?: string;\n branch?: string;\n dispatchToken?: string;\n pid?: number;\n started?: string;\n /** When set to `idle`, the entry is a placeholder (no factory task yet) — see #983. */\n status?: string;\n completed?: string;\n exit_code?: number;\n details?: string;\n /** Set on idle placeholders written by hybrid-mem so consumers can distinguish them. */\n producer?: string;\n /** Retry counter attached by the watchdog on successive clears */\n retryCount?: number;\n /** ISO timestamp when the watchdog last intervened */\n watchdogClearedAt?: string;\n /** Reason the watchdog flagged this entry */\n watchdogReason?: string;\n /** Set to true by the watchdog when requeue is requested */\n requeued?: boolean;\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers (exported for testing)\n// ---------------------------------------------------------------------------\n\n/**\n * Returns true when `current` is the same queue task as `stale` for safe replacement of current.json.\n * When pid/started are absent, pid/start equality must not be used (undefined === undefined is always true).\n */\nexport function taskQueueItemMatchesStale(current: TaskQueueItem, stale: TaskQueueItem): boolean {\n const hasPidOrStarted = stale.pid != null || stale.started != null;\n if (hasPidOrStarted) {\n return current.pid === stale.pid && current.started === stale.started;\n }\n return (\n current.issue === stale.issue && current.dispatchToken === stale.dispatchToken && current.branch === stale.branch\n );\n}\n\n/**\n * Returns true if the given PID is alive on this system.\n * Uses signal 0 (no-op) — throws ESRCH if no such process.\n */\nexport function isPidAlive(pid: number): boolean {\n if (!Number.isInteger(pid) || pid <= 0) return false;\n try {\n process.kill(pid, 0);\n return true;\n } catch (err) {\n const code = (err as NodeJS.ErrnoException).code;\n // ESRCH = no such process (dead)\n // EPERM = process exists but we lack permission to signal it (alive)\n if (code === \"ESRCH\") return false;\n if (code === \"EPERM\") return true;\n // Unexpected error — assume dead to unblock the queue\n return false;\n }\n}\n\n/**\n * Returns the set of branch names referenced by active git worktrees.\n * Gracefully returns an empty set if git is unavailable or repoDir is absent.\n */\nexport async function getActiveWorktreeBranches(repoDir: string): Promise<Set<string>> {\n try {\n const { stdout } = await execFile(\"git\", [\"-C\", repoDir, \"worktree\", \"list\", \"--porcelain\"], {\n timeout: 5000,\n });\n const branches = new Set<string>();\n for (const line of stdout.split(\"\\n\")) {\n const trimmed = line.trim();\n if (trimmed.startsWith(\"branch refs/heads/\")) {\n branches.add(trimmed.slice(\"branch refs/heads/\".length));\n }\n }\n return branches;\n } catch {\n return new Set();\n }\n}\n\n/**\n * Returns true if `started` is more than `maxRuntimeMs` milliseconds ago.\n * Returns false if `started` is missing or unparseable (gives benefit of the doubt).\n */\nexport function isRuntimeExceeded(started: string | undefined, maxRuntimeMs: number): boolean {\n if (!started) return false;\n const startedMs = new Date(started).getTime();\n if (Number.isNaN(startedMs)) return false;\n return Date.now() - startedMs > maxRuntimeMs;\n}\n\n// ---------------------------------------------------------------------------\n// Core watchdog logic\n// ---------------------------------------------------------------------------\n\nasync function writeJsonFile(filePath: string, data: unknown): Promise<void> {\n await mkdir(dirname(filePath), { recursive: true });\n await writeFile(filePath, JSON.stringify(data, null, 2), \"utf-8\");\n}\n\nexport const TASK_QUEUE_IDLE_PRODUCER = \"openclaw-hybrid-memory\";\n\nconst IDLE_DETAILS =\n \"Placeholder: no autonomous queue task is active. The factory overwrites this file when work is dispatched.\";\n\n/** Status values that imply a real queue entry when combined with producer/title/branch. */\nconst TASK_QUEUE_STATUS_WITH_PAYLOAD = new Set([\n \"running\",\n \"completed\",\n \"failed\",\n \"dispatched\",\n \"queued\",\n \"blocked\",\n \"cancelled\",\n]);\n\nfunction buildIdleTaskQueuePayload(): TaskQueueItem {\n return {\n status: \"idle\",\n producer: TASK_QUEUE_IDLE_PRODUCER,\n details: IDLE_DETAILS,\n };\n}\n\n/**\n * Returns true when `item` looks like a real task-queue snapshot (idle sentinel, running work,\n * forge issue, shell runner, etc.). Returns false for metadata-only shells such as\n * `{ updatedAt, repo, maxForge }` written by external tools (#1037).\n */\nexport function taskQueueItemHasRecognizedSemantics(item: TaskQueueItem): boolean {\n if (item.status === \"idle\" && item.producer === TASK_QUEUE_IDLE_PRODUCER) return true;\n if (typeof item.issue === \"number\" && Number.isFinite(item.issue) && item.issue > 0) return true;\n if (typeof item.pid === \"number\" && item.pid > 0) {\n if (typeof item.started === \"string\" && item.started.length > 0) return true;\n }\n if (item.dispatchToken != null && String(item.dispatchToken).trim().length > 0) return true;\n const st = item.status != null ? String(item.status).toLowerCase() : \"\";\n if (st && TASK_QUEUE_STATUS_WITH_PAYLOAD.has(st)) {\n if (item.producer != null && String(item.producer).trim().length > 0) return true;\n }\n if (item.branch != null && String(item.branch).trim().length > 0) {\n if (item.title != null && String(item.title).trim().length > 0) return true;\n if (typeof item.issue === \"number\" && item.issue > 0) return true;\n }\n if (item.title != null && String(item.title).trim().length > 0 && item.producer != null) {\n return String(item.producer).trim().length > 0;\n }\n return false;\n}\n\n/**\n * Write the canonical idle `current.json` (overwrites). Use after clearing a degenerate snapshot\n * so cron and prompts always see a valid hybrid-memory placeholder (#983, #1037).\n */\nexport async function writeCanonicalIdleTaskQueueFile(\n stateDir: string,\n logger?: { info?: (msg: string) => void },\n): Promise<void> {\n await mkdir(stateDir, { recursive: true });\n const currentPath = join(stateDir, \"current.json\");\n const body = JSON.stringify(buildIdleTaskQueuePayload(), null, 2);\n await writeFile(currentPath, body, { encoding: \"utf-8\" });\n logger?.info?.(`memory-hybrid: wrote canonical idle task-queue file at ${currentPath}`);\n}\n\n/**\n * When `current.json` is absent, write a minimal idle sentinel so tools and cron jobs can read\n * JSON from the canonical path without ENOENT (#983). Real factory tasks overwrite this file.\n *\n * @returns true when a new file was written.\n */\nexport async function ensureTaskQueueIdlePlaceholder(\n stateDir: string,\n logger?: { info?: (msg: string) => void },\n): Promise<boolean> {\n await mkdir(stateDir, { recursive: true });\n const currentPath = join(stateDir, \"current.json\");\n const body = JSON.stringify(buildIdleTaskQueuePayload(), null, 2);\n try {\n await writeFile(currentPath, body, { encoding: \"utf-8\", flag: \"wx\" });\n } catch (err) {\n const code = (err as NodeJS.ErrnoException).code;\n if (code === \"EEXIST\") return false;\n throw err;\n }\n logger?.info?.(`memory-hybrid: wrote task-queue idle placeholder at ${currentPath}`);\n return true;\n}\n\n/**\n * Build a timestamped history filename.\n * Format: YYYY-MM-DDTHH-MM-SS-{suffix}.json\n */\nfunction buildHistoryFilename(suffix: string): string {\n const ts = new Date().toISOString().replace(/[:.]/g, \"-\").slice(0, 19);\n return `${ts}-${suffix}.json`;\n}\n\n/**\n * Count how many quarantined/cleared history entries match the given task.\n * Matches by issue number if present, otherwise by branch name.\n * Used to determine retry exhaustion.\n */\nasync function countMatchingHistory(\n historyDir: string,\n issue: number | undefined,\n branch: string | undefined,\n): Promise<number> {\n // Need at least one identifier to match against\n if (issue == null && !branch) return 0;\n if (!existsSync(historyDir)) return 0;\n try {\n const files = (await readdir(historyDir)).filter((f) => f.endsWith(\".json\"));\n let count = 0;\n for (const file of files) {\n const item = await readJsonFile<TaskQueueItem>(join(historyDir, file));\n if (!item) continue;\n // Match by issue number if available, otherwise by branch\n if (issue != null && item.issue === issue) count++;\n else if (issue == null && branch && item.branch === branch) count++;\n }\n return count;\n } catch {\n return 0;\n }\n}\n\n/**\n * Run the task queue watchdog.\n *\n * Reads `<stateDir>/current.json`, checks health, and either:\n * - Returns `ok` if the entry looks healthy.\n * - Moves the entry to `<stateDir>/history/<timestamp>-{cleared|quarantined}.json`\n * if it is stale or broken, then deletes `current.json`.\n */\nexport async function runTaskQueueWatchdog(\n config: TaskQueueWatchdogConfig = {},\n logger?: { warn: (msg: string) => void; info: (msg: string) => void },\n): Promise<WatchdogResult> {\n const stateDir = config.stateDir ?? join(homedir(), \".openclaw\", \"workspace\", \"state\", \"task-queue\");\n const maxRuntimeMs = config.maxRuntimeMs ?? 4 * 60 * 60 * 1000; // 4 hours\n const maxRetries = config.maxRetries ?? 2;\n const enableRequeue = config.enableRequeue ?? false;\n const checkBranch = config.checkBranch ?? true;\n\n const currentPath = join(stateDir, \"current.json\");\n const historyDir = join(stateDir, \"history\");\n\n await mkdir(stateDir, { recursive: true });\n await ensureTaskQueueIdlePlaceholder(stateDir, logger);\n\n // Keep lease registry fresh even when there is no active current.json.\n try {\n await expireDispatchLeases(stateDir);\n } catch {\n // Non-fatal: watchdog should still function for current.json hygiene.\n }\n\n const item = await readJsonFile<TaskQueueItem>(currentPath);\n if (!item || typeof item !== \"object\" || Array.isArray(item)) {\n return { action: \"no-current\" };\n }\n\n // Idle placeholder — never treat as a stale factory run (#983).\n if (item.status === \"idle\" && item.producer === TASK_QUEUE_IDLE_PRODUCER) {\n return { action: \"ok\", item };\n }\n\n // Metadata-only or unknown shapes (e.g. external tools writing { updatedAt, repo, maxForge })\n // are not valid queue tasks — archive and restore canonical idle so automation sees a real sentinel (#1037).\n if (!taskQueueItemHasRecognizedSemantics(item)) {\n const recheckBefore = await readJsonFile<TaskQueueItem>(currentPath);\n if (recheckBefore != null && taskQueueItemHasRecognizedSemantics(recheckBefore)) {\n return { action: \"ok\", item: recheckBefore };\n }\n // Concurrent writers can replace the file between the initial read and this pass — do not archive/overwrite.\n if (recheckBefore == null || stableStringify(recheckBefore) !== stableStringify(item)) {\n return { action: \"ok\", item: recheckBefore ?? item };\n }\n\n const now = new Date().toISOString();\n const degenerateReason =\n \"current.json has no recognizable task-queue payload (metadata-only shell or unknown shape; issue #1037)\";\n await mkdir(historyDir, { recursive: true });\n const historyFilename = buildHistoryFilename(\"degenerate\");\n const historyPath = join(historyDir, historyFilename);\n const archived: TaskQueueItem = {\n ...item,\n watchdogClearedAt: now,\n watchdogReason: degenerateReason,\n };\n await writeJsonFile(historyPath, archived);\n try {\n const recheckAfter = await readJsonFile<TaskQueueItem>(currentPath);\n const stillSameSnapshot = recheckAfter != null && stableStringify(recheckAfter) === stableStringify(item);\n if (stillSameSnapshot) {\n await unlink(currentPath);\n await writeCanonicalIdleTaskQueueFile(stateDir, logger);\n const logMsg = `memory-hybrid: task-queue-watchdog — cleared degenerate current.json → idle placeholder (${historyFilename})`;\n logger?.info(logMsg);\n return {\n action: \"cleared\",\n reason: degenerateReason,\n item: archived,\n historyPath,\n };\n }\n return { action: \"ok\", item: recheckAfter ?? item };\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code !== \"ENOENT\") {\n capturePluginError(err instanceof Error ? err : new Error(String(err)), {\n subsystem: \"task-queue-watchdog\",\n operation: \"unlink-degenerate-current\",\n });\n }\n return { action: \"ok\", item };\n }\n }\n\n // ── Health checks ─────────────────────────────────────────────────────────\n\n let staleReason: string | null = null;\n\n // 1. PID liveness\n if (item.pid != null && !isPidAlive(item.pid)) {\n staleReason = `PID ${item.pid} is no longer alive`;\n }\n\n // 2. Max runtime\n if (!staleReason && isRuntimeExceeded(item.started, maxRuntimeMs)) {\n const hoursLimit = Math.round(maxRuntimeMs / (60 * 60 * 1000));\n staleReason = `runtime exceeded ${hoursLimit}h limit (started ${item.started})`;\n }\n\n // 3. Branch / worktree verification\n if (!staleReason && checkBranch && item.branch && config.repoDir) {\n const worktreeBranches = await getActiveWorktreeBranches(config.repoDir);\n if (worktreeBranches.size > 0 && !worktreeBranches.has(item.branch)) {\n staleReason = `branch \"${item.branch}\" not found in any active worktree`;\n }\n }\n\n if (!staleReason) {\n return { action: \"ok\", item };\n }\n\n // ── Recovery: clear or quarantine ─────────────────────────────────────────\n\n const previousRetries = item.retryCount ?? 0;\n const historyCount = await countMatchingHistory(historyDir, item.issue, item.branch);\n const isExhausted = previousRetries >= maxRetries || historyCount >= maxRetries;\n\n const action: WatchdogAction = isExhausted ? \"quarantined\" : \"cleared\";\n const now = new Date().toISOString();\n\n // If we can identify a lease for this issue, force it terminal so dispatch\n // dedupe does not depend on GitHub branch propagation.\n if (item.issue != null) {\n try {\n await transitionDispatchLease({\n stateDir,\n issue: item.issue,\n token: item.dispatchToken,\n toState: \"lease-expired\",\n reason: staleReason,\n });\n } catch {\n // Lease transition is best-effort here.\n }\n }\n\n const enrichedItem: TaskQueueItem = {\n ...item,\n watchdogClearedAt: now,\n watchdogReason: staleReason,\n retryCount: previousRetries + 1,\n // Explicitly set requeued to avoid inheriting a stale true from the spread\n requeued: enableRequeue && !isExhausted,\n };\n\n // Write to history\n await mkdir(historyDir, { recursive: true });\n const historyFilename = buildHistoryFilename(action);\n const historyPath = join(historyDir, historyFilename);\n await writeJsonFile(historyPath, enrichedItem);\n\n // Remove current.json — re-read to guard against TOCTOU race where a new task\n // was written between our initial read and this unlink.\n try {\n const recheck = await readJsonFile<TaskQueueItem>(currentPath);\n const identityMatches = recheck != null && taskQueueItemMatchesStale(recheck, item);\n if (identityMatches) {\n await unlink(currentPath);\n }\n // If recheck differs (new task started), leave current.json intact.\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code !== \"ENOENT\") {\n capturePluginError(err instanceof Error ? err : new Error(String(err)), {\n subsystem: \"task-queue-watchdog\",\n operation: \"unlink-current\",\n });\n }\n }\n\n const logMsg = `memory-hybrid: task-queue-watchdog — ${action} entry${item.issue != null ? ` for issue #${item.issue}` : \"\"}${item.branch ? ` (branch: ${item.branch})` : \"\"} — reason: ${staleReason}`;\n\n if (action === \"quarantined\") {\n logger?.warn(logMsg);\n } else {\n logger?.info(logMsg);\n }\n\n return {\n action,\n reason: staleReason,\n item: enrichedItem,\n historyPath,\n requeued: enableRequeue && !isExhausted,\n };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"task-queue-watchdog.js","names":["execFileCb"],"sources":["../../services/task-queue-watchdog.ts"],"sourcesContent":["/**\n * Task Queue Watchdog — Issue #631\n *\n * Detects stale/broken autonomous queue runs and self-heals by:\n * - Validating the PID in current.json is still alive\n * - Verifying the referenced branch/worktree still exists\n * - Detecting max-runtime exceeded entries\n * - Clearing or quarantining stale queue entries safely\n * - Emitting structured log events when intervening\n * - Optionally attaching retry metadata\n *\n * Addresses Engineering Goal 1: Rock-Solid Stability\n * Addresses Product Goal 4: Autonomous Maintenance\n */\n\nimport { existsSync } from \"node:fs\";\nimport { mkdir, readdir, unlink, writeFile } from \"node:fs/promises\";\nimport { homedir } from \"node:os\";\nimport { dirname, join } from \"node:path\";\nimport { promisify } from \"node:util\";\nimport { readJsonFile } from \"../utils/fs.js\";\nimport { execFile as execFileCb } from \"../utils/process-runner.js\";\nimport { stableStringify } from \"../utils/stable-stringify.js\";\nimport { capturePluginError } from \"./error-reporter.js\";\nimport { expireDispatchLeases, transitionDispatchLease } from \"./task-queue-leases.js\";\n\nconst execFile = promisify(execFileCb);\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface TaskQueueWatchdogConfig {\n /** Directory containing current.json and history/. Defaults to ~/.openclaw/workspace/state/task-queue */\n stateDir?: string;\n /** Root of the git repository — used for branch/worktree verification. */\n repoDir?: string;\n /** Maximum task runtime in milliseconds before the entry is flagged stale. Default: 4 hours */\n maxRuntimeMs?: number;\n /** Maximum retry attempts before an entry is quarantined permanently. Default: 2 */\n maxRetries?: number;\n /** Whether to requeue (add retry metadata) after clearing. Default: false */\n enableRequeue?: boolean;\n /** If true, treat a missing branch as stale (requires repoDir). Default: true */\n checkBranch?: boolean;\n}\n\ntype WatchdogAction =\n | \"no-current\" // No active current.json found\n | \"ok\" // Entry is healthy, no action needed\n | \"cleared\" // Stale entry moved to history (will be retried)\n | \"quarantined\"; // Entry exceeded retry limit; moved to quarantine\n\ninterface WatchdogResult {\n /** What the watchdog decided to do */\n action: WatchdogAction;\n /** Human-readable explanation for the action */\n reason?: string;\n /** The task queue item that was inspected */\n item?: TaskQueueItem;\n /** Absolute path of the history/quarantine file written */\n historyPath?: string;\n /** True when retry metadata was attached */\n requeued?: boolean;\n}\n\n/** Subset of TaskQueueItem used internally — avoids importing from dashboard-server */\nexport interface TaskQueueItem {\n issue?: number;\n title?: string;\n branch?: string;\n dispatchToken?: string;\n pid?: number;\n started?: string;\n /** When set to `idle`, the entry is a placeholder (no factory task yet) — see #983. */\n status?: string;\n completed?: string;\n exit_code?: number;\n details?: string;\n /** Set on idle placeholders written by hybrid-mem so consumers can distinguish them. */\n producer?: string;\n /** Retry counter attached by the watchdog on successive clears */\n retryCount?: number;\n /** ISO timestamp when the watchdog last intervened */\n watchdogClearedAt?: string;\n /** Reason the watchdog flagged this entry */\n watchdogReason?: string;\n /** Set to true by the watchdog when requeue is requested */\n requeued?: boolean;\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers (exported for testing)\n// ---------------------------------------------------------------------------\n\n/**\n * Returns true when `current` is the same queue task as `stale` for safe replacement of current.json.\n * When pid/started are absent, pid/start equality must not be used (undefined === undefined is always true).\n */\nexport function taskQueueItemMatchesStale(current: TaskQueueItem, stale: TaskQueueItem): boolean {\n const hasPidOrStarted = stale.pid != null || stale.started != null;\n if (hasPidOrStarted) {\n return current.pid === stale.pid && current.started === stale.started;\n }\n return (\n current.issue === stale.issue && current.dispatchToken === stale.dispatchToken && current.branch === stale.branch\n );\n}\n\n/**\n * Returns true if the given PID is alive on this system.\n * Uses signal 0 (no-op) — throws ESRCH if no such process.\n */\nexport function isPidAlive(pid: number): boolean {\n if (!Number.isInteger(pid) || pid <= 0) return false;\n try {\n process.kill(pid, 0);\n return true;\n } catch (err) {\n const code = (err as NodeJS.ErrnoException).code;\n // ESRCH = no such process (dead)\n // EPERM = process exists but we lack permission to signal it (alive)\n if (code === \"ESRCH\") return false;\n if (code === \"EPERM\") return true;\n // Unexpected error — assume dead to unblock the queue\n return false;\n }\n}\n\n/**\n * Returns the set of branch names referenced by active git worktrees.\n * Gracefully returns an empty set if git is unavailable or repoDir is absent.\n */\nexport async function getActiveWorktreeBranches(repoDir: string): Promise<Set<string>> {\n try {\n const { stdout } = await execFile(\"git\", [\"-C\", repoDir, \"worktree\", \"list\", \"--porcelain\"], {\n timeout: 5000,\n });\n const branches = new Set<string>();\n for (const line of stdout.split(\"\\n\")) {\n const trimmed = line.trim();\n if (trimmed.startsWith(\"branch refs/heads/\")) {\n branches.add(trimmed.slice(\"branch refs/heads/\".length));\n }\n }\n return branches;\n } catch {\n return new Set();\n }\n}\n\n/**\n * Returns true if `started` is more than `maxRuntimeMs` milliseconds ago.\n * Returns false if `started` is missing or unparseable (gives benefit of the doubt).\n */\nexport function isRuntimeExceeded(started: string | undefined, maxRuntimeMs: number): boolean {\n if (!started) return false;\n const startedMs = new Date(started).getTime();\n if (Number.isNaN(startedMs)) return false;\n return Date.now() - startedMs > maxRuntimeMs;\n}\n\n// ---------------------------------------------------------------------------\n// Core watchdog logic\n// ---------------------------------------------------------------------------\n\nasync function writeJsonFile(filePath: string, data: unknown): Promise<void> {\n await mkdir(dirname(filePath), { recursive: true });\n await writeFile(filePath, JSON.stringify(data, null, 2), \"utf-8\");\n}\n\nexport const TASK_QUEUE_IDLE_PRODUCER = \"openclaw-hybrid-memory\";\n\nconst IDLE_DETAILS =\n \"Placeholder: no autonomous queue task is active. The factory overwrites this file when work is dispatched.\";\n\n/** Status values that imply a real queue entry when combined with producer/title/branch. */\nconst TASK_QUEUE_STATUS_WITH_PAYLOAD = new Set([\n \"running\",\n \"completed\",\n \"failed\",\n \"dispatched\",\n \"queued\",\n \"blocked\",\n \"cancelled\",\n]);\n\nfunction buildIdleTaskQueuePayload(): TaskQueueItem {\n return {\n status: \"idle\",\n producer: TASK_QUEUE_IDLE_PRODUCER,\n details: IDLE_DETAILS,\n };\n}\n\n/**\n * Returns true when `item` looks like a real task-queue snapshot (idle sentinel, running work,\n * forge issue, shell runner, etc.). Returns false for metadata-only shells such as\n * `{ updatedAt, repo, maxForge }` written by external tools (#1037).\n */\nexport function taskQueueItemHasRecognizedSemantics(item: TaskQueueItem): boolean {\n if (item.status === \"idle\" && item.producer === TASK_QUEUE_IDLE_PRODUCER) return true;\n if (typeof item.issue === \"number\" && Number.isFinite(item.issue) && item.issue > 0) return true;\n if (typeof item.pid === \"number\" && item.pid > 0) {\n if (typeof item.started === \"string\" && item.started.length > 0) return true;\n }\n if (item.dispatchToken != null && String(item.dispatchToken).trim().length > 0) return true;\n const st = item.status != null ? String(item.status).toLowerCase() : \"\";\n if (st && TASK_QUEUE_STATUS_WITH_PAYLOAD.has(st)) {\n if (item.producer != null && String(item.producer).trim().length > 0) return true;\n }\n if (item.branch != null && String(item.branch).trim().length > 0) {\n if (item.title != null && String(item.title).trim().length > 0) return true;\n if (typeof item.issue === \"number\" && item.issue > 0) return true;\n }\n if (item.title != null && String(item.title).trim().length > 0 && item.producer != null) {\n return String(item.producer).trim().length > 0;\n }\n return false;\n}\n\n/**\n * Write the canonical idle `current.json` (overwrites). Use after clearing a degenerate snapshot\n * so cron and prompts always see a valid hybrid-memory placeholder (#983, #1037).\n */\nexport async function writeCanonicalIdleTaskQueueFile(\n stateDir: string,\n logger?: { info?: (msg: string) => void },\n): Promise<void> {\n await mkdir(stateDir, { recursive: true });\n const currentPath = join(stateDir, \"current.json\");\n const body = JSON.stringify(buildIdleTaskQueuePayload(), null, 2);\n await writeFile(currentPath, body, { encoding: \"utf-8\" });\n logger?.info?.(`memory-hybrid: wrote canonical idle task-queue file at ${currentPath}`);\n}\n\n/**\n * When `current.json` is absent, write a minimal idle sentinel so tools and cron jobs can read\n * JSON from the canonical path without ENOENT (#983). Real factory tasks overwrite this file.\n *\n * @returns true when a new file was written.\n */\nexport async function ensureTaskQueueIdlePlaceholder(\n stateDir: string,\n logger?: { info?: (msg: string) => void },\n): Promise<boolean> {\n await mkdir(stateDir, { recursive: true });\n const currentPath = join(stateDir, \"current.json\");\n const body = JSON.stringify(buildIdleTaskQueuePayload(), null, 2);\n try {\n await writeFile(currentPath, body, { encoding: \"utf-8\", flag: \"wx\" });\n } catch (err) {\n const code = (err as NodeJS.ErrnoException).code;\n if (code === \"EEXIST\") return false;\n throw err;\n }\n logger?.info?.(`memory-hybrid: wrote task-queue idle placeholder at ${currentPath}`);\n return true;\n}\n\n/**\n * Build a timestamped history filename.\n * Format: YYYY-MM-DDTHH-MM-SS-{suffix}.json\n */\nfunction buildHistoryFilename(suffix: string): string {\n const ts = new Date().toISOString().replace(/[:.]/g, \"-\").slice(0, 19);\n return `${ts}-${suffix}.json`;\n}\n\n/**\n * Count how many quarantined/cleared history entries match the given task.\n * Matches by issue number if present, otherwise by branch name.\n * Used to determine retry exhaustion.\n */\nasync function countMatchingHistory(\n historyDir: string,\n issue: number | undefined,\n branch: string | undefined,\n): Promise<number> {\n // Need at least one identifier to match against\n if (issue == null && !branch) return 0;\n if (!existsSync(historyDir)) return 0;\n try {\n const files = (await readdir(historyDir)).filter((f) => f.endsWith(\".json\"));\n let count = 0;\n for (const file of files) {\n const item = await readJsonFile<TaskQueueItem>(join(historyDir, file));\n if (!item) continue;\n // Match by issue number if available, otherwise by branch\n if (issue != null && item.issue === issue) count++;\n else if (issue == null && branch && item.branch === branch) count++;\n }\n return count;\n } catch {\n return 0;\n }\n}\n\n/**\n * Run the task queue watchdog.\n *\n * Reads `<stateDir>/current.json`, checks health, and either:\n * - Returns `ok` if the entry looks healthy.\n * - Moves the entry to `<stateDir>/history/<timestamp>-{cleared|quarantined}.json`\n * if it is stale or broken, then deletes `current.json`.\n */\nexport async function runTaskQueueWatchdog(\n config: TaskQueueWatchdogConfig = {},\n logger?: { warn: (msg: string) => void; info: (msg: string) => void },\n): Promise<WatchdogResult> {\n const stateDir = config.stateDir ?? join(homedir(), \".openclaw\", \"workspace\", \"state\", \"task-queue\");\n const maxRuntimeMs = config.maxRuntimeMs ?? 4 * 60 * 60 * 1000; // 4 hours\n const maxRetries = config.maxRetries ?? 2;\n const enableRequeue = config.enableRequeue ?? false;\n const checkBranch = config.checkBranch ?? true;\n\n const currentPath = join(stateDir, \"current.json\");\n const historyDir = join(stateDir, \"history\");\n\n await mkdir(stateDir, { recursive: true });\n await ensureTaskQueueIdlePlaceholder(stateDir, logger);\n\n // Keep lease registry fresh even when there is no active current.json.\n try {\n await expireDispatchLeases(stateDir);\n } catch {\n // Non-fatal: watchdog should still function for current.json hygiene.\n }\n\n const item = await readJsonFile<TaskQueueItem>(currentPath);\n if (!item || typeof item !== \"object\" || Array.isArray(item)) {\n return { action: \"no-current\" };\n }\n\n // Idle placeholder — never treat as a stale factory run (#983).\n if (item.status === \"idle\" && item.producer === TASK_QUEUE_IDLE_PRODUCER) {\n return { action: \"ok\", item };\n }\n\n // Metadata-only or unknown shapes (e.g. external tools writing { updatedAt, repo, maxForge })\n // are not valid queue tasks — archive and restore canonical idle so automation sees a real sentinel (#1037).\n if (!taskQueueItemHasRecognizedSemantics(item)) {\n const recheckBefore = await readJsonFile<TaskQueueItem>(currentPath);\n if (recheckBefore != null && taskQueueItemHasRecognizedSemantics(recheckBefore)) {\n return { action: \"ok\", item: recheckBefore };\n }\n // Concurrent writers can replace the file between the initial read and this pass — do not archive/overwrite.\n if (recheckBefore == null || stableStringify(recheckBefore) !== stableStringify(item)) {\n return { action: \"ok\", item: recheckBefore ?? item };\n }\n\n const now = new Date().toISOString();\n const degenerateReason =\n \"current.json has no recognizable task-queue payload (metadata-only shell or unknown shape; issue #1037)\";\n await mkdir(historyDir, { recursive: true });\n const historyFilename = buildHistoryFilename(\"degenerate\");\n const historyPath = join(historyDir, historyFilename);\n const archived: TaskQueueItem = {\n ...item,\n watchdogClearedAt: now,\n watchdogReason: degenerateReason,\n };\n await writeJsonFile(historyPath, archived);\n try {\n const recheckAfter = await readJsonFile<TaskQueueItem>(currentPath);\n const stillSameSnapshot = recheckAfter != null && stableStringify(recheckAfter) === stableStringify(item);\n if (stillSameSnapshot) {\n await unlink(currentPath);\n await writeCanonicalIdleTaskQueueFile(stateDir, logger);\n const logMsg = `memory-hybrid: task-queue-watchdog — cleared degenerate current.json → idle placeholder (${historyFilename})`;\n logger?.info(logMsg);\n return {\n action: \"cleared\",\n reason: degenerateReason,\n item: archived,\n historyPath,\n };\n }\n return { action: \"ok\", item: recheckAfter ?? item };\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code !== \"ENOENT\") {\n capturePluginError(err instanceof Error ? err : new Error(String(err)), {\n subsystem: \"task-queue-watchdog\",\n operation: \"unlink-degenerate-current\",\n });\n }\n return { action: \"ok\", item };\n }\n }\n\n // ── Health checks ─────────────────────────────────────────────────────────\n\n let staleReason: string | null = null;\n\n // 1. PID liveness\n if (item.pid != null && !isPidAlive(item.pid)) {\n staleReason = `PID ${item.pid} is no longer alive`;\n }\n\n // 2. Max runtime\n if (!staleReason && isRuntimeExceeded(item.started, maxRuntimeMs)) {\n const hoursLimit = Math.round(maxRuntimeMs / (60 * 60 * 1000));\n staleReason = `runtime exceeded ${hoursLimit}h limit (started ${item.started})`;\n }\n\n // 3. Branch / worktree verification\n if (!staleReason && checkBranch && item.branch && config.repoDir) {\n const worktreeBranches = await getActiveWorktreeBranches(config.repoDir);\n if (worktreeBranches.size > 0 && !worktreeBranches.has(item.branch)) {\n staleReason = `branch \"${item.branch}\" not found in any active worktree`;\n }\n }\n\n if (!staleReason) {\n return { action: \"ok\", item };\n }\n\n // ── Recovery: clear or quarantine ─────────────────────────────────────────\n\n const previousRetries = item.retryCount ?? 0;\n const historyCount = await countMatchingHistory(historyDir, item.issue, item.branch);\n const isExhausted = previousRetries >= maxRetries || historyCount >= maxRetries;\n\n const action: WatchdogAction = isExhausted ? \"quarantined\" : \"cleared\";\n const now = new Date().toISOString();\n\n // If we can identify a lease for this issue, force it terminal so dispatch\n // dedupe does not depend on GitHub branch propagation.\n if (item.issue != null) {\n try {\n await transitionDispatchLease({\n stateDir,\n issue: item.issue,\n token: item.dispatchToken,\n toState: \"lease-expired\",\n reason: staleReason,\n });\n } catch {\n // Lease transition is best-effort here.\n }\n }\n\n const enrichedItem: TaskQueueItem = {\n ...item,\n watchdogClearedAt: now,\n watchdogReason: staleReason,\n retryCount: previousRetries + 1,\n // Explicitly set requeued to avoid inheriting a stale true from the spread\n requeued: enableRequeue && !isExhausted,\n };\n\n // Write to history\n await mkdir(historyDir, { recursive: true });\n const historyFilename = buildHistoryFilename(action);\n const historyPath = join(historyDir, historyFilename);\n await writeJsonFile(historyPath, enrichedItem);\n\n // Remove current.json — re-read to guard against TOCTOU race where a new task\n // was written between our initial read and this unlink.\n try {\n const recheck = await readJsonFile<TaskQueueItem>(currentPath);\n const identityMatches = recheck != null && taskQueueItemMatchesStale(recheck, item);\n if (identityMatches) {\n await unlink(currentPath);\n }\n // If recheck differs (new task started), leave current.json intact.\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code !== \"ENOENT\") {\n capturePluginError(err instanceof Error ? err : new Error(String(err)), {\n subsystem: \"task-queue-watchdog\",\n operation: \"unlink-current\",\n });\n }\n }\n\n const logMsg = `memory-hybrid: task-queue-watchdog — ${action} entry${item.issue != null ? ` for issue #${item.issue}` : \"\"}${item.branch ? ` (branch: ${item.branch})` : \"\"} — reason: ${staleReason}`;\n\n if (action === \"quarantined\") {\n logger?.warn(logMsg);\n } else {\n logger?.info(logMsg);\n }\n\n return {\n action,\n reason: staleReason,\n item: enrichedItem,\n historyPath,\n requeued: enableRequeue && !isExhausted,\n };\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tool-effectiveness.js","names":["resolvedWorkflowDb"],"sources":["../../services/tool-effectiveness.ts"],"sourcesContent":["/**\n * Tool Effectiveness Scoring — Aggregate workflow traces into per-tool score profiles.\n * Issue #263 — Phase 3.\n *\n * Algorithm for each tool:\n * successRate = successCount / totalCalls\n * avgDuration = Σ(durationMs) / count\n * redundancyScore = avgTimesUsedPerSession (how often tool repeats in one session)\n * compositeScore = (successRate × 0.5) + ((1 - avgDuration/maxDuration) × 0.3) + ((1 - redundancyScore) × 0.2)\n *\n * Scores are persisted in the `tool_effectiveness` SQLite table.\n * A persistent score decay (factor 0.95) is applied each run so stale data fades out.\n * Low scorers can be flagged in CLI output.\n */\n\nimport { existsSync, mkdirSync } from \"node:fs\";\nimport { dirname } from \"node:path\";\nimport { DatabaseSync } from \"node:sqlite\";\nimport { BaseSqliteStore } from \"../backends/base-sqlite-store.js\";\nimport type { FactsDB } from \"../backends/facts-db.js\";\nimport type { ToolEffectivenessConfig } from \"../config/types/features.js\";\nimport { SQLITE_BUSY_TIMEOUT_MS } from \"../utils/constants.js\";\nimport { capturePluginError } from \"./error-reporter.js\";\n\n// ---------------------------------------------------------------------------\n// Public types\n// ---------------------------------------------------------------------------\n\nexport interface ToolMetrics {\n tool: string;\n /** Context label for this score row (default \"general\"). */\n context: string;\n totalCalls: number;\n successCalls: number;\n failureCalls: number;\n unknownCalls: number;\n successRate: number;\n avgDurationMs: number;\n avgCallsPerSession: number;\n redundancyScore: number; // 0-1: 1 = maximally redundant (same tool called many times)\n compositeScore: number; // 0-1: higher is better\n lastUpdated: number; // epoch seconds\n}\n\nexport interface ToolEffectivenessReport {\n computedAt: number;\n toolsScored: number;\n topTools: ToolMetrics[];\n lowScoreTools: ToolMetrics[];\n allScores: ToolMetrics[];\n recommendations: string[];\n}\n// ToolEffectivenessConfig is imported from ../config/types/features.js\n// Re-export for consumers that import it from this module.\n\n// ---------------------------------------------------------------------------\n// Database schema\n// ---------------------------------------------------------------------------\n\nconst SCHEMA = `\nCREATE TABLE IF NOT EXISTS tool_effectiveness (\n tool TEXT NOT NULL,\n context TEXT NOT NULL DEFAULT 'general',\n total_calls INTEGER NOT NULL DEFAULT 0,\n success_calls INTEGER NOT NULL DEFAULT 0,\n failure_calls INTEGER NOT NULL DEFAULT 0,\n unknown_calls INTEGER NOT NULL DEFAULT 0,\n avg_duration_ms REAL NOT NULL DEFAULT 0,\n avg_calls_per_session REAL NOT NULL DEFAULT 0,\n composite_score REAL NOT NULL DEFAULT 0,\n last_updated INTEGER NOT NULL,\n PRIMARY KEY (tool, context)\n);\n`;\n\n// ---------------------------------------------------------------------------\n// ToolEffectivenessStore\n// ---------------------------------------------------------------------------\n\nexport class ToolEffectivenessStore extends BaseSqliteStore {\n constructor(dbPath: string) {\n mkdirSync(dirname(dbPath), { recursive: true });\n const db = new DatabaseSync(dbPath);\n super(db, { foreignKeys: true });\n this.liveDb.exec(SCHEMA);\n }\n\n protected getSubsystemName(): string {\n return \"tool-effectiveness-store\";\n }\n\n /** Upsert a tool score row. */\n upsert(metrics: ToolMetrics): void {\n const context = metrics.context ?? \"general\";\n this.liveDb\n .prepare(\n `INSERT INTO tool_effectiveness\n (tool, context, total_calls, success_calls, failure_calls, unknown_calls, avg_duration_ms, avg_calls_per_session, composite_score, last_updated)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT(tool, context) DO UPDATE SET\n total_calls = excluded.total_calls,\n success_calls = excluded.success_calls,\n failure_calls = excluded.failure_calls,\n unknown_calls = excluded.unknown_calls,\n avg_duration_ms = excluded.avg_duration_ms,\n avg_calls_per_session = excluded.avg_calls_per_session,\n composite_score = excluded.composite_score,\n last_updated = excluded.last_updated`,\n )\n .run(\n metrics.tool,\n context,\n metrics.totalCalls,\n metrics.successCalls,\n metrics.failureCalls,\n metrics.unknownCalls,\n metrics.avgDurationMs,\n metrics.avgCallsPerSession,\n metrics.compositeScore,\n metrics.lastUpdated,\n );\n }\n\n /**\n * Record a single tool outcome (incremental upsert for real-time tracking).\n *\n * @param tool Tool name.\n * @param outcome \"success\" | \"failure\" | \"unknown\".\n * @param context Context label (default \"general\").\n * @param durationMs Duration of the call in milliseconds (default 0).\n *\n * Note: `avg_calls_per_session` is intentionally not updated here because individual\n * tool outcome events lack session boundary information. It is computed during the\n * nightly batch analysis via `computeToolEffectiveness` / `upsert`, which has access\n * to per-session call counts from workflow traces.\n */\n recordToolOutcome(\n tool: string,\n outcome: \"success\" | \"failure\" | \"unknown\",\n context = \"general\",\n durationMs = 0,\n ): void {\n const now = Math.floor(Date.now() / 1000);\n this.liveDb\n .prepare(\n `INSERT INTO tool_effectiveness\n (tool, context, total_calls, success_calls, failure_calls, unknown_calls, avg_duration_ms, avg_calls_per_session, composite_score, last_updated)\n VALUES (?, ?, 1,\n CASE ? WHEN 'success' THEN 1 ELSE 0 END,\n CASE ? WHEN 'failure' THEN 1 ELSE 0 END,\n CASE ? WHEN 'unknown' THEN 1 ELSE 0 END,\n ?, 1.0, 0.5, ?)\n ON CONFLICT(tool, context) DO UPDATE SET\n total_calls = total_calls + 1,\n success_calls = success_calls + CASE ? WHEN 'success' THEN 1 ELSE 0 END,\n failure_calls = failure_calls + CASE ? WHEN 'failure' THEN 1 ELSE 0 END,\n unknown_calls = unknown_calls + CASE ? WHEN 'unknown' THEN 1 ELSE 0 END,\n avg_duration_ms = (avg_duration_ms * total_calls + ?) / (total_calls + 1),\n composite_score = CAST(success_calls + CASE ? WHEN 'success' THEN 1 ELSE 0 END AS REAL) / MAX(total_calls + 1, 1) * 0.5 + 0.25,\n last_updated = ?`,\n )\n .run(\n tool,\n context,\n outcome,\n outcome,\n outcome,\n durationMs,\n now,\n outcome,\n outcome,\n outcome,\n durationMs,\n outcome,\n now,\n );\n }\n\n /**\n * Get effectiveness for a specific tool, optionally filtered by context.\n *\n * @param tool Tool name.\n * @param context Optional context filter. If omitted, returns all context rows for the tool.\n * @returns Array of ToolMetrics (one per context), or empty array if not found.\n */\n getToolEffectiveness(tool: string, context?: string): ToolMetrics[] {\n type Row = {\n tool: string;\n context: string;\n total_calls: number;\n success_calls: number;\n failure_calls: number;\n unknown_calls: number;\n avg_duration_ms: number;\n avg_calls_per_session: number;\n composite_score: number;\n last_updated: number;\n };\n\n let rows: Row[];\n if (context !== undefined) {\n rows = this.liveDb\n .prepare(\"SELECT * FROM tool_effectiveness WHERE tool = ? AND context = ?\")\n .all(tool, context) as Row[];\n } else {\n rows = this.liveDb.prepare(\"SELECT * FROM tool_effectiveness WHERE tool = ? ORDER BY context\").all(tool) as Row[];\n }\n\n return rows.map((r) => ({\n tool: r.tool,\n context: r.context,\n totalCalls: r.total_calls,\n successCalls: r.success_calls,\n failureCalls: r.failure_calls,\n unknownCalls: r.unknown_calls,\n successRate: r.total_calls > 0 ? r.success_calls / r.total_calls : 0,\n avgDurationMs: r.avg_duration_ms,\n avgCallsPerSession: r.avg_calls_per_session,\n redundancyScore: Math.min(1, Math.max(0, (r.avg_calls_per_session - 1) / 4)),\n compositeScore: r.composite_score,\n lastUpdated: r.last_updated,\n }));\n }\n\n /** Apply decay to all scores. */\n applyDecay(factor: number): void {\n this.liveDb.prepare(\"UPDATE tool_effectiveness SET composite_score = composite_score * ?\").run(factor);\n }\n\n /** Get all scores ordered by composite_score DESC. */\n getAll(): ToolMetrics[] {\n const rows = this.liveDb.prepare(\"SELECT * FROM tool_effectiveness ORDER BY composite_score DESC\").all() as Array<{\n tool: string;\n context: string;\n total_calls: number;\n success_calls: number;\n failure_calls: number;\n unknown_calls: number;\n avg_duration_ms: number;\n avg_calls_per_session: number;\n composite_score: number;\n last_updated: number;\n }>;\n\n return rows.map((r) => ({\n tool: r.tool,\n context: r.context ?? \"general\",\n totalCalls: r.total_calls,\n successCalls: r.success_calls,\n failureCalls: r.failure_calls,\n unknownCalls: r.unknown_calls,\n successRate: r.total_calls > 0 ? r.success_calls / r.total_calls : 0,\n avgDurationMs: r.avg_duration_ms,\n avgCallsPerSession: r.avg_calls_per_session,\n redundancyScore: Math.min(1, Math.max(0, (r.avg_calls_per_session - 1) / 4)),\n compositeScore: r.composite_score,\n lastUpdated: r.last_updated,\n }));\n }\n\n /** Get score for a specific tool (first context row, or \"general\"). */\n getByTool(tool: string): ToolMetrics | null {\n const row = this.liveDb\n .prepare(\"SELECT * FROM tool_effectiveness WHERE tool = ? ORDER BY context LIMIT 1\")\n .get(tool) as\n | {\n tool: string;\n context: string;\n total_calls: number;\n success_calls: number;\n failure_calls: number;\n unknown_calls: number;\n avg_duration_ms: number;\n avg_calls_per_session: number;\n composite_score: number;\n last_updated: number;\n }\n | undefined;\n\n if (!row) return null;\n return {\n tool: row.tool,\n context: row.context ?? \"general\",\n totalCalls: row.total_calls,\n successCalls: row.success_calls,\n failureCalls: row.failure_calls,\n unknownCalls: row.unknown_calls,\n successRate: row.total_calls > 0 ? row.success_calls / row.total_calls : 0,\n avgDurationMs: row.avg_duration_ms,\n avgCallsPerSession: row.avg_calls_per_session,\n redundancyScore: Math.min(1, Math.max(0, (row.avg_calls_per_session - 1) / 4)),\n compositeScore: row.composite_score,\n lastUpdated: row.last_updated,\n };\n }\n\n /** Count of scored tools. */\n count(): number {\n const row = this.liveDb.prepare(\"SELECT COUNT(*) as n FROM tool_effectiveness\").get() as { n: number };\n return row.n;\n }\n}\n\n// ---------------------------------------------------------------------------\n// Analytics helpers\n// ---------------------------------------------------------------------------\n\ninterface TraceRow {\n tool_sequence: string;\n outcome: string;\n duration_ms: number;\n session_id: string;\n}\n\n/** Aggregate raw workflow trace rows into per-tool metrics. */\nexport function aggregateTraceRows(rows: TraceRow[], minCalls: number): ToolMetrics[] {\n if (rows.length === 0) return [];\n\n const toolStats = new Map<\n string,\n {\n total: number;\n success: number;\n failure: number;\n unknown: number;\n durations: number[];\n // sessionId → call count in that session\n sessionCalls: Map<string, number>;\n }\n >();\n\n const maxDuration = rows.reduce((max, r) => Math.max(max, r.duration_ms ?? 0), 0) || 1;\n\n for (const row of rows) {\n let seq: string[] = [];\n try {\n seq = JSON.parse(row.tool_sequence) as string[];\n } catch {\n continue;\n }\n const uniqueToolsInRow = new Set(seq);\n const sessionId = row.session_id ?? \"unknown\";\n\n for (const tool of uniqueToolsInRow) {\n if (!toolStats.has(tool)) {\n toolStats.set(tool, {\n total: 0,\n success: 0,\n failure: 0,\n unknown: 0,\n durations: [],\n sessionCalls: new Map(),\n });\n }\n const s = toolStats.get(tool)!;\n\n // Count occurrences of this tool in this trace\n const callsInTrace = seq.filter((t) => t === tool).length;\n s.total += callsInTrace;\n\n // Outcome attribution\n if (row.outcome === \"success\") s.success += callsInTrace;\n else if (row.outcome === \"failure\") s.failure += callsInTrace;\n else s.unknown += callsInTrace;\n\n // Duration: attribute the trace duration equally across unique tools\n s.durations.push((row.duration_ms ?? 0) / uniqueToolsInRow.size);\n\n // Session redundancy\n const prevCalls = s.sessionCalls.get(sessionId) ?? 0;\n s.sessionCalls.set(sessionId, prevCalls + callsInTrace);\n }\n }\n\n const results: ToolMetrics[] = [];\n\n for (const [tool, s] of toolStats) {\n if (s.total < minCalls) continue;\n\n const successRate = s.total > 0 ? s.success / s.total : 0;\n const avgDuration = s.durations.reduce((a, b) => a + b, 0) / Math.max(1, s.durations.length);\n const sessionCallCounts = [...s.sessionCalls.values()];\n const avgCallsPerSession = sessionCallCounts.reduce((a, b) => a + b, 0) / Math.max(1, sessionCallCounts.length);\n\n // Redundancy = (avg calls per session - 1) / 4 clamped to [0,1]\n // 1 call/session = 0 redundancy; 5+ calls/session = 1.0 redundancy\n const redundancyScore = Math.min(1, Math.max(0, (avgCallsPerSession - 1) / 4));\n\n // Duration score: 1 = very fast, 0 = slowest seen\n const durationScore = 1 - Math.min(1, avgDuration / maxDuration);\n\n // Composite: success 50%, speed 30%, low-redundancy 20%\n const compositeScore = successRate * 0.5 + durationScore * 0.3 + (1 - redundancyScore) * 0.2;\n\n results.push({\n tool,\n context: \"general\",\n totalCalls: s.total,\n successCalls: s.success,\n failureCalls: s.failure,\n unknownCalls: s.unknown,\n successRate,\n avgDurationMs: avgDuration,\n avgCallsPerSession,\n redundancyScore,\n compositeScore: Math.min(1, Math.max(0, compositeScore)),\n lastUpdated: Math.floor(Date.now() / 1000),\n });\n }\n\n return results.sort((a, b) => b.compositeScore - a.compositeScore);\n}\n\n/** Generate human-readable recommendations from tool metrics. */\nexport function generateRecommendations(metrics: ToolMetrics[], lowScoreThreshold: number): string[] {\n const recs: string[] = [];\n\n const lowScorers = metrics.filter((m) => m.compositeScore < lowScoreThreshold && m.totalCalls >= 5);\n if (lowScorers.length > 0) {\n const names = lowScorers\n .slice(0, 3)\n .map((m) => `${m.tool} (score: ${m.compositeScore.toFixed(2)})`)\n .join(\", \");\n recs.push(`Low-scoring tools may need workflow review: ${names}`);\n }\n\n const highRedundancy = metrics.filter((m) => m.redundancyScore > 0.7 && m.totalCalls >= 5);\n if (highRedundancy.length > 0) {\n const names = highRedundancy\n .slice(0, 3)\n .map((m) => `${m.tool} (avg ${m.avgCallsPerSession.toFixed(1)}x/session)`)\n .join(\", \");\n recs.push(`Highly redundant tools (called many times per session): ${names}`);\n }\n\n const lowSuccess = metrics.filter((m) => m.successRate < 0.5 && m.totalCalls >= 5);\n if (lowSuccess.length > 0) {\n const names = lowSuccess\n .slice(0, 3)\n .map((m) => `${m.tool} (${(m.successRate * 100).toFixed(0)}% success)`)\n .join(\", \");\n recs.push(`Tools with low success rates: ${names}`);\n }\n\n if (recs.length === 0) {\n const topTool = metrics[0];\n if (topTool) {\n recs.push(`Best performing tool: ${topTool.tool} (score: ${topTool.compositeScore.toFixed(2)})`);\n }\n }\n\n return recs;\n}\n\n// ---------------------------------------------------------------------------\n// Main entry point\n// ---------------------------------------------------------------------------\n\n/**\n * Compute tool effectiveness scores from a workflow store database.\n *\n * @param workflowDbPath Path to the workflow_traces SQLite database.\n * @param effectivenessDb Pre-opened ToolEffectivenessStore (optional; created if omitted).\n * @param cfg Configuration.\n * @param logger Optional logger.\n * @returns Effectiveness report.\n */\nexport async function computeToolEffectiveness(\n workflowDbPath: string,\n effectivenessDb: ToolEffectivenessStore | null,\n cfg: Partial<ToolEffectivenessConfig> = {},\n logger: { warn?: (msg: string) => void } = {},\n): Promise<ToolEffectivenessReport> {\n const minCalls = cfg.minCalls ?? 3;\n const topN = cfg.topN ?? 10;\n const lowScoreThreshold = cfg.lowScoreThreshold ?? 0.3;\n const decayFactor = cfg.decayFactor ?? 0.95;\n\n const report: ToolEffectivenessReport = {\n computedAt: Math.floor(Date.now() / 1000),\n toolsScored: 0,\n topTools: [],\n lowScoreTools: [],\n allScores: [],\n recommendations: [],\n };\n\n // Open the workflow traces DB (read-only if possible)\n let traceDb: DatabaseSync | null = null;\n let ownedEffStore = false;\n let effStore = effectivenessDb;\n\n const isMemoryDb = workflowDbPath === \":memory:\";\n const isUriPath = workflowDbPath.startsWith(\"file:\");\n const shouldProbeFilesystem = !isMemoryDb && !isUriPath;\n\n try {\n if (shouldProbeFilesystem) {\n // Ensure parent dirs exist so a valid configured path doesn't fail solely due to missing directories.\n mkdirSync(dirname(workflowDbPath), { recursive: true });\n if (!existsSync(workflowDbPath)) {\n logger.warn?.(`tool-effectiveness: workflow traces DB not found at ${workflowDbPath} — no traces recorded yet`);\n return report;\n }\n }\n\n traceDb = new DatabaseSync(workflowDbPath, { readOnly: true });\n traceDb.exec(`PRAGMA busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS}`);\n\n // Check table exists\n const tableExists = traceDb\n .prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='workflow_traces'`)\n .get() as { name: string } | undefined;\n\n if (!tableExists) {\n logger.warn?.(\"tool-effectiveness: workflow_traces table not found — no traces recorded yet\");\n return report;\n }\n\n // Pull all traces\n const rows = traceDb\n .prepare(\"SELECT tool_sequence, outcome, duration_ms, session_id FROM workflow_traces\")\n .all() as unknown as TraceRow[];\n\n if (rows.length === 0) {\n return report;\n }\n\n // Compute metrics\n const metrics = aggregateTraceRows(rows, minCalls);\n\n if (metrics.length === 0) {\n return report;\n }\n\n // Apply decay and upsert into effectivenessDb\n if (!effStore) {\n const effPath = workflowDbPath.replace(/(\\.[^.]+)?$/, \"-tool-effectiveness.db\");\n effStore = new ToolEffectivenessStore(effPath);\n ownedEffStore = true;\n }\n\n effStore.applyDecay(decayFactor);\n\n for (const m of metrics) {\n effStore.upsert(m);\n }\n\n // Read back all scores (includes decayed history)\n const allScores = effStore.getAll();\n report.toolsScored = allScores.length;\n report.allScores = allScores;\n report.topTools = allScores.slice(0, topN);\n report.lowScoreTools = allScores.filter((m) => m.compositeScore < lowScoreThreshold && m.totalCalls >= minCalls);\n report.recommendations = generateRecommendations(allScores, lowScoreThreshold);\n } catch (err) {\n const baseError = err instanceof Error ? err : new Error(String(err));\n\n const resolvedWorkflowDb = workflowDbPath;\n const message = `tool-effectiveness: failed to compute scores (workflowDb=${resolvedWorkflowDb}): ${baseError.message}`;\n logger.warn?.(message);\n\n throw new Error(message, { cause: baseError });\n } finally {\n try {\n traceDb?.close();\n } catch {\n // ignore\n }\n if (ownedEffStore && effStore) {\n try {\n effStore.close();\n } catch {\n // ignore\n }\n }\n }\n\n return report;\n}\n\n/**\n * Format a ToolEffectivenessReport as a human-readable string.\n */\nexport function formatToolEffectivenessReport(report: ToolEffectivenessReport): string {\n if (report.toolsScored === 0) {\n return \"No tool effectiveness data available. Enable workflowTracking and run more sessions.\";\n }\n\n const lines: string[] = [\n `Tool Effectiveness Report (${new Date(report.computedAt * 1000).toISOString()})`,\n `Tools scored: ${report.toolsScored}`,\n \"\",\n \"Top Tools:\",\n ];\n\n for (const t of report.topTools.slice(0, 10)) {\n lines.push(\n ` ${t.tool}: score=${t.compositeScore.toFixed(3)} ` +\n `success=${(t.successRate * 100).toFixed(0)}% ` +\n `calls=${t.totalCalls} ` +\n `redundancy=${(t.redundancyScore * 100).toFixed(0)}%`,\n );\n }\n\n if (report.lowScoreTools.length > 0) {\n lines.push(\"\", \"⚠ Low-Score Tools:\");\n for (const t of report.lowScoreTools) {\n lines.push(` ${t.tool}: score=${t.compositeScore.toFixed(3)} ` + `success=${(t.successRate * 100).toFixed(0)}%`);\n }\n }\n\n if (report.recommendations.length > 0) {\n lines.push(\"\", \"Recommendations:\");\n for (const r of report.recommendations) {\n lines.push(` • ${r}`);\n }\n }\n\n return lines.join(\"\\n\");\n}\n\n// ---------------------------------------------------------------------------\n// Gap 5: Tool preference hints\n// ---------------------------------------------------------------------------\n\n/**\n * Generate a tool-preference hint string for the given context.\n *\n * Only includes tools that have >= minUses calls in the given context and where\n * the score difference between the best and worst tool exceeds hintThreshold.\n *\n * @param store ToolEffectivenessStore with scored data.\n * @param context Context label to filter scores by.\n * @param minUses Minimum total calls required (default 5).\n * @param hintThreshold Minimum score spread required to emit a hint (default 0.3).\n * @returns Hint string, or empty string if no meaningful data.\n */\nexport function generateToolHint(\n store: ToolEffectivenessStore,\n context: string,\n minUses = 5,\n hintThreshold = 0.3,\n): string {\n // Get all rows for this context\n const all = store.getAll().filter((m) => m.context === context && m.totalCalls >= minUses);\n\n if (all.length < 2) return \"\";\n\n // Sort by compositeScore DESC\n const sorted = [...all].sort((a, b) => b.compositeScore - a.compositeScore);\n const best = sorted[0]!;\n const worst = sorted[sorted.length - 1]!;\n\n if (best.compositeScore - worst.compositeScore < hintThreshold) return \"\";\n\n // Build the hint\n const toolList = sorted\n .map((m) => `${m.tool} scores ${m.compositeScore.toFixed(1)} (${m.totalCalls} uses)`)\n .join(\", \");\n\n return `[tool-hint: For \"${context}\" tasks, ${toolList}. Prefer ${best.tool}.]`;\n}\n\n// ---------------------------------------------------------------------------\n// Gap 6: Monthly PATTERN_FACT report\n// ---------------------------------------------------------------------------\n\n/**\n * Generate a monthly tool effectiveness summary and store it as a pattern fact.\n *\n * @param store ToolEffectivenessStore with scored data.\n * @param factsDb Facts database to store the pattern fact.\n */\nexport async function generateMonthlyReport(store: ToolEffectivenessStore, factsDb: FactsDB): Promise<void> {\n try {\n const allScores = store.getAll();\n\n if (allScores.length === 0) {\n return;\n }\n\n const top5 = allScores\n .slice(0, 5)\n .map((m) => `${m.tool}(${m.compositeScore.toFixed(2)})`)\n .join(\", \");\n\n const lowScorers = allScores\n .filter((m) => m.compositeScore < 0.3 && m.totalCalls >= 5)\n .slice(0, 3)\n .map((m) => `${m.tool}(${m.compositeScore.toFixed(2)})`)\n .join(\", \");\n\n const totalTools = allScores.length;\n const avgScore = allScores.reduce((sum, m) => sum + m.compositeScore, 0) / totalTools;\n\n const month = new Date().toISOString().slice(0, 7); // YYYY-MM\n\n const summaryLines = [\n `Monthly tool effectiveness report (${month}):`,\n `${totalTools} tools scored, avg composite score: ${avgScore.toFixed(3)}.`,\n `Top tools: ${top5}.`,\n ];\n if (lowScorers) {\n summaryLines.push(`Low-scoring tools (score < 0.3): ${lowScorers}.`);\n }\n\n const summary = summaryLines.join(\" \");\n\n factsDb.store({\n text: summary,\n category: \"pattern\",\n entity: null,\n key: `tool-effectiveness-monthly-${month}`,\n value: null,\n importance: 0.7,\n confidence: 0.9,\n scope: \"global\",\n source: \"tool-effectiveness\",\n tags: [\"tool-effectiveness\", \"monthly-report\"],\n summary: `Tool effectiveness summary for ${month}`,\n });\n } catch (err) {\n capturePluginError(err instanceof Error ? err : new Error(String(err)), {\n operation: \"tool-effectiveness-monthly-report\",\n });\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"tool-effectiveness.js","names":["resolvedWorkflowDb"],"sources":["../../services/tool-effectiveness.ts"],"sourcesContent":["/**\n * Tool Effectiveness Scoring — Aggregate workflow traces into per-tool score profiles.\n * Issue #263 — Phase 3.\n *\n * Algorithm for each tool:\n * successRate = successCount / totalCalls\n * avgDuration = Σ(durationMs) / count\n * redundancyScore = avgTimesUsedPerSession (how often tool repeats in one session)\n * compositeScore = (successRate × 0.5) + ((1 - avgDuration/maxDuration) × 0.3) + ((1 - redundancyScore) × 0.2)\n *\n * Scores are persisted in the `tool_effectiveness` SQLite table.\n * A persistent score decay (factor 0.95) is applied each run so stale data fades out.\n * Low scorers can be flagged in CLI output.\n */\n\nimport { existsSync, mkdirSync } from \"node:fs\";\nimport { dirname } from \"node:path\";\nimport { DatabaseSync } from \"node:sqlite\";\nimport { BaseSqliteStore } from \"../backends/base-sqlite-store.js\";\nimport type { FactsDB } from \"../backends/facts-db.js\";\nimport type { ToolEffectivenessConfig } from \"../config/types/features.js\";\nimport { SQLITE_BUSY_TIMEOUT_MS } from \"../utils/constants.js\";\nimport { capturePluginError } from \"./error-reporter.js\";\n\n// ---------------------------------------------------------------------------\n// Public types\n// ---------------------------------------------------------------------------\n\nexport interface ToolMetrics {\n tool: string;\n /** Context label for this score row (default \"general\"). */\n context: string;\n totalCalls: number;\n successCalls: number;\n failureCalls: number;\n unknownCalls: number;\n successRate: number;\n avgDurationMs: number;\n avgCallsPerSession: number;\n redundancyScore: number; // 0-1: 1 = maximally redundant (same tool called many times)\n compositeScore: number; // 0-1: higher is better\n lastUpdated: number; // epoch seconds\n}\n\nexport interface ToolEffectivenessReport {\n computedAt: number;\n toolsScored: number;\n topTools: ToolMetrics[];\n lowScoreTools: ToolMetrics[];\n allScores: ToolMetrics[];\n recommendations: string[];\n}\n// ToolEffectivenessConfig is imported from ../config/types/features.js\n// Re-export for consumers that import it from this module.\n\n// ---------------------------------------------------------------------------\n// Database schema\n// ---------------------------------------------------------------------------\n\nconst SCHEMA = `\nCREATE TABLE IF NOT EXISTS tool_effectiveness (\n tool TEXT NOT NULL,\n context TEXT NOT NULL DEFAULT 'general',\n total_calls INTEGER NOT NULL DEFAULT 0,\n success_calls INTEGER NOT NULL DEFAULT 0,\n failure_calls INTEGER NOT NULL DEFAULT 0,\n unknown_calls INTEGER NOT NULL DEFAULT 0,\n avg_duration_ms REAL NOT NULL DEFAULT 0,\n avg_calls_per_session REAL NOT NULL DEFAULT 0,\n composite_score REAL NOT NULL DEFAULT 0,\n last_updated INTEGER NOT NULL,\n PRIMARY KEY (tool, context)\n);\n`;\n\n// ---------------------------------------------------------------------------\n// ToolEffectivenessStore\n// ---------------------------------------------------------------------------\n\nexport class ToolEffectivenessStore extends BaseSqliteStore {\n constructor(dbPath: string) {\n mkdirSync(dirname(dbPath), { recursive: true });\n const db = new DatabaseSync(dbPath);\n super(db, { foreignKeys: true });\n this.liveDb.exec(SCHEMA);\n }\n\n protected getSubsystemName(): string {\n return \"tool-effectiveness-store\";\n }\n\n /** Upsert a tool score row. */\n upsert(metrics: ToolMetrics): void {\n const context = metrics.context ?? \"general\";\n this.liveDb\n .prepare(\n `INSERT INTO tool_effectiveness\n (tool, context, total_calls, success_calls, failure_calls, unknown_calls, avg_duration_ms, avg_calls_per_session, composite_score, last_updated)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT(tool, context) DO UPDATE SET\n total_calls = excluded.total_calls,\n success_calls = excluded.success_calls,\n failure_calls = excluded.failure_calls,\n unknown_calls = excluded.unknown_calls,\n avg_duration_ms = excluded.avg_duration_ms,\n avg_calls_per_session = excluded.avg_calls_per_session,\n composite_score = excluded.composite_score,\n last_updated = excluded.last_updated`,\n )\n .run(\n metrics.tool,\n context,\n metrics.totalCalls,\n metrics.successCalls,\n metrics.failureCalls,\n metrics.unknownCalls,\n metrics.avgDurationMs,\n metrics.avgCallsPerSession,\n metrics.compositeScore,\n metrics.lastUpdated,\n );\n }\n\n /**\n * Record a single tool outcome (incremental upsert for real-time tracking).\n *\n * @param tool Tool name.\n * @param outcome \"success\" | \"failure\" | \"unknown\".\n * @param context Context label (default \"general\").\n * @param durationMs Duration of the call in milliseconds (default 0).\n *\n * Note: `avg_calls_per_session` is intentionally not updated here because individual\n * tool outcome events lack session boundary information. It is computed during the\n * nightly batch analysis via `computeToolEffectiveness` / `upsert`, which has access\n * to per-session call counts from workflow traces.\n */\n recordToolOutcome(\n tool: string,\n outcome: \"success\" | \"failure\" | \"unknown\",\n context = \"general\",\n durationMs = 0,\n ): void {\n const now = Math.floor(Date.now() / 1000);\n this.liveDb\n .prepare(\n `INSERT INTO tool_effectiveness\n (tool, context, total_calls, success_calls, failure_calls, unknown_calls, avg_duration_ms, avg_calls_per_session, composite_score, last_updated)\n VALUES (?, ?, 1,\n CASE ? WHEN 'success' THEN 1 ELSE 0 END,\n CASE ? WHEN 'failure' THEN 1 ELSE 0 END,\n CASE ? WHEN 'unknown' THEN 1 ELSE 0 END,\n ?, 1.0, 0.5, ?)\n ON CONFLICT(tool, context) DO UPDATE SET\n total_calls = total_calls + 1,\n success_calls = success_calls + CASE ? WHEN 'success' THEN 1 ELSE 0 END,\n failure_calls = failure_calls + CASE ? WHEN 'failure' THEN 1 ELSE 0 END,\n unknown_calls = unknown_calls + CASE ? WHEN 'unknown' THEN 1 ELSE 0 END,\n avg_duration_ms = (avg_duration_ms * total_calls + ?) / (total_calls + 1),\n composite_score = CAST(success_calls + CASE ? WHEN 'success' THEN 1 ELSE 0 END AS REAL) / MAX(total_calls + 1, 1) * 0.5 + 0.25,\n last_updated = ?`,\n )\n .run(\n tool,\n context,\n outcome,\n outcome,\n outcome,\n durationMs,\n now,\n outcome,\n outcome,\n outcome,\n durationMs,\n outcome,\n now,\n );\n }\n\n /**\n * Get effectiveness for a specific tool, optionally filtered by context.\n *\n * @param tool Tool name.\n * @param context Optional context filter. If omitted, returns all context rows for the tool.\n * @returns Array of ToolMetrics (one per context), or empty array if not found.\n */\n getToolEffectiveness(tool: string, context?: string): ToolMetrics[] {\n type Row = {\n tool: string;\n context: string;\n total_calls: number;\n success_calls: number;\n failure_calls: number;\n unknown_calls: number;\n avg_duration_ms: number;\n avg_calls_per_session: number;\n composite_score: number;\n last_updated: number;\n };\n\n let rows: Row[];\n if (context !== undefined) {\n rows = this.liveDb\n .prepare(\"SELECT * FROM tool_effectiveness WHERE tool = ? AND context = ?\")\n .all(tool, context) as Row[];\n } else {\n rows = this.liveDb.prepare(\"SELECT * FROM tool_effectiveness WHERE tool = ? ORDER BY context\").all(tool) as Row[];\n }\n\n return rows.map((r) => ({\n tool: r.tool,\n context: r.context,\n totalCalls: r.total_calls,\n successCalls: r.success_calls,\n failureCalls: r.failure_calls,\n unknownCalls: r.unknown_calls,\n successRate: r.total_calls > 0 ? r.success_calls / r.total_calls : 0,\n avgDurationMs: r.avg_duration_ms,\n avgCallsPerSession: r.avg_calls_per_session,\n redundancyScore: Math.min(1, Math.max(0, (r.avg_calls_per_session - 1) / 4)),\n compositeScore: r.composite_score,\n lastUpdated: r.last_updated,\n }));\n }\n\n /** Apply decay to all scores. */\n applyDecay(factor: number): void {\n this.liveDb.prepare(\"UPDATE tool_effectiveness SET composite_score = composite_score * ?\").run(factor);\n }\n\n /** Get all scores ordered by composite_score DESC. */\n getAll(): ToolMetrics[] {\n const rows = this.liveDb.prepare(\"SELECT * FROM tool_effectiveness ORDER BY composite_score DESC\").all() as Array<{\n tool: string;\n context: string;\n total_calls: number;\n success_calls: number;\n failure_calls: number;\n unknown_calls: number;\n avg_duration_ms: number;\n avg_calls_per_session: number;\n composite_score: number;\n last_updated: number;\n }>;\n\n return rows.map((r) => ({\n tool: r.tool,\n context: r.context ?? \"general\",\n totalCalls: r.total_calls,\n successCalls: r.success_calls,\n failureCalls: r.failure_calls,\n unknownCalls: r.unknown_calls,\n successRate: r.total_calls > 0 ? r.success_calls / r.total_calls : 0,\n avgDurationMs: r.avg_duration_ms,\n avgCallsPerSession: r.avg_calls_per_session,\n redundancyScore: Math.min(1, Math.max(0, (r.avg_calls_per_session - 1) / 4)),\n compositeScore: r.composite_score,\n lastUpdated: r.last_updated,\n }));\n }\n\n /** Get score for a specific tool (first context row, or \"general\"). */\n getByTool(tool: string): ToolMetrics | null {\n const row = this.liveDb\n .prepare(\"SELECT * FROM tool_effectiveness WHERE tool = ? ORDER BY context LIMIT 1\")\n .get(tool) as\n | {\n tool: string;\n context: string;\n total_calls: number;\n success_calls: number;\n failure_calls: number;\n unknown_calls: number;\n avg_duration_ms: number;\n avg_calls_per_session: number;\n composite_score: number;\n last_updated: number;\n }\n | undefined;\n\n if (!row) return null;\n return {\n tool: row.tool,\n context: row.context ?? \"general\",\n totalCalls: row.total_calls,\n successCalls: row.success_calls,\n failureCalls: row.failure_calls,\n unknownCalls: row.unknown_calls,\n successRate: row.total_calls > 0 ? row.success_calls / row.total_calls : 0,\n avgDurationMs: row.avg_duration_ms,\n avgCallsPerSession: row.avg_calls_per_session,\n redundancyScore: Math.min(1, Math.max(0, (row.avg_calls_per_session - 1) / 4)),\n compositeScore: row.composite_score,\n lastUpdated: row.last_updated,\n };\n }\n\n /** Count of scored tools. */\n count(): number {\n const row = this.liveDb.prepare(\"SELECT COUNT(*) as n FROM tool_effectiveness\").get() as { n: number };\n return row.n;\n }\n}\n\n// ---------------------------------------------------------------------------\n// Analytics helpers\n// ---------------------------------------------------------------------------\n\ninterface TraceRow {\n tool_sequence: string;\n outcome: string;\n duration_ms: number;\n session_id: string;\n}\n\n/** Aggregate raw workflow trace rows into per-tool metrics. */\nexport function aggregateTraceRows(rows: TraceRow[], minCalls: number): ToolMetrics[] {\n if (rows.length === 0) return [];\n\n const toolStats = new Map<\n string,\n {\n total: number;\n success: number;\n failure: number;\n unknown: number;\n durations: number[];\n // sessionId → call count in that session\n sessionCalls: Map<string, number>;\n }\n >();\n\n const maxDuration = rows.reduce((max, r) => Math.max(max, r.duration_ms ?? 0), 0) || 1;\n\n for (const row of rows) {\n let seq: string[] = [];\n try {\n seq = JSON.parse(row.tool_sequence) as string[];\n } catch {\n continue;\n }\n const uniqueToolsInRow = new Set(seq);\n const sessionId = row.session_id ?? \"unknown\";\n\n for (const tool of uniqueToolsInRow) {\n if (!toolStats.has(tool)) {\n toolStats.set(tool, {\n total: 0,\n success: 0,\n failure: 0,\n unknown: 0,\n durations: [],\n sessionCalls: new Map(),\n });\n }\n const s = toolStats.get(tool)!;\n\n // Count occurrences of this tool in this trace\n const callsInTrace = seq.filter((t) => t === tool).length;\n s.total += callsInTrace;\n\n // Outcome attribution\n if (row.outcome === \"success\") s.success += callsInTrace;\n else if (row.outcome === \"failure\") s.failure += callsInTrace;\n else s.unknown += callsInTrace;\n\n // Duration: attribute the trace duration equally across unique tools\n s.durations.push((row.duration_ms ?? 0) / uniqueToolsInRow.size);\n\n // Session redundancy\n const prevCalls = s.sessionCalls.get(sessionId) ?? 0;\n s.sessionCalls.set(sessionId, prevCalls + callsInTrace);\n }\n }\n\n const results: ToolMetrics[] = [];\n\n for (const [tool, s] of toolStats) {\n if (s.total < minCalls) continue;\n\n const successRate = s.total > 0 ? s.success / s.total : 0;\n const avgDuration = s.durations.reduce((a, b) => a + b, 0) / Math.max(1, s.durations.length);\n const sessionCallCounts = [...s.sessionCalls.values()];\n const avgCallsPerSession = sessionCallCounts.reduce((a, b) => a + b, 0) / Math.max(1, sessionCallCounts.length);\n\n // Redundancy = (avg calls per session - 1) / 4 clamped to [0,1]\n // 1 call/session = 0 redundancy; 5+ calls/session = 1.0 redundancy\n const redundancyScore = Math.min(1, Math.max(0, (avgCallsPerSession - 1) / 4));\n\n // Duration score: 1 = very fast, 0 = slowest seen\n const durationScore = 1 - Math.min(1, avgDuration / maxDuration);\n\n // Composite: success 50%, speed 30%, low-redundancy 20%\n const compositeScore = successRate * 0.5 + durationScore * 0.3 + (1 - redundancyScore) * 0.2;\n\n results.push({\n tool,\n context: \"general\",\n totalCalls: s.total,\n successCalls: s.success,\n failureCalls: s.failure,\n unknownCalls: s.unknown,\n successRate,\n avgDurationMs: avgDuration,\n avgCallsPerSession,\n redundancyScore,\n compositeScore: Math.min(1, Math.max(0, compositeScore)),\n lastUpdated: Math.floor(Date.now() / 1000),\n });\n }\n\n return results.sort((a, b) => b.compositeScore - a.compositeScore);\n}\n\n/** Generate human-readable recommendations from tool metrics. */\nexport function generateRecommendations(metrics: ToolMetrics[], lowScoreThreshold: number): string[] {\n const recs: string[] = [];\n\n const lowScorers = metrics.filter((m) => m.compositeScore < lowScoreThreshold && m.totalCalls >= 5);\n if (lowScorers.length > 0) {\n const names = lowScorers\n .slice(0, 3)\n .map((m) => `${m.tool} (score: ${m.compositeScore.toFixed(2)})`)\n .join(\", \");\n recs.push(`Low-scoring tools may need workflow review: ${names}`);\n }\n\n const highRedundancy = metrics.filter((m) => m.redundancyScore > 0.7 && m.totalCalls >= 5);\n if (highRedundancy.length > 0) {\n const names = highRedundancy\n .slice(0, 3)\n .map((m) => `${m.tool} (avg ${m.avgCallsPerSession.toFixed(1)}x/session)`)\n .join(\", \");\n recs.push(`Highly redundant tools (called many times per session): ${names}`);\n }\n\n const lowSuccess = metrics.filter((m) => m.successRate < 0.5 && m.totalCalls >= 5);\n if (lowSuccess.length > 0) {\n const names = lowSuccess\n .slice(0, 3)\n .map((m) => `${m.tool} (${(m.successRate * 100).toFixed(0)}% success)`)\n .join(\", \");\n recs.push(`Tools with low success rates: ${names}`);\n }\n\n if (recs.length === 0) {\n const topTool = metrics[0];\n if (topTool) {\n recs.push(`Best performing tool: ${topTool.tool} (score: ${topTool.compositeScore.toFixed(2)})`);\n }\n }\n\n return recs;\n}\n\n// ---------------------------------------------------------------------------\n// Main entry point\n// ---------------------------------------------------------------------------\n\n/**\n * Compute tool effectiveness scores from a workflow store database.\n *\n * @param workflowDbPath Path to the workflow_traces SQLite database.\n * @param effectivenessDb Pre-opened ToolEffectivenessStore (optional; created if omitted).\n * @param cfg Configuration.\n * @param logger Optional logger.\n * @returns Effectiveness report.\n */\nexport async function computeToolEffectiveness(\n workflowDbPath: string,\n effectivenessDb: ToolEffectivenessStore | null,\n cfg: Partial<ToolEffectivenessConfig> = {},\n logger: { warn?: (msg: string) => void } = {},\n): Promise<ToolEffectivenessReport> {\n const minCalls = cfg.minCalls ?? 3;\n const topN = cfg.topN ?? 10;\n const lowScoreThreshold = cfg.lowScoreThreshold ?? 0.3;\n const decayFactor = cfg.decayFactor ?? 0.95;\n\n const report: ToolEffectivenessReport = {\n computedAt: Math.floor(Date.now() / 1000),\n toolsScored: 0,\n topTools: [],\n lowScoreTools: [],\n allScores: [],\n recommendations: [],\n };\n\n // Open the workflow traces DB (read-only if possible)\n let traceDb: DatabaseSync | null = null;\n let ownedEffStore = false;\n let effStore = effectivenessDb;\n\n const isMemoryDb = workflowDbPath === \":memory:\";\n const isUriPath = workflowDbPath.startsWith(\"file:\");\n const shouldProbeFilesystem = !isMemoryDb && !isUriPath;\n\n try {\n if (shouldProbeFilesystem) {\n // Ensure parent dirs exist so a valid configured path doesn't fail solely due to missing directories.\n mkdirSync(dirname(workflowDbPath), { recursive: true });\n if (!existsSync(workflowDbPath)) {\n logger.warn?.(`tool-effectiveness: workflow traces DB not found at ${workflowDbPath} — no traces recorded yet`);\n return report;\n }\n }\n\n traceDb = new DatabaseSync(workflowDbPath, { readOnly: true });\n traceDb.exec(`PRAGMA busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS}`);\n\n // Check table exists\n const tableExists = traceDb\n .prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='workflow_traces'`)\n .get() as { name: string } | undefined;\n\n if (!tableExists) {\n logger.warn?.(\"tool-effectiveness: workflow_traces table not found — no traces recorded yet\");\n return report;\n }\n\n // Pull all traces\n const rows = traceDb\n .prepare(\"SELECT tool_sequence, outcome, duration_ms, session_id FROM workflow_traces\")\n .all() as unknown as TraceRow[];\n\n if (rows.length === 0) {\n return report;\n }\n\n // Compute metrics\n const metrics = aggregateTraceRows(rows, minCalls);\n\n if (metrics.length === 0) {\n return report;\n }\n\n // Apply decay and upsert into effectivenessDb\n if (!effStore) {\n const effPath = workflowDbPath.replace(/(\\.[^.]+)?$/, \"-tool-effectiveness.db\");\n effStore = new ToolEffectivenessStore(effPath);\n ownedEffStore = true;\n }\n\n effStore.applyDecay(decayFactor);\n\n for (const m of metrics) {\n effStore.upsert(m);\n }\n\n // Read back all scores (includes decayed history)\n const allScores = effStore.getAll();\n report.toolsScored = allScores.length;\n report.allScores = allScores;\n report.topTools = allScores.slice(0, topN);\n report.lowScoreTools = allScores.filter((m) => m.compositeScore < lowScoreThreshold && m.totalCalls >= minCalls);\n report.recommendations = generateRecommendations(allScores, lowScoreThreshold);\n } catch (err) {\n const baseError = err instanceof Error ? err : new Error(String(err));\n\n const resolvedWorkflowDb = workflowDbPath;\n const message = `tool-effectiveness: failed to compute scores (workflowDb=${resolvedWorkflowDb}): ${baseError.message}`;\n logger.warn?.(message);\n\n throw new Error(message, { cause: baseError });\n } finally {\n try {\n traceDb?.close();\n } catch {\n // ignore\n }\n if (ownedEffStore && effStore) {\n try {\n effStore.close();\n } catch {\n // ignore\n }\n }\n }\n\n return report;\n}\n\n/**\n * Format a ToolEffectivenessReport as a human-readable string.\n */\nexport function formatToolEffectivenessReport(report: ToolEffectivenessReport): string {\n if (report.toolsScored === 0) {\n return \"No tool effectiveness data available. Enable workflowTracking and run more sessions.\";\n }\n\n const lines: string[] = [\n `Tool Effectiveness Report (${new Date(report.computedAt * 1000).toISOString()})`,\n `Tools scored: ${report.toolsScored}`,\n \"\",\n \"Top Tools:\",\n ];\n\n for (const t of report.topTools.slice(0, 10)) {\n lines.push(\n ` ${t.tool}: score=${t.compositeScore.toFixed(3)} ` +\n `success=${(t.successRate * 100).toFixed(0)}% ` +\n `calls=${t.totalCalls} ` +\n `redundancy=${(t.redundancyScore * 100).toFixed(0)}%`,\n );\n }\n\n if (report.lowScoreTools.length > 0) {\n lines.push(\"\", \"⚠ Low-Score Tools:\");\n for (const t of report.lowScoreTools) {\n lines.push(` ${t.tool}: score=${t.compositeScore.toFixed(3)} ` + `success=${(t.successRate * 100).toFixed(0)}%`);\n }\n }\n\n if (report.recommendations.length > 0) {\n lines.push(\"\", \"Recommendations:\");\n for (const r of report.recommendations) {\n lines.push(` • ${r}`);\n }\n }\n\n return lines.join(\"\\n\");\n}\n\n// ---------------------------------------------------------------------------\n// Gap 5: Tool preference hints\n// ---------------------------------------------------------------------------\n\n/**\n * Generate a tool-preference hint string for the given context.\n *\n * Only includes tools that have >= minUses calls in the given context and where\n * the score difference between the best and worst tool exceeds hintThreshold.\n *\n * @param store ToolEffectivenessStore with scored data.\n * @param context Context label to filter scores by.\n * @param minUses Minimum total calls required (default 5).\n * @param hintThreshold Minimum score spread required to emit a hint (default 0.3).\n * @returns Hint string, or empty string if no meaningful data.\n */\nexport function generateToolHint(\n store: ToolEffectivenessStore,\n context: string,\n minUses = 5,\n hintThreshold = 0.3,\n): string {\n // Get all rows for this context\n const all = store.getAll().filter((m) => m.context === context && m.totalCalls >= minUses);\n\n if (all.length < 2) return \"\";\n\n // Sort by compositeScore DESC\n const sorted = [...all].sort((a, b) => b.compositeScore - a.compositeScore);\n const best = sorted[0]!;\n const worst = sorted[sorted.length - 1]!;\n\n if (best.compositeScore - worst.compositeScore < hintThreshold) return \"\";\n\n // Build the hint\n const toolList = sorted\n .map((m) => `${m.tool} scores ${m.compositeScore.toFixed(1)} (${m.totalCalls} uses)`)\n .join(\", \");\n\n return `[tool-hint: For \"${context}\" tasks, ${toolList}. Prefer ${best.tool}.]`;\n}\n\n// ---------------------------------------------------------------------------\n// Gap 6: Monthly PATTERN_FACT report\n// ---------------------------------------------------------------------------\n\n/**\n * Generate a monthly tool effectiveness summary and store it as a pattern fact.\n *\n * @param store ToolEffectivenessStore with scored data.\n * @param factsDb Facts database to store the pattern fact.\n */\nexport async function generateMonthlyReport(store: ToolEffectivenessStore, factsDb: FactsDB): Promise<void> {\n try {\n const allScores = store.getAll();\n\n if (allScores.length === 0) {\n return;\n }\n\n const top5 = allScores\n .slice(0, 5)\n .map((m) => `${m.tool}(${m.compositeScore.toFixed(2)})`)\n .join(\", \");\n\n const lowScorers = allScores\n .filter((m) => m.compositeScore < 0.3 && m.totalCalls >= 5)\n .slice(0, 3)\n .map((m) => `${m.tool}(${m.compositeScore.toFixed(2)})`)\n .join(\", \");\n\n const totalTools = allScores.length;\n const avgScore = allScores.reduce((sum, m) => sum + m.compositeScore, 0) / totalTools;\n\n const month = new Date().toISOString().slice(0, 7); // YYYY-MM\n\n const summaryLines = [\n `Monthly tool effectiveness report (${month}):`,\n `${totalTools} tools scored, avg composite score: ${avgScore.toFixed(3)}.`,\n `Top tools: ${top5}.`,\n ];\n if (lowScorers) {\n summaryLines.push(`Low-scoring tools (score < 0.3): ${lowScorers}.`);\n }\n\n const summary = summaryLines.join(\" \");\n\n factsDb.store({\n text: summary,\n category: \"pattern\",\n entity: null,\n key: `tool-effectiveness-monthly-${month}`,\n value: null,\n importance: 0.7,\n confidence: 0.9,\n scope: \"global\",\n source: \"tool-effectiveness\",\n tags: [\"tool-effectiveness\", \"monthly-report\"],\n summary: `Tool effectiveness summary for ${month}`,\n });\n } catch (err) {\n capturePluginError(err instanceof Error ? err : new Error(String(err)), {\n operation: \"tool-effectiveness-monthly-report\",\n });\n }\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tool-proposer.js","names":[],"sources":["../../services/tool-proposer.ts"],"sourcesContent":["/**\n * Tool Proposer — generates tool proposals from detected gaps (Issue #210).\n *\n * Takes DetectedGap objects from GapDetector and generates structured ToolProposal\n * specifications. Proposals are stored in ToolProposalStore; they are SPECIFICATIONS\n * only — no code is generated. Implementation is left to humans or LLMs.\n */\n\nimport type { ToolProposal, ToolProposalStore } from \"../backends/tool-proposal-store.js\";\nimport type { SelfExtensionConfig } from \"../config/types/features.js\";\nimport type { DetectedGap, GapDetector, GapDetectorOptions } from \"./gap-detector.js\";\n\n// ---------------------------------------------------------------------------\n// Public types\n// ---------------------------------------------------------------------------\n\ninterface ProposeCycleResult {\n proposed: number;\n skipped: number;\n reasons: string[];\n proposals: ToolProposal[];\n}\n\n// ---------------------------------------------------------------------------\n// Parameter schema generation\n// ---------------------------------------------------------------------------\n\n/**\n * Generate a minimal JSON Schema for the proposed tool parameters based on\n * the tool sequence it replaces. This is a heuristic sketch — not a formal spec.\n */\nfunction generateParameterSchema(gap: DetectedGap): string {\n const params: Record<string, { type: string; description: string }> = {};\n\n // Always include the goal the tool should accomplish\n params.goal = {\n type: \"string\",\n description: \"What you want to accomplish (replaces the multi-step workflow).\",\n };\n\n // If the sequence contains memory_recall-like tools, add a query param\n const hasMemoryTools = gap.toolSequence.some((t) => t.startsWith(\"memory_\"));\n if (hasMemoryTools) {\n params.queries = {\n type: \"array\",\n description: \"List of search queries to run in one batch call.\",\n };\n }\n\n // If the sequence contains exec calls, add a commands param\n const hasExec = gap.toolSequence.some((t) => t === \"exec\");\n if (hasExec) {\n params.commands = {\n type: \"array\",\n description: \"List of shell commands to execute in sequence.\",\n };\n }\n\n return JSON.stringify({ type: \"object\", properties: params }, null, 2);\n}\n\n// ---------------------------------------------------------------------------\n// Implementation hint generation\n// ---------------------------------------------------------------------------\n\n/**\n * Generate a plain-English implementation sketch for the proposed tool.\n */\nfunction generateImplementationHint(gap: DetectedGap): string {\n const unique = [...new Set(gap.toolSequence)];\n const lines: string[] = [\n `This tool replaces the recurring ${gap.toolSequence.length}-step workflow: [${gap.toolSequence.join(\" → \")}].`,\n \"\",\n \"Implementation sketch:\",\n \"1. Accept a goal/query parameter and any tool-specific inputs (see parameters schema).\",\n `2. Internally call the following tools in sequence: ${unique.join(\", \")}.`,\n \"3. Merge and deduplicate results before returning.\",\n `4. Return a single consolidated response instead of ${gap.toolSequence.length} separate calls.`,\n \"\",\n `Observed success rate: ${Math.round(gap.successRate * 100)}% across ${gap.frequency} executions.`,\n `Estimated tool-call savings: ${gap.toolSavings} calls per invocation.`,\n ];\n return lines.join(\"\\n\");\n}\n\n// ---------------------------------------------------------------------------\n// Rationale generation\n// ---------------------------------------------------------------------------\n\nfunction generateRationale(gap: DetectedGap): string {\n const goalExamples =\n gap.exampleGoals.length > 0\n ? `\\nExample goals where this pattern appeared:\\n${gap.exampleGoals.map((g) => ` - \"${g}\"`).join(\"\\n\")}`\n : \"\";\n\n return `The agent repeatedly uses [${gap.toolSequence.join(\" → \")}] (${gap.frequency} times, ${Math.round(gap.successRate * 100)}% success rate) to achieve what could be a single tool call. A purpose-built tool would save ${gap.toolSavings} tool calls per invocation.${goalExamples}`;\n}\n\n// ---------------------------------------------------------------------------\n// ToolProposer\n// ---------------------------------------------------------------------------\n\nexport class ToolProposer {\n constructor(\n private readonly gapDetector: GapDetector,\n private readonly proposalStore: ToolProposalStore,\n private readonly cfg: SelfExtensionConfig,\n ) {}\n\n /**\n * Run a full detection + proposal cycle.\n * Returns a summary of what was proposed and what was skipped.\n */\n runCycle(opts?: Partial<GapDetectorOptions>): ProposeCycleResult {\n const result: ProposeCycleResult = {\n proposed: 0,\n skipped: 0,\n reasons: [],\n proposals: [],\n };\n\n if (!this.cfg.enabled) {\n result.reasons.push(\"Self-extension is disabled (selfExtension.enabled = false).\");\n return result;\n }\n\n // Check max pending proposals cap\n const currentCount = this.proposalStore.count(\"proposed\");\n if (currentCount >= this.cfg.maxProposals) {\n result.reasons.push(\n `Max pending proposals reached (${currentCount}/${this.cfg.maxProposals}). Approve or reject existing proposals first.`,\n );\n return result;\n }\n\n // Detect gaps using config thresholds\n const detectorOpts: Partial<GapDetectorOptions> = {\n minFrequency: this.cfg.minGapFrequency,\n minToolSavings: this.cfg.minToolSavings,\n };\n const gaps = this.gapDetector.detect({ ...detectorOpts, ...opts });\n\n for (const gap of gaps) {\n const remaining = this.cfg.maxProposals - this.proposalStore.count(\"proposed\");\n if (remaining <= 0) {\n result.skipped++;\n result.reasons.push(`Skipped gap \"${gap.suggestedToolName}\" — max proposals reached.`);\n continue;\n }\n\n // Skip if a live proposal already exists for this tool name\n if (this.proposalStore.existsByName(gap.suggestedToolName)) {\n result.skipped++;\n result.reasons.push(\n `Skipped gap \"${gap.suggestedToolName}\" — active proposal already exists for this tool name.`,\n );\n continue;\n }\n\n const proposal = this.proposalStore.create({\n name: gap.suggestedToolName,\n description: `A single tool that consolidates the [${gap.toolSequence.join(\" → \")}] workflow pattern.`,\n parameters: generateParameterSchema(gap),\n rationale: generateRationale(gap),\n sourcePatterns: JSON.stringify([gap.id]),\n implementationHint: generateImplementationHint(gap),\n });\n\n result.proposed++;\n result.proposals.push(proposal);\n result.reasons.push(\n `Proposed \"${gap.suggestedToolName}\" (gap score: ${gap.score.toFixed(2)}, saves ${gap.toolSavings} tool calls).`,\n );\n }\n\n if (gaps.length === 0) {\n result.reasons.push(\"No actionable gaps detected in current workflow traces.\");\n }\n\n return result;\n }\n\n /**\n * Approve a pending proposal (marks it as approved).\n */\n approveProposal(id: string): { success: boolean; message: string; proposal?: ToolProposal } {\n const proposal = this.proposalStore.getById(id);\n if (!proposal) {\n return { success: false, message: `Proposal \"${id}\" not found.` };\n }\n if (proposal.status !== \"proposed\") {\n return {\n success: false,\n message: `Proposal \"${id}\" is already ${proposal.status} — cannot approve.`,\n };\n }\n const updated = this.proposalStore.updateStatus(id, \"approved\", \"proposed\");\n if (!updated) {\n return {\n success: false,\n message: `Proposal \"${id}\" status changed concurrently — cannot approve.`,\n };\n }\n return {\n success: true,\n message: `Proposal \"${proposal.name}\" approved. Mark as implemented when the tool is built.`,\n proposal: updated,\n };\n }\n\n /**\n * Reject a pending proposal (marks it as rejected).\n */\n rejectProposal(id: string): { success: boolean; message: string; proposal?: ToolProposal } {\n const proposal = this.proposalStore.getById(id);\n if (!proposal) {\n return { success: false, message: `Proposal \"${id}\" not found.` };\n }\n if (proposal.status !== \"proposed\") {\n return {\n success: false,\n message: `Proposal \"${id}\" is already ${proposal.status} — cannot reject.`,\n };\n }\n const updated = this.proposalStore.updateStatus(id, \"rejected\", \"proposed\");\n if (!updated) {\n return {\n success: false,\n message: `Proposal \"${id}\" status changed concurrently — cannot reject.`,\n };\n }\n return {\n success: true,\n message: `Proposal \"${proposal.name}\" rejected.`,\n proposal: updated,\n };\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"tool-proposer.js","names":[],"sources":["../../services/tool-proposer.ts"],"sourcesContent":["/**\n * Tool Proposer — generates tool proposals from detected gaps (Issue #210).\n *\n * Takes DetectedGap objects from GapDetector and generates structured ToolProposal\n * specifications. Proposals are stored in ToolProposalStore; they are SPECIFICATIONS\n * only — no code is generated. Implementation is left to humans or LLMs.\n */\n\nimport type { ToolProposal, ToolProposalStore } from \"../backends/tool-proposal-store.js\";\nimport type { SelfExtensionConfig } from \"../config/types/features.js\";\nimport type { DetectedGap, GapDetector, GapDetectorOptions } from \"./gap-detector.js\";\n\n// ---------------------------------------------------------------------------\n// Public types\n// ---------------------------------------------------------------------------\n\ninterface ProposeCycleResult {\n proposed: number;\n skipped: number;\n reasons: string[];\n proposals: ToolProposal[];\n}\n\n// ---------------------------------------------------------------------------\n// Parameter schema generation\n// ---------------------------------------------------------------------------\n\n/**\n * Generate a minimal JSON Schema for the proposed tool parameters based on\n * the tool sequence it replaces. This is a heuristic sketch — not a formal spec.\n */\nfunction generateParameterSchema(gap: DetectedGap): string {\n const params: Record<string, { type: string; description: string }> = {};\n\n // Always include the goal the tool should accomplish\n params.goal = {\n type: \"string\",\n description: \"What you want to accomplish (replaces the multi-step workflow).\",\n };\n\n // If the sequence contains memory_recall-like tools, add a query param\n const hasMemoryTools = gap.toolSequence.some((t) => t.startsWith(\"memory_\"));\n if (hasMemoryTools) {\n params.queries = {\n type: \"array\",\n description: \"List of search queries to run in one batch call.\",\n };\n }\n\n // If the sequence contains exec calls, add a commands param\n const hasExec = gap.toolSequence.some((t) => t === \"exec\");\n if (hasExec) {\n params.commands = {\n type: \"array\",\n description: \"List of shell commands to execute in sequence.\",\n };\n }\n\n return JSON.stringify({ type: \"object\", properties: params }, null, 2);\n}\n\n// ---------------------------------------------------------------------------\n// Implementation hint generation\n// ---------------------------------------------------------------------------\n\n/**\n * Generate a plain-English implementation sketch for the proposed tool.\n */\nfunction generateImplementationHint(gap: DetectedGap): string {\n const unique = [...new Set(gap.toolSequence)];\n const lines: string[] = [\n `This tool replaces the recurring ${gap.toolSequence.length}-step workflow: [${gap.toolSequence.join(\" → \")}].`,\n \"\",\n \"Implementation sketch:\",\n \"1. Accept a goal/query parameter and any tool-specific inputs (see parameters schema).\",\n `2. Internally call the following tools in sequence: ${unique.join(\", \")}.`,\n \"3. Merge and deduplicate results before returning.\",\n `4. Return a single consolidated response instead of ${gap.toolSequence.length} separate calls.`,\n \"\",\n `Observed success rate: ${Math.round(gap.successRate * 100)}% across ${gap.frequency} executions.`,\n `Estimated tool-call savings: ${gap.toolSavings} calls per invocation.`,\n ];\n return lines.join(\"\\n\");\n}\n\n// ---------------------------------------------------------------------------\n// Rationale generation\n// ---------------------------------------------------------------------------\n\nfunction generateRationale(gap: DetectedGap): string {\n const goalExamples =\n gap.exampleGoals.length > 0\n ? `\\nExample goals where this pattern appeared:\\n${gap.exampleGoals.map((g) => ` - \"${g}\"`).join(\"\\n\")}`\n : \"\";\n\n return `The agent repeatedly uses [${gap.toolSequence.join(\" → \")}] (${gap.frequency} times, ${Math.round(gap.successRate * 100)}% success rate) to achieve what could be a single tool call. A purpose-built tool would save ${gap.toolSavings} tool calls per invocation.${goalExamples}`;\n}\n\n// ---------------------------------------------------------------------------\n// ToolProposer\n// ---------------------------------------------------------------------------\n\nexport class ToolProposer {\n constructor(\n private readonly gapDetector: GapDetector,\n private readonly proposalStore: ToolProposalStore,\n private readonly cfg: SelfExtensionConfig,\n ) {}\n\n /**\n * Run a full detection + proposal cycle.\n * Returns a summary of what was proposed and what was skipped.\n */\n runCycle(opts?: Partial<GapDetectorOptions>): ProposeCycleResult {\n const result: ProposeCycleResult = {\n proposed: 0,\n skipped: 0,\n reasons: [],\n proposals: [],\n };\n\n if (!this.cfg.enabled) {\n result.reasons.push(\"Self-extension is disabled (selfExtension.enabled = false).\");\n return result;\n }\n\n // Check max pending proposals cap\n const currentCount = this.proposalStore.count(\"proposed\");\n if (currentCount >= this.cfg.maxProposals) {\n result.reasons.push(\n `Max pending proposals reached (${currentCount}/${this.cfg.maxProposals}). Approve or reject existing proposals first.`,\n );\n return result;\n }\n\n // Detect gaps using config thresholds\n const detectorOpts: Partial<GapDetectorOptions> = {\n minFrequency: this.cfg.minGapFrequency,\n minToolSavings: this.cfg.minToolSavings,\n };\n const gaps = this.gapDetector.detect({ ...detectorOpts, ...opts });\n\n for (const gap of gaps) {\n const remaining = this.cfg.maxProposals - this.proposalStore.count(\"proposed\");\n if (remaining <= 0) {\n result.skipped++;\n result.reasons.push(`Skipped gap \"${gap.suggestedToolName}\" — max proposals reached.`);\n continue;\n }\n\n // Skip if a live proposal already exists for this tool name\n if (this.proposalStore.existsByName(gap.suggestedToolName)) {\n result.skipped++;\n result.reasons.push(\n `Skipped gap \"${gap.suggestedToolName}\" — active proposal already exists for this tool name.`,\n );\n continue;\n }\n\n const proposal = this.proposalStore.create({\n name: gap.suggestedToolName,\n description: `A single tool that consolidates the [${gap.toolSequence.join(\" → \")}] workflow pattern.`,\n parameters: generateParameterSchema(gap),\n rationale: generateRationale(gap),\n sourcePatterns: JSON.stringify([gap.id]),\n implementationHint: generateImplementationHint(gap),\n });\n\n result.proposed++;\n result.proposals.push(proposal);\n result.reasons.push(\n `Proposed \"${gap.suggestedToolName}\" (gap score: ${gap.score.toFixed(2)}, saves ${gap.toolSavings} tool calls).`,\n );\n }\n\n if (gaps.length === 0) {\n result.reasons.push(\"No actionable gaps detected in current workflow traces.\");\n }\n\n return result;\n }\n\n /**\n * Approve a pending proposal (marks it as approved).\n */\n approveProposal(id: string): { success: boolean; message: string; proposal?: ToolProposal } {\n const proposal = this.proposalStore.getById(id);\n if (!proposal) {\n return { success: false, message: `Proposal \"${id}\" not found.` };\n }\n if (proposal.status !== \"proposed\") {\n return {\n success: false,\n message: `Proposal \"${id}\" is already ${proposal.status} — cannot approve.`,\n };\n }\n const updated = this.proposalStore.updateStatus(id, \"approved\", \"proposed\");\n if (!updated) {\n return {\n success: false,\n message: `Proposal \"${id}\" status changed concurrently — cannot approve.`,\n };\n }\n return {\n success: true,\n message: `Proposal \"${proposal.name}\" approved. Mark as implemented when the tool is built.`,\n proposal: updated,\n };\n }\n\n /**\n * Reject a pending proposal (marks it as rejected).\n */\n rejectProposal(id: string): { success: boolean; message: string; proposal?: ToolProposal } {\n const proposal = this.proposalStore.getById(id);\n if (!proposal) {\n return { success: false, message: `Proposal \"${id}\" not found.` };\n }\n if (proposal.status !== \"proposed\") {\n return {\n success: false,\n message: `Proposal \"${id}\" is already ${proposal.status} — cannot reject.`,\n };\n }\n const updated = this.proposalStore.updateStatus(id, \"rejected\", \"proposed\");\n if (!updated) {\n return {\n success: false,\n message: `Proposal \"${id}\" status changed concurrently — cannot reject.`,\n };\n }\n return {\n success: true,\n message: `Proposal \"${proposal.name}\" rejected.`,\n proposal: updated,\n };\n }\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tools-md-section.js","names":[],"sources":["../../services/tools-md-section.ts"],"sourcesContent":["/**\n * TOOLS.md section handling: insert self-correction rules under a\n * named section instead of appending at end. Dedup by normalized line.\n */\n\nimport { existsSync, readFileSync, writeFileSync } from \"node:fs\";\n\nfunction normalizeRuleLine(line: string): string {\n return line.trim().replace(/\\s+/g, \" \").toLowerCase();\n}\n\n/**\n * Find the line index of a section heading (e.g. \"## Self-correction rules\").\n * Section is identified by exact match of ## <sectionTitle> (case-insensitive for title).\n */\nfunction findSectionStart(lines: string[], sectionTitle: string): number {\n const needle = `## ${sectionTitle.trim()}`;\n const lower = needle.toLowerCase();\n for (let i = 0; i < lines.length; i++) {\n const trimmed = lines[i].trim();\n if (trimmed.toLowerCase() === lower || trimmed.toLowerCase().startsWith(`${lower} `)) {\n return i;\n }\n }\n return -1;\n}\n\n/**\n * Find the end of the section (next ## or end of file).\n */\nfunction findSectionEnd(lines: string[], startIndex: number): number {\n for (let i = startIndex + 1; i < lines.length; i++) {\n if (lines[i].trim().startsWith(\"## \")) return i;\n }\n return lines.length;\n}\n\n/**\n * Extract existing bullet lines under the section (e.g. \"- rule text\").\n */\nfunction getExistingRuleLines(lines: string[], startIndex: number, endIndex: number): string[] {\n const rules: string[] = [];\n for (let i = startIndex + 1; i < endIndex; i++) {\n const line = lines[i].trim();\n if (line.startsWith(\"- \") && line.length > 2) {\n rules.push(line.slice(2).trim());\n }\n }\n return rules;\n}\n\n/**\n * Insert new rules under the given section. If section exists, append new rules\n * (dedup by normalized content). If section does not exist, append it at end of file.\n * Each rule is written as \"- <rule>\\n\".\n * @param defaultHeading - Heading used when file is empty (e.g. \"# AGENTS\" for AGENTS.md). Default \"# TOOLS\".\n */\nexport function insertRulesUnderSection(\n filePath: string,\n sectionTitle: string,\n newRules: string[],\n defaultHeading = \"# TOOLS\",\n): { inserted: number; sectionExisted: boolean } {\n const content = existsSync(filePath) ? readFileSync(filePath, \"utf-8\") : \"\";\n const lines = content.split(\"\\n\");\n const existingSet = new Set<string>();\n const sectionStart = findSectionStart(lines, sectionTitle);\n\n if (sectionStart >= 0) {\n const sectionEnd = findSectionEnd(lines, sectionStart);\n const existing = getExistingRuleLines(lines, sectionStart, sectionEnd);\n existing.forEach((r) => {\n existingSet.add(normalizeRuleLine(r));\n });\n }\n\n const toInsert: string[] = [];\n for (const r of newRules) {\n const t = r.trim();\n if (!t) continue;\n if (existingSet.has(normalizeRuleLine(t))) continue;\n existingSet.add(normalizeRuleLine(t));\n toInsert.push(t);\n }\n\n if (toInsert.length === 0) {\n return { inserted: 0, sectionExisted: sectionStart >= 0 };\n }\n\n const bulletLines = toInsert.map((t) => `- ${t}`);\n let newContent: string;\n\n if (sectionStart >= 0) {\n const sectionEnd = findSectionEnd(lines, sectionStart);\n const before = lines.slice(0, sectionEnd).join(\"\\n\");\n const after = lines.slice(sectionEnd).join(\"\\n\");\n const insertBlock = (before.trimEnd().endsWith(\"\\n\") ? \"\" : \"\\n\") + bulletLines.join(\"\\n\") + (after ? \"\\n\" : \"\");\n newContent = before + insertBlock + (after ? `\\n${after}` : \"\");\n } else {\n const sectionHeader = `\\n\\n## ${sectionTitle}\\n\\n`;\n newContent = `${(content.trimEnd() || defaultHeading) + sectionHeader + bulletLines.join(\"\\n\")}\\n`;\n }\n\n writeFileSync(filePath, newContent, \"utf-8\");\n return { inserted: toInsert.length, sectionExisted: sectionStart >= 0 };\n}\n"],"mappings":";;;;;;AAOA,SAAS,kBAAkB,MAAsB;CAC/C,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"tools-md-section.js","names":[],"sources":["../../services/tools-md-section.ts"],"sourcesContent":["/**\n * TOOLS.md section handling: insert self-correction rules under a\n * named section instead of appending at end. Dedup by normalized line.\n */\n\nimport { existsSync, readFileSync, writeFileSync } from \"node:fs\";\n\nfunction normalizeRuleLine(line: string): string {\n return line.trim().replace(/\\s+/g, \" \").toLowerCase();\n}\n\n/**\n * Find the line index of a section heading (e.g. \"## Self-correction rules\").\n * Section is identified by exact match of ## <sectionTitle> (case-insensitive for title).\n */\nfunction findSectionStart(lines: string[], sectionTitle: string): number {\n const needle = `## ${sectionTitle.trim()}`;\n const lower = needle.toLowerCase();\n for (let i = 0; i < lines.length; i++) {\n const trimmed = lines[i].trim();\n if (trimmed.toLowerCase() === lower || trimmed.toLowerCase().startsWith(`${lower} `)) {\n return i;\n }\n }\n return -1;\n}\n\n/**\n * Find the end of the section (next ## or end of file).\n */\nfunction findSectionEnd(lines: string[], startIndex: number): number {\n for (let i = startIndex + 1; i < lines.length; i++) {\n if (lines[i].trim().startsWith(\"## \")) return i;\n }\n return lines.length;\n}\n\n/**\n * Extract existing bullet lines under the section (e.g. \"- rule text\").\n */\nfunction getExistingRuleLines(lines: string[], startIndex: number, endIndex: number): string[] {\n const rules: string[] = [];\n for (let i = startIndex + 1; i < endIndex; i++) {\n const line = lines[i].trim();\n if (line.startsWith(\"- \") && line.length > 2) {\n rules.push(line.slice(2).trim());\n }\n }\n return rules;\n}\n\n/**\n * Insert new rules under the given section. If section exists, append new rules\n * (dedup by normalized content). If section does not exist, append it at end of file.\n * Each rule is written as \"- <rule>\\n\".\n * @param defaultHeading - Heading used when file is empty (e.g. \"# AGENTS\" for AGENTS.md). Default \"# TOOLS\".\n */\nexport function insertRulesUnderSection(\n filePath: string,\n sectionTitle: string,\n newRules: string[],\n defaultHeading = \"# TOOLS\",\n): { inserted: number; sectionExisted: boolean } {\n const content = existsSync(filePath) ? readFileSync(filePath, \"utf-8\") : \"\";\n const lines = content.split(\"\\n\");\n const existingSet = new Set<string>();\n const sectionStart = findSectionStart(lines, sectionTitle);\n\n if (sectionStart >= 0) {\n const sectionEnd = findSectionEnd(lines, sectionStart);\n const existing = getExistingRuleLines(lines, sectionStart, sectionEnd);\n existing.forEach((r) => {\n existingSet.add(normalizeRuleLine(r));\n });\n }\n\n const toInsert: string[] = [];\n for (const r of newRules) {\n const t = r.trim();\n if (!t) continue;\n if (existingSet.has(normalizeRuleLine(t))) continue;\n existingSet.add(normalizeRuleLine(t));\n toInsert.push(t);\n }\n\n if (toInsert.length === 0) {\n return { inserted: 0, sectionExisted: sectionStart >= 0 };\n }\n\n const bulletLines = toInsert.map((t) => `- ${t}`);\n let newContent: string;\n\n if (sectionStart >= 0) {\n const sectionEnd = findSectionEnd(lines, sectionStart);\n const before = lines.slice(0, sectionEnd).join(\"\\n\");\n const after = lines.slice(sectionEnd).join(\"\\n\");\n const insertBlock = (before.trimEnd().endsWith(\"\\n\") ? \"\" : \"\\n\") + bulletLines.join(\"\\n\") + (after ? \"\\n\" : \"\");\n newContent = before + insertBlock + (after ? `\\n${after}` : \"\");\n } else {\n const sectionHeader = `\\n\\n## ${sectionTitle}\\n\\n`;\n newContent = `${(content.trimEnd() || defaultHeading) + sectionHeader + bulletLines.join(\"\\n\")}\\n`;\n }\n\n writeFileSync(filePath, newContent, \"utf-8\");\n return { inserted: toInsert.length, sectionExisted: sectionStart >= 0 };\n}\n"],"mappings":";;;;;;AAOA,SAAS,kBAAkB,MAAsB;CAC/C,OAAO,KAAK,KAAK,EAAE,QAAQ,QAAQ,GAAG,EAAE,YAAY;AACtD;;;;;AAMA,SAAS,iBAAiB,OAAiB,cAA8B;CAEvE,MAAM,QAAQ,MADO,aAAa,KAAK,IAClB,YAAY;CACjC,KAAK,IAAI,IAAI,GAAG,IAAI,MAAM,QAAQ,KAAK;EACrC,MAAM,UAAU,MAAM,GAAG,KAAK;EAC9B,IAAI,QAAQ,YAAY,MAAM,SAAS,QAAQ,YAAY,EAAE,WAAW,GAAG,MAAM,EAAE,GACjF,OAAO;CAEX;CACA,OAAO;AACT;;;;AAKA,SAAS,eAAe,OAAiB,YAA4B;CACnE,KAAK,IAAI,IAAI,aAAa,GAAG,IAAI,MAAM,QAAQ,KAC7C,IAAI,MAAM,GAAG,KAAK,EAAE,WAAW,KAAK,GAAG,OAAO;CAEhD,OAAO,MAAM;AACf;;;;AAKA,SAAS,qBAAqB,OAAiB,YAAoB,UAA4B;CAC7F,MAAM,QAAkB,CAAC;CACzB,KAAK,IAAI,IAAI,aAAa,GAAG,IAAI,UAAU,KAAK;EAC9C,MAAM,OAAO,MAAM,GAAG,KAAK;EAC3B,IAAI,KAAK,WAAW,IAAI,KAAK,KAAK,SAAS,GACzC,MAAM,KAAK,KAAK,MAAM,CAAC,EAAE,KAAK,CAAC;CAEnC;CACA,OAAO;AACT;;;;;;;AAQA,SAAgB,wBACd,UACA,cACA,UACA,iBAAiB,WAC8B;CAC/C,MAAM,UAAU,WAAW,QAAQ,IAAI,aAAa,UAAU,OAAO,IAAI;CACzE,MAAM,QAAQ,QAAQ,MAAM,IAAI;CAChC,MAAM,8BAAc,IAAI,IAAY;CACpC,MAAM,eAAe,iBAAiB,OAAO,YAAY;CAEzD,IAAI,gBAAgB,GAGlB,qBADsC,OAAO,cAD1B,eAAe,OAAO,YAC2B,CAC7D,EAAE,SAAS,MAAM;EACtB,YAAY,IAAI,kBAAkB,CAAC,CAAC;CACtC,CAAC;CAGH,MAAM,WAAqB,CAAC;CAC5B,KAAK,MAAM,KAAK,UAAU;EACxB,MAAM,IAAI,EAAE,KAAK;EACjB,IAAI,CAAC,GAAG;EACR,IAAI,YAAY,IAAI,kBAAkB,CAAC,CAAC,GAAG;EAC3C,YAAY,IAAI,kBAAkB,CAAC,CAAC;EACpC,SAAS,KAAK,CAAC;CACjB;CAEA,IAAI,SAAS,WAAW,GACtB,OAAO;EAAE,UAAU;EAAG,gBAAgB,gBAAgB;CAAE;CAG1D,MAAM,cAAc,SAAS,KAAK,MAAM,KAAK,GAAG;CAChD,IAAI;CAEJ,IAAI,gBAAgB,GAAG;EACrB,MAAM,aAAa,eAAe,OAAO,YAAY;EACrD,MAAM,SAAS,MAAM,MAAM,GAAG,UAAU,EAAE,KAAK,IAAI;EACnD,MAAM,QAAQ,MAAM,MAAM,UAAU,EAAE,KAAK,IAAI;EAE/C,aAAa,WADQ,OAAO,QAAQ,EAAE,SAAS,IAAI,IAAI,KAAK,QAAQ,YAAY,KAAK,IAAI,KAAK,QAAQ,OAAO,QACxE,QAAQ,KAAK,UAAU;CAC9D,OAAO;EACL,MAAM,gBAAgB,UAAU,aAAa;EAC7C,aAAa,IAAI,QAAQ,QAAQ,KAAK,kBAAkB,gBAAgB,YAAY,KAAK,IAAI,EAAE;CACjG;CAEA,cAAc,UAAU,YAAY,OAAO;CAC3C,OAAO;EAAE,UAAU,SAAS;EAAQ,gBAAgB,gBAAgB;CAAE;AACxE"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"topic-clusters.js","names":[],"sources":["../../services/topic-clusters.ts"],"sourcesContent":["/**\n * Topic Cluster Detection (Issue #146).\n *\n * Detects and labels topic clusters — groups of densely interconnected facts\n * forming natural knowledge domains. Uses BFS connected-component analysis on\n * the memory_links graph.\n *\n * Usage:\n * const result = detectClusters(factsDb, { minClusterSize: 3 });\n * // result.clusters: TopicCluster[] sorted by size desc\n */\n\nimport { randomUUID } from \"node:crypto\";\nimport type { MemoryEntry } from \"../types/memory.js\";\n\n// ---------------------------------------------------------------------------\n// Public types\n// ---------------------------------------------------------------------------\n\n/** A detected topic cluster of interconnected facts. */\nexport interface TopicCluster {\n /** UUID for this cluster. */\n id: string;\n /** 2-3 word label derived from dominant entity/tags in cluster. */\n label: string;\n /** Fact IDs belonging to this cluster (sorted for stability). */\n factIds: string[];\n /** Number of facts (= factIds.length). */\n factCount: number;\n /** Epoch seconds when cluster was first detected. */\n createdAt: number;\n /** Epoch seconds when cluster was last updated. */\n updatedAt: number;\n}\n\n/** Options for cluster detection. */\nexport interface ClusterDetectionOptions {\n /** Minimum number of facts to form a cluster (default: 3). */\n minClusterSize?: number;\n /** Reserved: model for label generation; null = rule-based only (default: null). */\n labelModel?: string | null;\n /**\n * Map from canonical component key (sorted IDs joined by \",\") to existing cluster metadata.\n * Enables stable cluster IDs and createdAt timestamps across incremental re-runs.\n */\n existingClusterIds?: Map<string, { id: string; createdAt: number }>;\n}\n\n/** Result of a cluster detection run. */\nexport interface ClusterDetectionResult {\n /** Detected clusters above minClusterSize threshold, sorted by size desc. */\n clusters: TopicCluster[];\n /** Number of linked facts that belong to no cluster (below threshold). */\n isolatedFacts: number;\n /** Total number of unique fact IDs that appear in at least one link. */\n totalLinkedFacts: number;\n}\n\n/**\n * Minimal FactsDB interface needed for cluster detection.\n * FactsDB already satisfies this interface.\n */\nexport interface ClusterFactLookup {\n /**\n * Get all unique fact IDs that participate in at least one memory link\n * (as source or target).\n */\n getAllLinkedFactIds(): string[];\n /**\n * Get all edges in the memory_links graph as [sourceFactId, targetFactId] pairs.\n * Used for building the adjacency map in one efficient DB query.\n */\n getAllLinks(): Array<{ sourceFactId: string; targetFactId: string }>;\n /** Get a fact entry by ID. Returns null when not found or expired. */\n getById(id: string): MemoryEntry | null;\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Build a bidirectional adjacency map from edge pairs.\n * Nodes with no edges are seeded from factIds to ensure they appear in the map\n * even when getAllLinks() returns no edges for them (isolated case).\n */\nfunction buildAdjacency(\n factIds: string[],\n edges: Array<{ sourceFactId: string; targetFactId: string }>,\n): Map<string, Set<string>> {\n const adj = new Map<string, Set<string>>();\n\n // Seed all known nodes\n for (const id of factIds) {\n if (!adj.has(id)) adj.set(id, new Set());\n }\n\n // Add edges (bidirectional)\n for (const { sourceFactId, targetFactId } of edges) {\n if (!adj.has(sourceFactId)) adj.set(sourceFactId, new Set());\n if (!adj.has(targetFactId)) adj.set(targetFactId, new Set());\n adj.get(sourceFactId)?.add(targetFactId);\n adj.get(targetFactId)?.add(sourceFactId);\n }\n\n return adj;\n}\n\n/**\n * BFS from startId, collecting all reachable fact IDs.\n * Marks visited nodes in the shared `visited` set.\n */\nfunction bfsComponent(startId: string, adj: Map<string, Set<string>>, visited: Set<string>): string[] {\n const component: string[] = [];\n const queue: string[] = [startId];\n visited.add(startId);\n\n let head = 0;\n while (head < queue.length) {\n const current = queue[head++];\n component.push(current);\n const neighbors = adj.get(current);\n if (neighbors) {\n for (const neighbor of neighbors) {\n if (!visited.has(neighbor)) {\n visited.add(neighbor);\n queue.push(neighbor);\n }\n }\n }\n }\n\n return component;\n}\n\n// ---------------------------------------------------------------------------\n// Label generation\n// ---------------------------------------------------------------------------\n\n/**\n * Generate a 2-3 word label for a cluster using a rule-based approach.\n *\n * Priority:\n * 1. Most frequent entity across cluster members\n * 2. Most frequent tag across cluster members\n * 3. Most frequent category\n * 4. Generic fallback \"knowledge cluster\"\n */\nexport function generateClusterLabel(entries: MemoryEntry[]): string {\n if (entries.length === 0) return \"knowledge cluster\";\n\n // 1. Entity frequency\n const entityCounts = new Map<string, number>();\n for (const entry of entries) {\n const e = entry.entity?.trim();\n if (e) entityCounts.set(e, (entityCounts.get(e) ?? 0) + 1);\n }\n if (entityCounts.size > 0) {\n const topEntity = [...entityCounts.entries()].sort((a, b) => b[1] - a[1])[0][0];\n // Limit to 3 words, lowercase\n return topEntity\n .split(/[\\s_-]+/)\n .slice(0, 3)\n .join(\" \")\n .toLowerCase();\n }\n\n // 2. Tag frequency (entry.tags is already string[] | null after DB mapping)\n const tagCounts = new Map<string, number>();\n for (const entry of entries) {\n const tags = Array.isArray(entry.tags) ? entry.tags : [];\n for (const tag of tags) {\n const t = tag.trim();\n if (t) tagCounts.set(t, (tagCounts.get(t) ?? 0) + 1);\n }\n }\n if (tagCounts.size > 0) {\n const topTag = [...tagCounts.entries()].sort((a, b) => b[1] - a[1])[0][0];\n return topTag\n .split(/[\\s_-]+/)\n .slice(0, 3)\n .join(\" \")\n .toLowerCase();\n }\n\n // 3. Category frequency\n const categoryCounts = new Map<string, number>();\n for (const entry of entries) {\n const cat = entry.category;\n categoryCounts.set(cat, (categoryCounts.get(cat) ?? 0) + 1);\n }\n if (categoryCounts.size > 0) {\n const topCat = [...categoryCounts.entries()].sort((a, b) => b[1] - a[1])[0][0];\n return `${topCat} cluster`;\n }\n\n return \"knowledge cluster\";\n}\n\n// ---------------------------------------------------------------------------\n// Main detection function\n// ---------------------------------------------------------------------------\n\n/**\n * Detect topic clusters by BFS connected-component analysis on the memory_links graph.\n *\n * Algorithm:\n * 1. Load all fact IDs that participate in at least one link.\n * 2. Build a bidirectional adjacency map from all links.\n * 3. BFS from each unvisited fact to find connected components.\n * 4. Filter components below minClusterSize.\n * 5. Generate a rule-based label for each cluster.\n *\n * @param factsDb FactsDB-compatible interface (ClusterFactLookup).\n * @param options Detection options.\n * @returns ClusterDetectionResult with clusters sorted by size desc.\n */\nexport function detectClusters(\n factsDb: ClusterFactLookup,\n options: ClusterDetectionOptions = {},\n): ClusterDetectionResult {\n const { minClusterSize = 3, existingClusterIds } = options;\n const minSize = Number.isFinite(minClusterSize) ? Math.max(1, Math.floor(minClusterSize)) : 3;\n\n const linkedFactIds = factsDb.getAllLinkedFactIds();\n const totalLinkedFacts = linkedFactIds.length;\n\n if (totalLinkedFacts === 0) {\n return { clusters: [], isolatedFacts: 0, totalLinkedFacts: 0 };\n }\n\n // Build adjacency from all links at once (efficient: single DB query)\n const allEdges = factsDb.getAllLinks();\n const adj = buildAdjacency(linkedFactIds, allEdges);\n\n // BFS connected-component analysis\n const visited = new Set<string>();\n const components: string[][] = [];\n\n for (const factId of adj.keys()) {\n if (!visited.has(factId)) {\n components.push(bfsComponent(factId, adj, visited));\n }\n }\n\n const now = Math.floor(Date.now() / 1000);\n let isolatedFacts = 0;\n const clusters: TopicCluster[] = [];\n\n for (const component of components) {\n if (component.length < minSize) {\n isolatedFacts += component.length;\n continue;\n }\n\n const sortedIds = [...component].sort();\n const componentKey = sortedIds.join(\",\");\n const existing = existingClusterIds?.get(componentKey);\n const clusterId = existing?.id ?? randomUUID();\n const createdAt = existing?.createdAt ?? now;\n\n // Load entries for label generation (skips missing/expired facts)\n const entries: MemoryEntry[] = [];\n for (const id of sortedIds) {\n const entry = factsDb.getById(id);\n if (entry) entries.push(entry);\n }\n\n clusters.push({\n id: clusterId,\n label: generateClusterLabel(entries),\n factIds: sortedIds,\n factCount: sortedIds.length,\n createdAt,\n updatedAt: now,\n });\n }\n\n // Sort by size descending (largest cluster first)\n clusters.sort((a, b) => b.factCount - a.factCount);\n\n return { clusters, isolatedFacts, totalLinkedFacts };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"topic-clusters.js","names":[],"sources":["../../services/topic-clusters.ts"],"sourcesContent":["/**\n * Topic Cluster Detection (Issue #146).\n *\n * Detects and labels topic clusters — groups of densely interconnected facts\n * forming natural knowledge domains. Uses BFS connected-component analysis on\n * the memory_links graph.\n *\n * Usage:\n * const result = detectClusters(factsDb, { minClusterSize: 3 });\n * // result.clusters: TopicCluster[] sorted by size desc\n */\n\nimport { randomUUID } from \"node:crypto\";\nimport type { MemoryEntry } from \"../types/memory.js\";\n\n// ---------------------------------------------------------------------------\n// Public types\n// ---------------------------------------------------------------------------\n\n/** A detected topic cluster of interconnected facts. */\nexport interface TopicCluster {\n /** UUID for this cluster. */\n id: string;\n /** 2-3 word label derived from dominant entity/tags in cluster. */\n label: string;\n /** Fact IDs belonging to this cluster (sorted for stability). */\n factIds: string[];\n /** Number of facts (= factIds.length). */\n factCount: number;\n /** Epoch seconds when cluster was first detected. */\n createdAt: number;\n /** Epoch seconds when cluster was last updated. */\n updatedAt: number;\n}\n\n/** Options for cluster detection. */\nexport interface ClusterDetectionOptions {\n /** Minimum number of facts to form a cluster (default: 3). */\n minClusterSize?: number;\n /** Reserved: model for label generation; null = rule-based only (default: null). */\n labelModel?: string | null;\n /**\n * Map from canonical component key (sorted IDs joined by \",\") to existing cluster metadata.\n * Enables stable cluster IDs and createdAt timestamps across incremental re-runs.\n */\n existingClusterIds?: Map<string, { id: string; createdAt: number }>;\n}\n\n/** Result of a cluster detection run. */\nexport interface ClusterDetectionResult {\n /** Detected clusters above minClusterSize threshold, sorted by size desc. */\n clusters: TopicCluster[];\n /** Number of linked facts that belong to no cluster (below threshold). */\n isolatedFacts: number;\n /** Total number of unique fact IDs that appear in at least one link. */\n totalLinkedFacts: number;\n}\n\n/**\n * Minimal FactsDB interface needed for cluster detection.\n * FactsDB already satisfies this interface.\n */\nexport interface ClusterFactLookup {\n /**\n * Get all unique fact IDs that participate in at least one memory link\n * (as source or target).\n */\n getAllLinkedFactIds(): string[];\n /**\n * Get all edges in the memory_links graph as [sourceFactId, targetFactId] pairs.\n * Used for building the adjacency map in one efficient DB query.\n */\n getAllLinks(): Array<{ sourceFactId: string; targetFactId: string }>;\n /** Get a fact entry by ID. Returns null when not found or expired. */\n getById(id: string): MemoryEntry | null;\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Build a bidirectional adjacency map from edge pairs.\n * Nodes with no edges are seeded from factIds to ensure they appear in the map\n * even when getAllLinks() returns no edges for them (isolated case).\n */\nfunction buildAdjacency(\n factIds: string[],\n edges: Array<{ sourceFactId: string; targetFactId: string }>,\n): Map<string, Set<string>> {\n const adj = new Map<string, Set<string>>();\n\n // Seed all known nodes\n for (const id of factIds) {\n if (!adj.has(id)) adj.set(id, new Set());\n }\n\n // Add edges (bidirectional)\n for (const { sourceFactId, targetFactId } of edges) {\n if (!adj.has(sourceFactId)) adj.set(sourceFactId, new Set());\n if (!adj.has(targetFactId)) adj.set(targetFactId, new Set());\n adj.get(sourceFactId)?.add(targetFactId);\n adj.get(targetFactId)?.add(sourceFactId);\n }\n\n return adj;\n}\n\n/**\n * BFS from startId, collecting all reachable fact IDs.\n * Marks visited nodes in the shared `visited` set.\n */\nfunction bfsComponent(startId: string, adj: Map<string, Set<string>>, visited: Set<string>): string[] {\n const component: string[] = [];\n const queue: string[] = [startId];\n visited.add(startId);\n\n let head = 0;\n while (head < queue.length) {\n const current = queue[head++];\n component.push(current);\n const neighbors = adj.get(current);\n if (neighbors) {\n for (const neighbor of neighbors) {\n if (!visited.has(neighbor)) {\n visited.add(neighbor);\n queue.push(neighbor);\n }\n }\n }\n }\n\n return component;\n}\n\n// ---------------------------------------------------------------------------\n// Label generation\n// ---------------------------------------------------------------------------\n\n/**\n * Generate a 2-3 word label for a cluster using a rule-based approach.\n *\n * Priority:\n * 1. Most frequent entity across cluster members\n * 2. Most frequent tag across cluster members\n * 3. Most frequent category\n * 4. Generic fallback \"knowledge cluster\"\n */\nexport function generateClusterLabel(entries: MemoryEntry[]): string {\n if (entries.length === 0) return \"knowledge cluster\";\n\n // 1. Entity frequency\n const entityCounts = new Map<string, number>();\n for (const entry of entries) {\n const e = entry.entity?.trim();\n if (e) entityCounts.set(e, (entityCounts.get(e) ?? 0) + 1);\n }\n if (entityCounts.size > 0) {\n const topEntity = [...entityCounts.entries()].sort((a, b) => b[1] - a[1])[0][0];\n // Limit to 3 words, lowercase\n return topEntity\n .split(/[\\s_-]+/)\n .slice(0, 3)\n .join(\" \")\n .toLowerCase();\n }\n\n // 2. Tag frequency (entry.tags is already string[] | null after DB mapping)\n const tagCounts = new Map<string, number>();\n for (const entry of entries) {\n const tags = Array.isArray(entry.tags) ? entry.tags : [];\n for (const tag of tags) {\n const t = tag.trim();\n if (t) tagCounts.set(t, (tagCounts.get(t) ?? 0) + 1);\n }\n }\n if (tagCounts.size > 0) {\n const topTag = [...tagCounts.entries()].sort((a, b) => b[1] - a[1])[0][0];\n return topTag\n .split(/[\\s_-]+/)\n .slice(0, 3)\n .join(\" \")\n .toLowerCase();\n }\n\n // 3. Category frequency\n const categoryCounts = new Map<string, number>();\n for (const entry of entries) {\n const cat = entry.category;\n categoryCounts.set(cat, (categoryCounts.get(cat) ?? 0) + 1);\n }\n if (categoryCounts.size > 0) {\n const topCat = [...categoryCounts.entries()].sort((a, b) => b[1] - a[1])[0][0];\n return `${topCat} cluster`;\n }\n\n return \"knowledge cluster\";\n}\n\n// ---------------------------------------------------------------------------\n// Main detection function\n// ---------------------------------------------------------------------------\n\n/**\n * Detect topic clusters by BFS connected-component analysis on the memory_links graph.\n *\n * Algorithm:\n * 1. Load all fact IDs that participate in at least one link.\n * 2. Build a bidirectional adjacency map from all links.\n * 3. BFS from each unvisited fact to find connected components.\n * 4. Filter components below minClusterSize.\n * 5. Generate a rule-based label for each cluster.\n *\n * @param factsDb FactsDB-compatible interface (ClusterFactLookup).\n * @param options Detection options.\n * @returns ClusterDetectionResult with clusters sorted by size desc.\n */\nexport function detectClusters(\n factsDb: ClusterFactLookup,\n options: ClusterDetectionOptions = {},\n): ClusterDetectionResult {\n const { minClusterSize = 3, existingClusterIds } = options;\n const minSize = Number.isFinite(minClusterSize) ? Math.max(1, Math.floor(minClusterSize)) : 3;\n\n const linkedFactIds = factsDb.getAllLinkedFactIds();\n const totalLinkedFacts = linkedFactIds.length;\n\n if (totalLinkedFacts === 0) {\n return { clusters: [], isolatedFacts: 0, totalLinkedFacts: 0 };\n }\n\n // Build adjacency from all links at once (efficient: single DB query)\n const allEdges = factsDb.getAllLinks();\n const adj = buildAdjacency(linkedFactIds, allEdges);\n\n // BFS connected-component analysis\n const visited = new Set<string>();\n const components: string[][] = [];\n\n for (const factId of adj.keys()) {\n if (!visited.has(factId)) {\n components.push(bfsComponent(factId, adj, visited));\n }\n }\n\n const now = Math.floor(Date.now() / 1000);\n let isolatedFacts = 0;\n const clusters: TopicCluster[] = [];\n\n for (const component of components) {\n if (component.length < minSize) {\n isolatedFacts += component.length;\n continue;\n }\n\n const sortedIds = [...component].sort();\n const componentKey = sortedIds.join(\",\");\n const existing = existingClusterIds?.get(componentKey);\n const clusterId = existing?.id ?? randomUUID();\n const createdAt = existing?.createdAt ?? now;\n\n // Load entries for label generation (skips missing/expired facts)\n const entries: MemoryEntry[] = [];\n for (const id of sortedIds) {\n const entry = factsDb.getById(id);\n if (entry) entries.push(entry);\n }\n\n clusters.push({\n id: clusterId,\n label: generateClusterLabel(entries),\n factIds: sortedIds,\n factCount: sortedIds.length,\n createdAt,\n updatedAt: now,\n });\n }\n\n // Sort by size descending (largest cluster first)\n clusters.sort((a, b) => b.factCount - a.factCount);\n\n return { clusters, isolatedFacts, totalLinkedFacts };\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"trajectory-tracker.js","names":[],"sources":["../../services/trajectory-tracker.ts"],"sourcesContent":["/**\n * Trajectory learning: detect and analyze multi-turn task sequences,\n * classify outcomes, find pivot points, and extract heuristic lessons.\n * Issue #262 — Phase 2.\n */\n\nimport { randomUUID } from \"node:crypto\";\nimport { capturePluginError } from \"./error-reporter.js\";\nimport { computeSimpleSimilarity } from \"./implicit-feedback-extract.js\";\nimport type { ConversationTurn } from \"./implicit-feedback-extract.js\";\n\nexport interface TrajectoryTurn {\n role: \"user\" | \"assistant\";\n summary: string; // first 200 chars\n sentiment: \"positive\" | \"negative\" | \"neutral\";\n wasCorrection: boolean;\n wasRephrase: boolean;\n toolsUsed?: string[];\n}\n\nexport interface FeedbackTrajectory {\n id: string;\n sessionFile: string;\n turns: TrajectoryTurn[];\n outcome: \"success\" | \"partial\" | \"failure\";\n outcomeSignal: string;\n keyPivot?: number;\n lessonsExtracted: string[];\n topic: string;\n toolsUsed: string[];\n turnCount: number;\n}\n\ninterface TrajectoryBoundary {\n startIndex: number;\n endIndex: number;\n}\n\n// Patterns indicating positive sentiment in user messages\nconst POSITIVE_RE =\n /\\b(thanks|thank you|cheers|perfect|great|awesome|brilliant|excellent|wonderful|worked|done|fixed|solved|got it|success|that'?s it|exactly|correct|yes|nice|good)\\b/i;\n// Patterns indicating correction\nconst CORRECTION_RE =\n /\\b(no(?!\\s+(problem|worries|need|issue|issues|questions?|thanks?))|nope|wrong|incorrect|not what i (said|meant|asked)|that'?s not|i said|not right|you misunderstood|not quite)\\b/i;\n\n/**\n * Classify sentiment of a user message.\n */\nfunction classifySentiment(content: string): \"positive\" | \"negative\" | \"neutral\" {\n if (POSITIVE_RE.test(content)) return \"positive\";\n if (CORRECTION_RE.test(content)) return \"negative\";\n return \"neutral\";\n}\n\n/**\n * Detect boundaries of multi-turn task sequences.\n * Start: user message with content length > 20 characters.\n * End: grateful_close signal (short thank-you message) or significant topic change (cosine\n * similarity to the opening message drops below 0.2). Any open trajectory is also closed at\n * the end of the conversation. Minimum trajectory length is 3 turns (start + at least 2 more).\n */\nexport function detectTrajectoryBoundaries(turns: ConversationTurn[]): TrajectoryBoundary[] {\n const boundaries: TrajectoryBoundary[] = [];\n if (turns.length < 3) return boundaries;\n\n let trajectoryStart = -1;\n let lastTopicContent = \"\";\n\n for (let i = 0; i < turns.length; i++) {\n const turn = turns[i];\n\n if (turn.role === \"user\") {\n if (trajectoryStart < 0) {\n // A user message starting a potential trajectory\n if (turn.content.length > 20) {\n trajectoryStart = i;\n lastTopicContent = turn.content;\n }\n continue;\n }\n\n // Check if topic changed significantly\n const sim = computeSimpleSimilarity(turn.content, lastTopicContent);\n const isGrateful =\n /\\b(thanks|thank you|cheers|perfect|great|awesome)\\b/i.test(turn.content) && turn.content.length < 60;\n\n if (isGrateful || sim < 0.2) {\n // End current trajectory (include this turn)\n if (i - trajectoryStart >= 2) {\n boundaries.push({ startIndex: trajectoryStart, endIndex: i });\n }\n // Start new trajectory on next substantial message\n trajectoryStart = -1;\n lastTopicContent = \"\";\n } else {\n // Update last topic (rolling average of similarity)\n if (sim > 0.3) lastTopicContent = turn.content;\n }\n }\n }\n\n // Close any open trajectory at the end\n if (trajectoryStart >= 0 && turns.length - trajectoryStart >= 3) {\n boundaries.push({ startIndex: trajectoryStart, endIndex: turns.length - 1 });\n }\n\n return boundaries;\n}\n\n/**\n * Build TrajectoryTurn array from raw ConversationTurns in a boundary.\n */\nfunction buildTrajectoryTurns(turns: ConversationTurn[], boundary: TrajectoryBoundary): TrajectoryTurn[] {\n const slice = turns.slice(boundary.startIndex, boundary.endIndex + 1);\n return slice.map((t, idx) => {\n const wasCorrection = t.role === \"user\" && CORRECTION_RE.test(t.content);\n // Rephrase: high similarity to previous user message but not identical\n let wasRephrase = false;\n if (t.role === \"user\" && idx > 0) {\n const prevUser = slice\n .slice(0, idx)\n .reverse()\n .find((x) => x.role === \"user\");\n if (prevUser) {\n const sim = computeSimpleSimilarity(t.content, prevUser.content);\n wasRephrase = sim > 0.6 && t.content.trim() !== prevUser.content.trim();\n }\n }\n return {\n role: t.role,\n summary: t.content.slice(0, 200),\n sentiment: t.role === \"user\" ? classifySentiment(t.content) : \"neutral\",\n wasCorrection,\n wasRephrase,\n toolsUsed: t.toolCalls,\n };\n });\n}\n\n/**\n * Classify a trajectory's outcome and find the pivot point.\n */\nexport function classifyTrajectoryOutcome(trajectoryTurns: TrajectoryTurn[]): {\n outcome: \"success\" | \"partial\" | \"failure\";\n signal: string;\n keyPivot?: number;\n} {\n const userTurns = trajectoryTurns.filter((t) => t.role === \"user\");\n if (userTurns.length === 0) {\n return { outcome: \"failure\", signal: \"no_user_turns\" };\n }\n\n const lastUserTurn = userTurns[userTurns.length - 1];\n const hasCorrections = trajectoryTurns.some((t) => t.wasCorrection);\n const hasRephrases = trajectoryTurns.some((t) => t.wasRephrase);\n\n // Detect self-service / escalation (negative)\n const SELF_SERVICE_RE = /\\b(i'?ll (do|handle|fix) it (myself)?|never mind|forget it|i'?ll figure it out)\\b/i;\n const ESCALATION_RE = /\\b(i'?ll ask (someone|my|the|a)|going to ask|ask (someone else|my colleague|gpt|chatgpt))\\b/i;\n\n if (SELF_SERVICE_RE.test(lastUserTurn.summary) || ESCALATION_RE.test(lastUserTurn.summary)) {\n return { outcome: \"failure\", signal: \"self_service_or_escalation\" };\n }\n\n // Last user turn is positive → success or partial\n if (lastUserTurn.sentiment === \"positive\") {\n if (hasCorrections || hasRephrases) {\n // Find the last correction/rephrase, then the first positive turn after it\n let lastCorrectionIndex = -1;\n for (let i = trajectoryTurns.length - 1; i >= 0; i--) {\n if (trajectoryTurns[i].wasCorrection || trajectoryTurns[i].wasRephrase) {\n lastCorrectionIndex = i;\n break;\n }\n }\n let keyPivot: number | undefined;\n for (let i = lastCorrectionIndex + 1; i < trajectoryTurns.length; i++) {\n const t = trajectoryTurns[i];\n if (t.role === \"user\" && t.sentiment === \"positive\" && !t.wasCorrection) {\n keyPivot = i;\n break;\n }\n }\n return {\n outcome: \"partial\",\n signal: \"corrections_then_success\",\n keyPivot,\n };\n }\n return { outcome: \"success\", signal: \"positive_close\" };\n }\n\n // Last turn negative → failure\n if (lastUserTurn.sentiment === \"negative\") {\n return { outcome: \"failure\", signal: \"ended_with_correction\" };\n }\n\n // Neutral last turn — check if there were any positives anywhere\n const anyPositive = userTurns.some((t) => t.sentiment === \"positive\");\n if (anyPositive && !hasCorrections) {\n return { outcome: \"success\", signal: \"neutral_close_after_praise\" };\n }\n\n return { outcome: \"partial\", signal: \"neutral_close\" };\n}\n\n/**\n * Extract heuristic lessons from a trajectory without LLM.\n */\nexport function extractTrajectoryLessons(trajectory: FeedbackTrajectory): string[] {\n const lessons: string[] = [];\n const { outcome, turns, keyPivot, toolsUsed, turnCount, topic } = trajectory;\n\n const topicStr = topic ? ` on \"${topic}\"` : \" in this conversation\";\n\n if (outcome === \"success\") {\n if (turnCount <= 3) {\n const tools = toolsUsed.length > 0 ? ` using ${toolsUsed.join(\", \")}` : \"\";\n lessons.push(`Direct approach${tools} worked immediately${topicStr} — no corrections needed.`);\n } else {\n lessons.push(`After ${turnCount} turns, user was satisfied${topicStr}.`);\n }\n } else if (outcome === \"partial\") {\n if (keyPivot !== undefined) {\n const pivotTurn = turns[keyPivot];\n const before = turns.slice(0, keyPivot).filter((t) => t.wasCorrection).length;\n const pivotSummary = pivotTurn ? pivotTurn.summary.slice(0, 100) : \"unknown\";\n lessons.push(\n `Approach changed at turn ${keyPivot} (after ${before} correction(s)) which resolved the issue${topicStr}: \"${pivotSummary}\"`,\n );\n } else {\n lessons.push(`User had corrections then eventual success${topicStr} after ${turnCount} turns.`);\n }\n } else {\n // failure\n const corrections = turns.filter((t) => t.wasCorrection).length;\n const rephrases = turns.filter((t) => t.wasRephrase).length;\n if (corrections > 0 || rephrases > 0) {\n lessons.push(\n `User had to self-service or escalate after ${corrections} correction(s) and ${rephrases} rephrase(s)${topicStr} across ${turnCount} turns.`,\n );\n } else {\n lessons.push(`Interaction did not reach resolution${topicStr} after ${turnCount} turns.`);\n }\n }\n\n return lessons;\n}\n\n/**\n * Extract the dominant topic from trajectory turns.\n */\nfunction extractTopic(turns: ConversationTurn[], boundary: TrajectoryBoundary): string {\n const firstUserMsg = turns.slice(boundary.startIndex, boundary.endIndex + 1).find((t) => t.role === \"user\");\n if (!firstUserMsg) return \"\";\n // Take first 60 chars as topic proxy\n return firstUserMsg.content.slice(0, 60).trim();\n}\n\n/**\n * Extract all tools used in a trajectory.\n */\nfunction extractToolsUsed(turns: ConversationTurn[], boundary: TrajectoryBoundary): string[] {\n const tools = new Set<string>();\n for (let i = boundary.startIndex; i <= boundary.endIndex; i++) {\n for (const tool of turns[i].toolCalls ?? []) {\n tools.add(tool);\n }\n }\n return [...tools];\n}\n\n/**\n * Build FeedbackTrajectory objects from raw conversation turns.\n */\nexport function buildTrajectories(turns: ConversationTurn[], sessionFile: string): FeedbackTrajectory[] {\n const trajectories: FeedbackTrajectory[] = [];\n try {\n const boundaries = detectTrajectoryBoundaries(turns);\n for (const boundary of boundaries) {\n const tTurns = buildTrajectoryTurns(turns, boundary);\n const { outcome, signal, keyPivot } = classifyTrajectoryOutcome(tTurns);\n const topic = extractTopic(turns, boundary);\n const toolsUsed = extractToolsUsed(turns, boundary);\n\n const trajectory: FeedbackTrajectory = {\n id: randomUUID(),\n sessionFile,\n turns: tTurns,\n outcome,\n outcomeSignal: signal,\n keyPivot,\n lessonsExtracted: [],\n topic,\n toolsUsed,\n turnCount: tTurns.length,\n };\n trajectory.lessonsExtracted = extractTrajectoryLessons(trajectory);\n trajectories.push(trajectory);\n }\n } catch (err) {\n capturePluginError(err instanceof Error ? err : new Error(String(err)), {\n operation: \"buildTrajectories\",\n severity: \"warning\",\n subsystem: \"trajectory-tracker\",\n });\n }\n return trajectories;\n}\n\n/**\n * Serialize a FeedbackTrajectory for DB storage.\n */\nexport function serializeTrajectory(t: FeedbackTrajectory): {\n id: string;\n session_file: string;\n turns_json: string;\n outcome: string;\n outcome_signal: string;\n key_pivot: number | null;\n lessons_json: string;\n topic: string;\n tools_used: string;\n turn_count: number;\n} {\n return {\n id: t.id,\n session_file: t.sessionFile,\n turns_json: JSON.stringify(t.turns),\n outcome: t.outcome,\n outcome_signal: t.outcomeSignal,\n key_pivot: t.keyPivot ?? null,\n lessons_json: JSON.stringify(t.lessonsExtracted),\n topic: t.topic,\n tools_used: JSON.stringify(t.toolsUsed),\n turn_count: t.turnCount,\n };\n}\n\n/**\n * LLM-based trajectory analysis result.\n * Returned by analyzeTrajectoriesWithLLM when LLM config is available.\n */\ninterface TrajectoryLLMAnalysis {\n outcome: \"success\" | \"partial\" | \"failure\";\n keyLesson: string;\n pivotTurn: number | null;\n patterns: string[];\n}\n\n/**\n * Optional LLM analysis path for trajectory lessons.\n * Falls back to heuristic extractTrajectoryLessons() if the LLM call fails.\n *\n * @param trajectory - The trajectory to analyze\n * @param prompt - The trajectory-analyze.txt prompt template (with {{trajectory_json}} placeholder)\n * @param chatFn - The chatCompleteWithRetry function from chat.ts\n * @param model - LLM model name (optional, uses default if omitted)\n * @returns LLM-produced analysis, or null if unavailable/failed\n */\nexport async function analyzeTrajectoriesWithLLM(\n trajectory: FeedbackTrajectory,\n prompt: string,\n chatFn: (opts: { model?: string; messages: Array<{ role: string; content: string }> }) => Promise<string>,\n model?: string,\n): Promise<TrajectoryLLMAnalysis | null> {\n try {\n const turnsPayload = trajectory.turns.map((t) => ({\n role: t.role,\n contentSummary: t.summary,\n sentiment: t.sentiment,\n corrections: t.wasCorrection ? 1 : 0,\n }));\n\n const filledPrompt = prompt.replace(\"{{trajectory_json}}\", JSON.stringify(turnsPayload, null, 2));\n\n const raw = await chatFn({\n model,\n messages: [{ role: \"user\", content: filledPrompt }],\n });\n\n const parsed = JSON.parse(raw.trim()) as TrajectoryLLMAnalysis;\n\n // Validate minimal shape\n if (typeof parsed.outcome === \"string\" && typeof parsed.keyLesson === \"string\" && Array.isArray(parsed.patterns)) {\n return parsed;\n }\n return null;\n } catch {\n return null;\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"trajectory-tracker.js","names":[],"sources":["../../services/trajectory-tracker.ts"],"sourcesContent":["/**\n * Trajectory learning: detect and analyze multi-turn task sequences,\n * classify outcomes, find pivot points, and extract heuristic lessons.\n * Issue #262 — Phase 2.\n */\n\nimport { randomUUID } from \"node:crypto\";\nimport { capturePluginError } from \"./error-reporter.js\";\nimport { computeSimpleSimilarity } from \"./implicit-feedback-extract.js\";\nimport type { ConversationTurn } from \"./implicit-feedback-extract.js\";\n\nexport interface TrajectoryTurn {\n role: \"user\" | \"assistant\";\n summary: string; // first 200 chars\n sentiment: \"positive\" | \"negative\" | \"neutral\";\n wasCorrection: boolean;\n wasRephrase: boolean;\n toolsUsed?: string[];\n}\n\nexport interface FeedbackTrajectory {\n id: string;\n sessionFile: string;\n turns: TrajectoryTurn[];\n outcome: \"success\" | \"partial\" | \"failure\";\n outcomeSignal: string;\n keyPivot?: number;\n lessonsExtracted: string[];\n topic: string;\n toolsUsed: string[];\n turnCount: number;\n}\n\ninterface TrajectoryBoundary {\n startIndex: number;\n endIndex: number;\n}\n\n// Patterns indicating positive sentiment in user messages\nconst POSITIVE_RE =\n /\\b(thanks|thank you|cheers|perfect|great|awesome|brilliant|excellent|wonderful|worked|done|fixed|solved|got it|success|that'?s it|exactly|correct|yes|nice|good)\\b/i;\n// Patterns indicating correction\nconst CORRECTION_RE =\n /\\b(no(?!\\s+(problem|worries|need|issue|issues|questions?|thanks?))|nope|wrong|incorrect|not what i (said|meant|asked)|that'?s not|i said|not right|you misunderstood|not quite)\\b/i;\n\n/**\n * Classify sentiment of a user message.\n */\nfunction classifySentiment(content: string): \"positive\" | \"negative\" | \"neutral\" {\n if (POSITIVE_RE.test(content)) return \"positive\";\n if (CORRECTION_RE.test(content)) return \"negative\";\n return \"neutral\";\n}\n\n/**\n * Detect boundaries of multi-turn task sequences.\n * Start: user message with content length > 20 characters.\n * End: grateful_close signal (short thank-you message) or significant topic change (cosine\n * similarity to the opening message drops below 0.2). Any open trajectory is also closed at\n * the end of the conversation. Minimum trajectory length is 3 turns (start + at least 2 more).\n */\nexport function detectTrajectoryBoundaries(turns: ConversationTurn[]): TrajectoryBoundary[] {\n const boundaries: TrajectoryBoundary[] = [];\n if (turns.length < 3) return boundaries;\n\n let trajectoryStart = -1;\n let lastTopicContent = \"\";\n\n for (let i = 0; i < turns.length; i++) {\n const turn = turns[i];\n\n if (turn.role === \"user\") {\n if (trajectoryStart < 0) {\n // A user message starting a potential trajectory\n if (turn.content.length > 20) {\n trajectoryStart = i;\n lastTopicContent = turn.content;\n }\n continue;\n }\n\n // Check if topic changed significantly\n const sim = computeSimpleSimilarity(turn.content, lastTopicContent);\n const isGrateful =\n /\\b(thanks|thank you|cheers|perfect|great|awesome)\\b/i.test(turn.content) && turn.content.length < 60;\n\n if (isGrateful || sim < 0.2) {\n // End current trajectory (include this turn)\n if (i - trajectoryStart >= 2) {\n boundaries.push({ startIndex: trajectoryStart, endIndex: i });\n }\n // Start new trajectory on next substantial message\n trajectoryStart = -1;\n lastTopicContent = \"\";\n } else {\n // Update last topic (rolling average of similarity)\n if (sim > 0.3) lastTopicContent = turn.content;\n }\n }\n }\n\n // Close any open trajectory at the end\n if (trajectoryStart >= 0 && turns.length - trajectoryStart >= 3) {\n boundaries.push({ startIndex: trajectoryStart, endIndex: turns.length - 1 });\n }\n\n return boundaries;\n}\n\n/**\n * Build TrajectoryTurn array from raw ConversationTurns in a boundary.\n */\nfunction buildTrajectoryTurns(turns: ConversationTurn[], boundary: TrajectoryBoundary): TrajectoryTurn[] {\n const slice = turns.slice(boundary.startIndex, boundary.endIndex + 1);\n return slice.map((t, idx) => {\n const wasCorrection = t.role === \"user\" && CORRECTION_RE.test(t.content);\n // Rephrase: high similarity to previous user message but not identical\n let wasRephrase = false;\n if (t.role === \"user\" && idx > 0) {\n const prevUser = slice\n .slice(0, idx)\n .reverse()\n .find((x) => x.role === \"user\");\n if (prevUser) {\n const sim = computeSimpleSimilarity(t.content, prevUser.content);\n wasRephrase = sim > 0.6 && t.content.trim() !== prevUser.content.trim();\n }\n }\n return {\n role: t.role,\n summary: t.content.slice(0, 200),\n sentiment: t.role === \"user\" ? classifySentiment(t.content) : \"neutral\",\n wasCorrection,\n wasRephrase,\n toolsUsed: t.toolCalls,\n };\n });\n}\n\n/**\n * Classify a trajectory's outcome and find the pivot point.\n */\nexport function classifyTrajectoryOutcome(trajectoryTurns: TrajectoryTurn[]): {\n outcome: \"success\" | \"partial\" | \"failure\";\n signal: string;\n keyPivot?: number;\n} {\n const userTurns = trajectoryTurns.filter((t) => t.role === \"user\");\n if (userTurns.length === 0) {\n return { outcome: \"failure\", signal: \"no_user_turns\" };\n }\n\n const lastUserTurn = userTurns[userTurns.length - 1];\n const hasCorrections = trajectoryTurns.some((t) => t.wasCorrection);\n const hasRephrases = trajectoryTurns.some((t) => t.wasRephrase);\n\n // Detect self-service / escalation (negative)\n const SELF_SERVICE_RE = /\\b(i'?ll (do|handle|fix) it (myself)?|never mind|forget it|i'?ll figure it out)\\b/i;\n const ESCALATION_RE = /\\b(i'?ll ask (someone|my|the|a)|going to ask|ask (someone else|my colleague|gpt|chatgpt))\\b/i;\n\n if (SELF_SERVICE_RE.test(lastUserTurn.summary) || ESCALATION_RE.test(lastUserTurn.summary)) {\n return { outcome: \"failure\", signal: \"self_service_or_escalation\" };\n }\n\n // Last user turn is positive → success or partial\n if (lastUserTurn.sentiment === \"positive\") {\n if (hasCorrections || hasRephrases) {\n // Find the last correction/rephrase, then the first positive turn after it\n let lastCorrectionIndex = -1;\n for (let i = trajectoryTurns.length - 1; i >= 0; i--) {\n if (trajectoryTurns[i].wasCorrection || trajectoryTurns[i].wasRephrase) {\n lastCorrectionIndex = i;\n break;\n }\n }\n let keyPivot: number | undefined;\n for (let i = lastCorrectionIndex + 1; i < trajectoryTurns.length; i++) {\n const t = trajectoryTurns[i];\n if (t.role === \"user\" && t.sentiment === \"positive\" && !t.wasCorrection) {\n keyPivot = i;\n break;\n }\n }\n return {\n outcome: \"partial\",\n signal: \"corrections_then_success\",\n keyPivot,\n };\n }\n return { outcome: \"success\", signal: \"positive_close\" };\n }\n\n // Last turn negative → failure\n if (lastUserTurn.sentiment === \"negative\") {\n return { outcome: \"failure\", signal: \"ended_with_correction\" };\n }\n\n // Neutral last turn — check if there were any positives anywhere\n const anyPositive = userTurns.some((t) => t.sentiment === \"positive\");\n if (anyPositive && !hasCorrections) {\n return { outcome: \"success\", signal: \"neutral_close_after_praise\" };\n }\n\n return { outcome: \"partial\", signal: \"neutral_close\" };\n}\n\n/**\n * Extract heuristic lessons from a trajectory without LLM.\n */\nexport function extractTrajectoryLessons(trajectory: FeedbackTrajectory): string[] {\n const lessons: string[] = [];\n const { outcome, turns, keyPivot, toolsUsed, turnCount, topic } = trajectory;\n\n const topicStr = topic ? ` on \"${topic}\"` : \" in this conversation\";\n\n if (outcome === \"success\") {\n if (turnCount <= 3) {\n const tools = toolsUsed.length > 0 ? ` using ${toolsUsed.join(\", \")}` : \"\";\n lessons.push(`Direct approach${tools} worked immediately${topicStr} — no corrections needed.`);\n } else {\n lessons.push(`After ${turnCount} turns, user was satisfied${topicStr}.`);\n }\n } else if (outcome === \"partial\") {\n if (keyPivot !== undefined) {\n const pivotTurn = turns[keyPivot];\n const before = turns.slice(0, keyPivot).filter((t) => t.wasCorrection).length;\n const pivotSummary = pivotTurn ? pivotTurn.summary.slice(0, 100) : \"unknown\";\n lessons.push(\n `Approach changed at turn ${keyPivot} (after ${before} correction(s)) which resolved the issue${topicStr}: \"${pivotSummary}\"`,\n );\n } else {\n lessons.push(`User had corrections then eventual success${topicStr} after ${turnCount} turns.`);\n }\n } else {\n // failure\n const corrections = turns.filter((t) => t.wasCorrection).length;\n const rephrases = turns.filter((t) => t.wasRephrase).length;\n if (corrections > 0 || rephrases > 0) {\n lessons.push(\n `User had to self-service or escalate after ${corrections} correction(s) and ${rephrases} rephrase(s)${topicStr} across ${turnCount} turns.`,\n );\n } else {\n lessons.push(`Interaction did not reach resolution${topicStr} after ${turnCount} turns.`);\n }\n }\n\n return lessons;\n}\n\n/**\n * Extract the dominant topic from trajectory turns.\n */\nfunction extractTopic(turns: ConversationTurn[], boundary: TrajectoryBoundary): string {\n const firstUserMsg = turns.slice(boundary.startIndex, boundary.endIndex + 1).find((t) => t.role === \"user\");\n if (!firstUserMsg) return \"\";\n // Take first 60 chars as topic proxy\n return firstUserMsg.content.slice(0, 60).trim();\n}\n\n/**\n * Extract all tools used in a trajectory.\n */\nfunction extractToolsUsed(turns: ConversationTurn[], boundary: TrajectoryBoundary): string[] {\n const tools = new Set<string>();\n for (let i = boundary.startIndex; i <= boundary.endIndex; i++) {\n for (const tool of turns[i].toolCalls ?? []) {\n tools.add(tool);\n }\n }\n return [...tools];\n}\n\n/**\n * Build FeedbackTrajectory objects from raw conversation turns.\n */\nexport function buildTrajectories(turns: ConversationTurn[], sessionFile: string): FeedbackTrajectory[] {\n const trajectories: FeedbackTrajectory[] = [];\n try {\n const boundaries = detectTrajectoryBoundaries(turns);\n for (const boundary of boundaries) {\n const tTurns = buildTrajectoryTurns(turns, boundary);\n const { outcome, signal, keyPivot } = classifyTrajectoryOutcome(tTurns);\n const topic = extractTopic(turns, boundary);\n const toolsUsed = extractToolsUsed(turns, boundary);\n\n const trajectory: FeedbackTrajectory = {\n id: randomUUID(),\n sessionFile,\n turns: tTurns,\n outcome,\n outcomeSignal: signal,\n keyPivot,\n lessonsExtracted: [],\n topic,\n toolsUsed,\n turnCount: tTurns.length,\n };\n trajectory.lessonsExtracted = extractTrajectoryLessons(trajectory);\n trajectories.push(trajectory);\n }\n } catch (err) {\n capturePluginError(err instanceof Error ? err : new Error(String(err)), {\n operation: \"buildTrajectories\",\n severity: \"warning\",\n subsystem: \"trajectory-tracker\",\n });\n }\n return trajectories;\n}\n\n/**\n * Serialize a FeedbackTrajectory for DB storage.\n */\nexport function serializeTrajectory(t: FeedbackTrajectory): {\n id: string;\n session_file: string;\n turns_json: string;\n outcome: string;\n outcome_signal: string;\n key_pivot: number | null;\n lessons_json: string;\n topic: string;\n tools_used: string;\n turn_count: number;\n} {\n return {\n id: t.id,\n session_file: t.sessionFile,\n turns_json: JSON.stringify(t.turns),\n outcome: t.outcome,\n outcome_signal: t.outcomeSignal,\n key_pivot: t.keyPivot ?? null,\n lessons_json: JSON.stringify(t.lessonsExtracted),\n topic: t.topic,\n tools_used: JSON.stringify(t.toolsUsed),\n turn_count: t.turnCount,\n };\n}\n\n/**\n * LLM-based trajectory analysis result.\n * Returned by analyzeTrajectoriesWithLLM when LLM config is available.\n */\ninterface TrajectoryLLMAnalysis {\n outcome: \"success\" | \"partial\" | \"failure\";\n keyLesson: string;\n pivotTurn: number | null;\n patterns: string[];\n}\n\n/**\n * Optional LLM analysis path for trajectory lessons.\n * Falls back to heuristic extractTrajectoryLessons() if the LLM call fails.\n *\n * @param trajectory - The trajectory to analyze\n * @param prompt - The trajectory-analyze.txt prompt template (with {{trajectory_json}} placeholder)\n * @param chatFn - The chatCompleteWithRetry function from chat.ts\n * @param model - LLM model name (optional, uses default if omitted)\n * @returns LLM-produced analysis, or null if unavailable/failed\n */\nexport async function analyzeTrajectoriesWithLLM(\n trajectory: FeedbackTrajectory,\n prompt: string,\n chatFn: (opts: { model?: string; messages: Array<{ role: string; content: string }> }) => Promise<string>,\n model?: string,\n): Promise<TrajectoryLLMAnalysis | null> {\n try {\n const turnsPayload = trajectory.turns.map((t) => ({\n role: t.role,\n contentSummary: t.summary,\n sentiment: t.sentiment,\n corrections: t.wasCorrection ? 1 : 0,\n }));\n\n const filledPrompt = prompt.replace(\"{{trajectory_json}}\", JSON.stringify(turnsPayload, null, 2));\n\n const raw = await chatFn({\n model,\n messages: [{ role: \"user\", content: filledPrompt }],\n });\n\n const parsed = JSON.parse(raw.trim()) as TrajectoryLLMAnalysis;\n\n // Validate minimal shape\n if (typeof parsed.outcome === \"string\" && typeof parsed.keyLesson === \"string\" && Array.isArray(parsed.patterns)) {\n return parsed;\n }\n return null;\n } catch {\n return null;\n }\n}\n"],"mappings":"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"}
|