freeharness 0.4.1
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/LICENSE +21 -0
- package/NOTICE +108 -0
- package/README.md +134 -0
- package/canonical/HARVEST.yaml +858 -0
- package/canonical/PROVENANCE.yaml +740 -0
- package/canonical/agents/README.md +95 -0
- package/canonical/agents/agentic-architect.md +18 -0
- package/canonical/agents/database-reviewer.md +41 -0
- package/canonical/agents/explorer-ai-engineer-analyst.md +65 -0
- package/canonical/agents/pr-reviewer.md +96 -0
- package/canonical/agents/python-reviewer.md +55 -0
- package/canonical/agents/report-formatter.md +19 -0
- package/canonical/agents/security-reviewer.md +53 -0
- package/canonical/agents/test-writer.md +34 -0
- package/canonical/commands/fh-context.md +37 -0
- package/canonical/evals/promptfoo.redteam.yaml +108 -0
- package/canonical/hooks/cost-prices.json +14 -0
- package/canonical/hooks/freeharness-auto-memory.mjs +158 -0
- package/canonical/hooks/freeharness-config-guard.mjs +162 -0
- package/canonical/hooks/freeharness-context-monitor.mjs +198 -0
- package/canonical/hooks/freeharness-converge.mjs +227 -0
- package/canonical/hooks/freeharness-cost-tracker.mjs +1027 -0
- package/canonical/hooks/freeharness-memory-guard.mjs +57 -0
- package/canonical/hooks/freeharness-output-pruner.mjs +333 -0
- package/canonical/hooks/freeharness-precompact.mjs +323 -0
- package/canonical/hooks/freeharness-repeat-tool.mjs +208 -0
- package/canonical/hooks/freeharness-security-guidance.mjs +164 -0
- package/canonical/hooks/freeharness-session-start +583 -0
- package/canonical/hooks/hooks.auto-memory.claude-code.json +16 -0
- package/canonical/hooks/hooks.claude-code.json +17 -0
- package/canonical/hooks/hooks.config-guard.claude-code.json +16 -0
- package/canonical/hooks/hooks.context-monitor.claude-code.json +15 -0
- package/canonical/hooks/hooks.converge.claude-code.json +15 -0
- package/canonical/hooks/hooks.cost-tracker.claude-code.json +37 -0
- package/canonical/hooks/hooks.memory-guard.claude-code.json +16 -0
- package/canonical/hooks/hooks.output-pruner.claude-code.json +16 -0
- package/canonical/hooks/hooks.precompact.claude-code.json +15 -0
- package/canonical/hooks/hooks.repeat-tool.claude-code.json +25 -0
- package/canonical/hooks/hooks.security-guidance.claude-code.json +16 -0
- package/canonical/mcp/servers.json +20 -0
- package/canonical/openspec/config.base.yaml +89 -0
- package/canonical/openspec/schema.spec-driven.yaml +277 -0
- package/canonical/openspec/templates/data-contract.md +36 -0
- package/canonical/openspec/templates/design.md +84 -0
- package/canonical/openspec/templates/eval-plan.md +33 -0
- package/canonical/openspec/templates/follow-ups.md +2 -0
- package/canonical/openspec/templates/proposal.md +45 -0
- package/canonical/openspec/templates/spec.md +42 -0
- package/canonical/openspec/templates/tasks.md +38 -0
- package/canonical/profiles/local-llamacpp.LOCAL-MODELS.md +47 -0
- package/canonical/profiles/local-llamacpp.json +27 -0
- package/canonical/profiles/local-ollama.json +20 -0
- package/canonical/profiles/mcp.json +91 -0
- package/canonical/rules/CLAUDE.md +78 -0
- package/canonical/rules/project-CLAUDE.template.md +62 -0
- package/canonical/rules/routing.md +20 -0
- package/canonical/rules/safety.md +41 -0
- package/canonical/settings/settings.cache-hygiene.claude-code.json +5 -0
- package/canonical/skills/agent-evaluation/SKILL.md +102 -0
- package/canonical/skills/agent-evaluation/assets/evaluation_report_template.md +206 -0
- package/canonical/skills/agent-evaluation/evals/cases.yaml +106 -0
- package/canonical/skills/agent-evaluation/references/dataset-preparation.md +351 -0
- package/canonical/skills/agent-evaluation/references/scorers-constraints.md +165 -0
- package/canonical/skills/agent-evaluation/references/scorers.md +431 -0
- package/canonical/skills/agent-evaluation/references/setup-guide.md +252 -0
- package/canonical/skills/agent-evaluation/references/tracing-integration.md +164 -0
- package/canonical/skills/agent-evaluation/references/troubleshooting.md +514 -0
- package/canonical/skills/agent-evaluation/scripts/analyze_results.py +462 -0
- package/canonical/skills/agent-evaluation/scripts/create_dataset_template.py +382 -0
- package/canonical/skills/agent-evaluation/scripts/list_datasets.py +273 -0
- package/canonical/skills/agent-evaluation/scripts/run_evaluation_template.py +309 -0
- package/canonical/skills/agent-evaluation/scripts/setup_mlflow.py +353 -0
- package/canonical/skills/agent-evaluation/scripts/utils/__init__.py +27 -0
- package/canonical/skills/agent-evaluation/scripts/utils/env_validation.py +123 -0
- package/canonical/skills/agent-evaluation/scripts/utils/tracing_utils.py +108 -0
- package/canonical/skills/agent-evaluation/scripts/validate_agent_tracing.py +148 -0
- package/canonical/skills/agent-evaluation/scripts/validate_auth.py +240 -0
- package/canonical/skills/agent-evaluation/scripts/validate_environment.py +185 -0
- package/canonical/skills/agent-evaluation/scripts/validate_tracing_runtime.py +318 -0
- package/canonical/skills/agentic-architecture/SKILL.md +41 -0
- package/canonical/skills/agentic-architecture/evals/cases.yaml +94 -0
- package/canonical/skills/agentic-architecture/reference/context-engineering.md +75 -0
- package/canonical/skills/agentic-architecture/reference/patterns.md +76 -0
- package/canonical/skills/agentic-architecture/reference/reliability-observability.md +94 -0
- package/canonical/skills/agentic-architecture/reference/review-framework.md +59 -0
- package/canonical/skills/code-review/SKILL.md +52 -0
- package/canonical/skills/code-review/evals/cases.yaml +197 -0
- package/canonical/skills/git-workflow/SKILL.md +41 -0
- package/canonical/skills/git-workflow/evals/cases.yaml +92 -0
- package/canonical/skills/local-code/SKILL.md +24 -0
- package/canonical/skills/local-code/evals/cases.yaml +85 -0
- package/canonical/skills/local-code/scripts/query_llama.py +68 -0
- package/canonical/skills/report-formatting/SKILL.md +62 -0
- package/canonical/skills/report-formatting/evals/cases.yaml +83 -0
- package/canonical/skills/report-formatting/reference/ARCHITECTURE.md +135 -0
- package/canonical/skills/report-formatting/reference/HELPERS.md +120 -0
- package/canonical/skills/report-formatting/reference/PPTX.md +136 -0
- package/canonical/skills/report-formatting/reference/RELEASE.md +99 -0
- package/canonical/skills/report-formatting/reference/TEMPLATES.md +48 -0
- package/canonical/skills/report-formatting/reference/authoring.md +66 -0
- package/canonical/skills/report-formatting/scripts/architecture-template.md +271 -0
- package/canonical/skills/report-formatting/scripts/assets/claro-logo.png +0 -0
- package/canonical/skills/report-formatting/scripts/assets/claro-wordmark.svg +1 -0
- package/canonical/skills/report-formatting/scripts/build-architecture.js +57 -0
- package/canonical/skills/report-formatting/scripts/build-md-claro.js +224 -0
- package/canonical/skills/report-formatting/scripts/build-release.js +51 -0
- package/canonical/skills/report-formatting/scripts/check-env.js +58 -0
- package/canonical/skills/report-formatting/scripts/docmodel.js +283 -0
- package/canonical/skills/report-formatting/scripts/example-build.js +119 -0
- package/canonical/skills/report-formatting/scripts/example_build_ArchitectureReview_docx.js +195 -0
- package/canonical/skills/report-formatting/scripts/example_build_ExecutiveReport_docx.js +114 -0
- package/canonical/skills/report-formatting/scripts/package.json +17 -0
- package/canonical/skills/report-formatting/scripts/pptx/example_build_StatusReport_pptx.py +223 -0
- package/canonical/skills/report-formatting/scripts/pptx/example_build_WeeklyStatus_pptx.py +245 -0
- package/canonical/skills/report-formatting/scripts/pptx/pptx_office.py +144 -0
- package/canonical/skills/report-formatting/scripts/pptx/requirements.txt +7 -0
- package/canonical/skills/report-formatting/scripts/pptx/status_report_blocks.py +193 -0
- package/canonical/skills/report-formatting/scripts/pptx/theme_claro_pptx.py +473 -0
- package/canonical/skills/report-formatting/scripts/release-template.md +80 -0
- package/canonical/skills/report-formatting/scripts/render-mermaid.js +105 -0
- package/canonical/skills/report-formatting/scripts/theme-claro.js +403 -0
- package/canonical/skills/subagent-driven-development/SKILL.md +60 -0
- package/canonical/skills/subagent-driven-development/evals/cases.yaml +208 -0
- package/canonical/skills/subagent-driven-development/reference/brief.md +91 -0
- package/canonical/skills/subagent-driven-development/reference/handoff.md +118 -0
- package/canonical/skills/subagent-driven-development/scripts/validate-handoff.mjs +850 -0
- package/canonical/skills/systematic-debugging/SKILL.md +44 -0
- package/canonical/skills/systematic-debugging/evals/cases.yaml +90 -0
- package/canonical/skills/test-driven-development/SKILL.md +42 -0
- package/canonical/skills/test-driven-development/evals/cases.yaml +86 -0
- package/canonical/skills/test-driven-development/reference/writing-good-tests.md +52 -0
- package/canonical/skills/verification-before-completion/SKILL.md +53 -0
- package/canonical/skills/verification-before-completion/evals/cases.yaml +107 -0
- package/dist/adapters/content/language-lint.d.ts +46 -0
- package/dist/adapters/content/language-lint.js +191 -0
- package/dist/adapters/harness/claude-code.d.ts +57 -0
- package/dist/adapters/harness/claude-code.js +385 -0
- package/dist/adapters/harness/index.d.ts +3 -0
- package/dist/adapters/harness/index.js +17 -0
- package/dist/adapters/harness/opencode.d.ts +7 -0
- package/dist/adapters/harness/opencode.js +166 -0
- package/dist/adapters/harness/plugin.d.ts +22 -0
- package/dist/adapters/harness/plugin.js +257 -0
- package/dist/adapters/memory/doctor.d.ts +480 -0
- package/dist/adapters/memory/doctor.js +1217 -0
- package/dist/adapters/memory/vendored.d.ts +150 -0
- package/dist/adapters/memory/vendored.js +487 -0
- package/dist/adapters/package/node.d.ts +9 -0
- package/dist/adapters/package/node.js +20 -0
- package/dist/adapters/prompt/readline.d.ts +19 -0
- package/dist/adapters/prompt/readline.js +53 -0
- package/dist/adapters/sync/diff.d.ts +43 -0
- package/dist/adapters/sync/diff.js +380 -0
- package/dist/cli/cleanup.d.ts +86 -0
- package/dist/cli/cleanup.js +382 -0
- package/dist/cli/cost.d.ts +304 -0
- package/dist/cli/cost.js +501 -0
- package/dist/cli/doctor.d.ts +205 -0
- package/dist/cli/doctor.js +654 -0
- package/dist/cli/executor.d.ts +5 -0
- package/dist/cli/executor.js +48 -0
- package/dist/cli/plugin-validate.d.ts +37 -0
- package/dist/cli/plugin-validate.js +256 -0
- package/dist/cli/prefix.d.ts +68 -0
- package/dist/cli/prefix.js +218 -0
- package/dist/cli/profile.d.ts +170 -0
- package/dist/cli/profile.js +285 -0
- package/dist/cli/runner.d.ts +10 -0
- package/dist/cli/runner.js +27 -0
- package/dist/cli/uninstall.d.ts +65 -0
- package/dist/cli/uninstall.js +147 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +989 -0
- package/dist/infra/dogfood.d.ts +31 -0
- package/dist/infra/dogfood.js +51 -0
- package/dist/infra/fsutil.d.ts +40 -0
- package/dist/infra/fsutil.js +143 -0
- package/dist/infra/manifest.d.ts +76 -0
- package/dist/infra/manifest.js +92 -0
- package/dist/infra/paths.d.ts +19 -0
- package/dist/infra/paths.js +74 -0
- package/dist/infra/routing.d.ts +4 -0
- package/dist/infra/routing.js +7 -0
- package/node_modules/@freeharness/core/dist/context.d.ts +30 -0
- package/node_modules/@freeharness/core/dist/context.js +1 -0
- package/node_modules/@freeharness/core/dist/index.d.ts +5 -0
- package/node_modules/@freeharness/core/dist/index.js +6 -0
- package/node_modules/@freeharness/core/dist/planner.d.ts +13 -0
- package/node_modules/@freeharness/core/dist/planner.js +18 -0
- package/node_modules/@freeharness/core/dist/ports.d.ts +90 -0
- package/node_modules/@freeharness/core/dist/ports.js +1 -0
- package/node_modules/@freeharness/core/dist/registry.d.ts +41 -0
- package/node_modules/@freeharness/core/dist/registry.js +124 -0
- package/node_modules/@freeharness/core/dist/tiers.d.ts +30 -0
- package/node_modules/@freeharness/core/dist/tiers.js +99 -0
- package/node_modules/@freeharness/core/package.json +34 -0
- package/node_modules/@freeharness/sdd/PROVENANCE.yaml +175 -0
- package/node_modules/@freeharness/sdd/dist/src/bin/openspec.d.ts +2 -0
- package/node_modules/@freeharness/sdd/dist/src/bin/openspec.js +166 -0
- package/node_modules/@freeharness/sdd/dist/src/index.d.ts +14 -0
- package/node_modules/@freeharness/sdd/dist/src/index.js +11 -0
- package/node_modules/@freeharness/sdd/dist/src/init.d.ts +39 -0
- package/node_modules/@freeharness/sdd/dist/src/init.js +190 -0
- package/node_modules/@freeharness/sdd/dist/src/root-resolver.d.ts +8 -0
- package/node_modules/@freeharness/sdd/dist/src/root-resolver.js +32 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/archive.d.ts +81 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/archive.js +483 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/context.d.ts +4 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/context.js +48 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/follow-up.d.ts +57 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/follow-up.js +279 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/instructions.d.ts +67 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/instructions.js +460 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/list.d.ts +5 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/list.js +95 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/new-change.d.ts +6 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/new-change.js +150 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/schemas.d.ts +4 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/schemas.js +23 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/shared.d.ts +56 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/shared.js +140 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/status.d.ts +8 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/status.js +85 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/validate.d.ts +6 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/validate.js +229 -0
- package/node_modules/@freeharness/sdd/dist/src/validate-change.d.ts +40 -0
- package/node_modules/@freeharness/sdd/dist/src/validate-change.js +114 -0
- package/node_modules/@freeharness/sdd/dist/src/validate.d.ts +24 -0
- package/node_modules/@freeharness/sdd/dist/src/validate.js +72 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/apply/specs-apply.d.ts +130 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/apply/specs-apply.js +981 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/change-metadata.d.ts +46 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/change-metadata.js +128 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/change-status-policy.d.ts +26 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/change-status-policy.js +32 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/file-system.d.ts +50 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/file-system.js +394 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/global-config.d.ts +15 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/global-config.js +49 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/graph.d.ts +71 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/graph.js +164 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/index.d.ts +11 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/index.js +21 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/instruction-loader.d.ts +102 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/instruction-loader.js +230 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/outputs.d.ts +18 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/outputs.js +138 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/resolver.d.ts +82 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/resolver.js +333 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/schema.d.ts +12 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/schema.js +110 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/state.d.ts +11 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/state.js +32 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/task-progress.d.ts +33 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/task-progress.js +149 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/types.d.ts +39 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/types.js +49 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/adapters/claude.d.ts +12 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/adapters/claude.js +31 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/adapters/index.d.ts +10 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/adapters/index.js +10 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/adapters/opencode.d.ts +12 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/adapters/opencode.js +26 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/generator.d.ts +28 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/generator.js +41 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/index.d.ts +23 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/index.js +25 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/invocation.d.ts +72 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/invocation.js +76 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/registry.d.ts +39 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/registry.js +53 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/types.d.ts +62 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/types.js +7 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/yaml.d.ts +35 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-generation/yaml.js +47 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-references.d.ts +33 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/command-references.js +55 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/shared/allowed-tools.d.ts +11 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/shared/allowed-tools.js +11 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/shared/skill-generation.d.ts +48 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/shared/skill-generation.js +99 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/skill-templates.d.ts +19 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/skill-templates.js +18 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/types.d.ts +18 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/types.js +4 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/apply-change.d.ts +19 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/apply-change.js +207 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/archive-change.d.ts +12 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/archive-change.js +409 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/bulk-archive-change.d.ts +9 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/bulk-archive-change.js +673 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/continue-change.d.ts +9 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/continue-change.js +230 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/explore.d.ts +12 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/explore.js +550 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/feedback.d.ts +8 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/feedback.js +107 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/ff-change.d.ts +12 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/ff-change.js +228 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/new-change.d.ts +9 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/new-change.js +147 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/onboard.d.ts +9 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/onboard.js +571 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/propose.d.ts +12 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/propose.js +322 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/store-selection.d.ts +7 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/store-selection.js +7 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/sync-specs.d.ts +9 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/sync-specs.js +518 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/update-change.d.ts +9 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/update-change.js +176 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/verify-change.d.ts +9 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/verify-change.js +344 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/constants.d.ts +39 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/constants.js +54 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/delta-resolution.d.ts +35 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/delta-resolution.js +171 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/change-parser.d.ts +27 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/change-parser.js +210 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/code-fence.d.ts +14 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/code-fence.js +48 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/markdown-parser.d.ts +22 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/markdown-parser.js +186 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-blocks.d.ts +65 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-blocks.js +409 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-text.d.ts +55 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-text.js +98 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/spec-structure.d.ts +8 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/spec-structure.js +77 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/purpose-placeholder.d.ts +44 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/purpose-placeholder.js +116 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/schemas/base.schema.d.ts +12 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/schemas/base.schema.js +17 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/schemas/change.schema.d.ts +72 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/schemas/change.schema.js +30 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/schemas/index.d.ts +3 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/schemas/index.js +3 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/schemas/spec.schema.d.ts +17 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/schemas/spec.schema.js +14 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/spec-discovery.d.ts +39 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/spec-discovery.js +114 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/task-lines.d.ts +45 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/task-lines.js +105 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/task-numbering.d.ts +15 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/task-numbering.js +56 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/types.d.ts +17 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/types.js +1 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/validator.d.ts +94 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/validator.js +756 -0
- package/node_modules/@freeharness/sdd/package.json +57 -0
- package/node_modules/@freeharness/sdd/vendor/openspec/LICENSE.upstream +22 -0
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/schema.upstream.yaml +232 -0
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/README.md +19 -0
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-apply-change/SKILL.md +187 -0
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-archive-change/SKILL.md +181 -0
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-bulk-archive-change/SKILL.md +338 -0
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-continue-change/SKILL.md +117 -0
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-explore/SKILL.md +342 -0
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-ff-change/SKILL.md +116 -0
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-new-change/SKILL.md +76 -0
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-onboard/SKILL.md +560 -0
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-propose/SKILL.md +162 -0
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-sync-specs/SKILL.md +261 -0
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-update-change/SKILL.md +90 -0
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-verify-change/SKILL.md +174 -0
- package/package.json +70 -0
- package/provenance/canonical.PROVENANCE.yaml +740 -0
- package/provenance/claude-mem.LICENSE.upstream +202 -0
- package/provenance/memory.PROVENANCE.yaml +399 -0
- package/provenance/sdd.PROVENANCE.yaml +175 -0
- package/vendor-licenses/caveman.LICENSE +27 -0
- package/vendor-licenses/deepseek-harness.LICENSE +21 -0
- package/vendor-licenses/ecc.LICENSE +21 -0
- package/vendor-licenses/pi.LICENSE +21 -0
- package/vendor-licenses/superpowers.LICENSE +21 -0
|
@@ -0,0 +1,1217 @@
|
|
|
1
|
+
// Memory doctor: tell which claude-mem is actually active on this machine (ours vs an
|
|
2
|
+
// upstream install), back up its data, and switch the machine to the FreeHarness build.
|
|
3
|
+
//
|
|
4
|
+
// Evidence used (no guessing):
|
|
5
|
+
// - plugin cache versions under <cfg>/plugins/cache/thedotmack/claude-mem/<ver>/ — ours
|
|
6
|
+
// carries the `.install-version` marker written by VendoredMemory and ships exactly
|
|
7
|
+
// one skill (mem-search); an upstream install ships many (cloud-sync, babysit, ...).
|
|
8
|
+
// - installed_plugins.json — the installPath Claude Code loads hooks/skills from.
|
|
9
|
+
// - the running worker — /api/health reports its version; GET / answers 200 with the
|
|
10
|
+
// React viewer on upstream and, on our build, 200 with the FreeHarness viewer page
|
|
11
|
+
// (marker "FreeHarness memory", FIXUP section 8) or 404 on a pre-viewer build.
|
|
12
|
+
// - the data dir (~/.claude-mem or CLAUDE_MEM_DATA_DIR): claude-mem.db + chroma, plus the
|
|
13
|
+
// observer ledgers written by the ported 13.24 engine (observer-health.json,
|
|
14
|
+
// quota-cooldown.json) that `memory status` renders as rejections / cooldowns.
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import os from "node:os";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
19
|
+
import { MARKETPLACE_OWNER, PLUGIN_KEY, PLUGIN_NAME, UPSTREAM_MARKETPLACE_OWNER, UPSTREAM_PLUGIN_NAME } from "./vendored.js";
|
|
20
|
+
import { resolveCanonicalDir, sourceRepoRoot } from "../../infra/paths.js";
|
|
21
|
+
/** Marker in the FreeHarness viewer page served on GET / (plugin/ui/viewer.html). */
|
|
22
|
+
export const VIEWER_MARKER = "FreeHarness memory";
|
|
23
|
+
/** Mirror of engine shared/observer-health.ts: warn only after this many consecutive failures. */
|
|
24
|
+
export const OBSERVER_UNHEALTHY_FAILURE_THRESHOLD = 3;
|
|
25
|
+
/** Mirror of engine shared/quota-cooldown.ts: how long requests are withheld after a quota refusal. */
|
|
26
|
+
export const QUOTA_EXHAUSTED_RECHECK_COOLDOWN_MS = 30 * 60_000;
|
|
27
|
+
/** Ledger file names (engine shared/observer-health.ts, shared/quota-cooldown.ts), under the data dir. */
|
|
28
|
+
export const OBSERVER_HEALTH_FILENAME = "observer-health.json";
|
|
29
|
+
export const QUOTA_COOLDOWN_FILENAME = "quota-cooldown.json";
|
|
30
|
+
/**
|
|
31
|
+
* Read the observer ledgers of the data dir.
|
|
32
|
+
*
|
|
33
|
+
* Args:
|
|
34
|
+
* data: The claude-mem data dir.
|
|
35
|
+
* now: Clock (ms), injectable for tests.
|
|
36
|
+
*
|
|
37
|
+
* Returns:
|
|
38
|
+
* Health (null when no ledger), the unhealthy verdict (engine rule: >= threshold failures
|
|
39
|
+
* and the last error newer than the last success) and every armed cooldown with its remaining time.
|
|
40
|
+
*/
|
|
41
|
+
export function readObserverStatus(data, now = Date.now()) {
|
|
42
|
+
let health = null;
|
|
43
|
+
try {
|
|
44
|
+
const raw = JSON.parse(fs.readFileSync(path.join(data, OBSERVER_HEALTH_FILENAME), "utf8"));
|
|
45
|
+
if (raw && typeof raw === "object") {
|
|
46
|
+
health = {
|
|
47
|
+
consecutiveFailures: Number(raw.consecutiveFailures) || 0,
|
|
48
|
+
failingSinceAt: raw.failingSinceAt ?? null,
|
|
49
|
+
lastErrorAt: raw.lastErrorAt ?? null,
|
|
50
|
+
lastErrorMessage: raw.lastErrorMessage ?? null,
|
|
51
|
+
lastErrorProvider: raw.lastErrorProvider ?? null,
|
|
52
|
+
lastSuccessAt: raw.lastSuccessAt ?? null,
|
|
53
|
+
lastErrorKind: raw.lastErrorKind ?? null,
|
|
54
|
+
lastErrorCode: raw.lastErrorCode ?? null,
|
|
55
|
+
lastErrorAction: raw.lastErrorAction ?? null,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
/* absent or unreadable */
|
|
61
|
+
}
|
|
62
|
+
const unhealthy = health !== null && health.consecutiveFailures >= OBSERVER_UNHEALTHY_FAILURE_THRESHOLD && (health.lastErrorAt ?? 0) > (health.lastSuccessAt ?? 0);
|
|
63
|
+
const cooldowns = [];
|
|
64
|
+
try {
|
|
65
|
+
const raw = JSON.parse(fs.readFileSync(path.join(data, QUOTA_COOLDOWN_FILENAME), "utf8"));
|
|
66
|
+
if (Array.isArray(raw)) {
|
|
67
|
+
for (const e of raw) {
|
|
68
|
+
if (!e || typeof e.provider !== "string" || typeof e.armedAtMs !== "number")
|
|
69
|
+
continue;
|
|
70
|
+
cooldowns.push({
|
|
71
|
+
provider: e.provider,
|
|
72
|
+
message: typeof e.message === "string" ? e.message : "Provider reported the inference allowance exhausted",
|
|
73
|
+
...(e.window ? { window: e.window } : {}),
|
|
74
|
+
armedAtMs: e.armedAtMs,
|
|
75
|
+
remainingMs: Math.max(0, QUOTA_EXHAUSTED_RECHECK_COOLDOWN_MS - (now - e.armedAtMs)),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
/* absent or unreadable */
|
|
82
|
+
}
|
|
83
|
+
return { health, unhealthy, cooldowns };
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* How long an unresolved observer verdict stays worth a fresh warning. Independent of the
|
|
87
|
+
* engine's own `isObserverUnhealthy` (that gate does not carry an age bound yet): this reads
|
|
88
|
+
* the same ledger file directly so `memory status` can down-rank a failure with no session in
|
|
89
|
+
* between from a live WARN to a dated INFO line.
|
|
90
|
+
*/
|
|
91
|
+
export const OBSERVER_STALE_AFTER_MS = 12 * 60 * 60_000;
|
|
92
|
+
/**
|
|
93
|
+
* True when the ledger's last failure is old enough that a fresh WARN would be noise: the
|
|
94
|
+
* same unhealthy condition (>= threshold consecutive failures, the last error newer than the
|
|
95
|
+
* last success) plus that last error being older than `OBSERVER_STALE_AFTER_MS`.
|
|
96
|
+
*
|
|
97
|
+
* Args:
|
|
98
|
+
* ledger: The observer-health ledger (null when no ledger file exists yet).
|
|
99
|
+
* nowMs: Clock (ms), injectable for tests.
|
|
100
|
+
*
|
|
101
|
+
* Returns:
|
|
102
|
+
* Whether the verdict is stale.
|
|
103
|
+
*/
|
|
104
|
+
export function isObserverVerdictStale(ledger, nowMs = Date.now()) {
|
|
105
|
+
if (!ledger || ledger.lastErrorAt === null)
|
|
106
|
+
return false;
|
|
107
|
+
if (ledger.consecutiveFailures < OBSERVER_UNHEALTHY_FAILURE_THRESHOLD)
|
|
108
|
+
return false;
|
|
109
|
+
if (ledger.lastErrorAt <= (ledger.lastSuccessAt ?? 0))
|
|
110
|
+
return false;
|
|
111
|
+
return nowMs - ledger.lastErrorAt > OBSERVER_STALE_AFTER_MS;
|
|
112
|
+
}
|
|
113
|
+
/** "3 minutes" / "about 2 hours" for durations (mirrors the engine's describeDuration). */
|
|
114
|
+
function describeDuration(ms) {
|
|
115
|
+
const minutes = Math.max(1, Math.round(ms / 60_000));
|
|
116
|
+
if (minutes < 60)
|
|
117
|
+
return `${minutes} minute${minutes === 1 ? "" : "s"}`;
|
|
118
|
+
const hours = Math.round(minutes / 60);
|
|
119
|
+
if (hours < 48)
|
|
120
|
+
return `about ${hours} hour${hours === 1 ? "" : "s"}`;
|
|
121
|
+
return `about ${Math.round(hours / 24)} days`;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Printable lines for the observer state (OK/WARN/INFO prefixed).
|
|
125
|
+
*
|
|
126
|
+
* Args:
|
|
127
|
+
* s: The observer status.
|
|
128
|
+
* now: Clock (ms).
|
|
129
|
+
*
|
|
130
|
+
* Returns:
|
|
131
|
+
* Lines: one for the health ledger, one per cooldown (or "no quota cooldown").
|
|
132
|
+
*/
|
|
133
|
+
export function observerLines(s, now = Date.now()) {
|
|
134
|
+
const out = [];
|
|
135
|
+
const iso = (ms) => (ms ? new Date(ms).toISOString() : "never");
|
|
136
|
+
if (!s.health) {
|
|
137
|
+
out.push(`INFO observer: no ${OBSERVER_HEALTH_FILENAME} yet (written by the worker after its first observer run)`);
|
|
138
|
+
}
|
|
139
|
+
else if (s.unhealthy && isObserverVerdictStale(s.health, now)) {
|
|
140
|
+
// The verdict has expired (no session in between refreshed it): the caller's own
|
|
141
|
+
// "observer verdict stale" INFO line names the last failure, so no WARN is injected here.
|
|
142
|
+
}
|
|
143
|
+
else if (s.unhealthy) {
|
|
144
|
+
const since = s.health.failingSinceAt ?? s.health.lastErrorAt;
|
|
145
|
+
out.push(`WARN observer UNHEALTHY: ${s.health.consecutiveFailures} consecutive rejections on ${s.health.lastErrorProvider ?? "unknown provider"} for ${describeDuration(now - (since ?? now))} (since ${iso(since)}; last success ${iso(s.health.lastSuccessAt)})${s.health.lastErrorKind ? ` [${s.health.lastErrorKind}]` : ""}: ${s.health.lastErrorMessage ?? "unknown error"}${s.health.lastErrorAction ? ` — ${s.health.lastErrorAction}` : ""}`);
|
|
146
|
+
}
|
|
147
|
+
else if (s.health.consecutiveFailures > 0) {
|
|
148
|
+
out.push(`OK observer: ${s.health.consecutiveFailures} rejection${s.health.consecutiveFailures === 1 ? "" : "s"} since the last success (${iso(s.health.lastSuccessAt)}); below the ${OBSERVER_UNHEALTHY_FAILURE_THRESHOLD}-failure warning threshold. Last error: ${s.health.lastErrorMessage ?? "unknown"}`);
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
out.push(`OK observer healthy: last successful store ${iso(s.health.lastSuccessAt)}, no rejections since`);
|
|
152
|
+
}
|
|
153
|
+
// A resolved local-provider failure (the claude fallback stored the observation, so the
|
|
154
|
+
// ledger is not unhealthy) still deserves a visible line: local kinds never raise the
|
|
155
|
+
// health WARN on their own (spec: "local kinds SHALL NOT mark the observer unhealthy while
|
|
156
|
+
// the claude fallback stores observations"), so without this the owner would see no signal
|
|
157
|
+
// at all that the local server dropped a batch. Gated the same way as ContextBuilder's
|
|
158
|
+
// localNoticeApplies: a success recorded after the failure means a retry already landed it,
|
|
159
|
+
// so the line would misreport a stale outage as current.
|
|
160
|
+
if (s.health &&
|
|
161
|
+
!s.unhealthy &&
|
|
162
|
+
s.health.lastErrorKind &&
|
|
163
|
+
s.health.lastErrorKind.startsWith("local_") &&
|
|
164
|
+
s.health.lastErrorAt !== null &&
|
|
165
|
+
s.health.lastErrorAt > (s.health.lastSuccessAt ?? 0)) {
|
|
166
|
+
out.push(`INFO observer last failure was local (${s.health.lastErrorKind}): failed batches were retried on the claude path or stay queued for the next observer start; freeharness doctor --scope user probes the server`);
|
|
167
|
+
}
|
|
168
|
+
if (s.cooldowns.length === 0) {
|
|
169
|
+
out.push("OK no quota cooldown armed");
|
|
170
|
+
}
|
|
171
|
+
for (const c of s.cooldowns) {
|
|
172
|
+
const armed = new Date(c.armedAtMs).toISOString();
|
|
173
|
+
out.push(c.remainingMs > 0
|
|
174
|
+
? `WARN quota cooldown ACTIVE for ${c.provider}${c.window ? ` (${c.window} window)` : ""}: ${c.message} — ${describeDuration(c.remainingMs)} remaining (armed ${armed}; observations are withheld until then, one re-probe after)`
|
|
175
|
+
: `OK quota cooldown for ${c.provider} elapsed (armed ${armed}); the next observation re-probes the provider`);
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
/** Highest schema_versions row our vendored SessionStore applies (13.24.0 port; the upstream v50 observed_model/observed_billing migration is telemetry-only and not ported). */
|
|
180
|
+
export const OUR_SCHEMA_VERSION = 49;
|
|
181
|
+
/**
|
|
182
|
+
* Read max(version) from schema_versions via the sqlite3 CLI (no node sqlite dep).
|
|
183
|
+
*
|
|
184
|
+
* Returns:
|
|
185
|
+
* The version, or null when the db/sqlite3 is unavailable.
|
|
186
|
+
*/
|
|
187
|
+
function dbSchemaVersion(dbPath) {
|
|
188
|
+
if (!fs.existsSync(dbPath))
|
|
189
|
+
return null;
|
|
190
|
+
const r = spawnSync("sqlite3", ["-readonly", dbPath, "SELECT MAX(version) FROM schema_versions;"], { encoding: "utf8" });
|
|
191
|
+
if (r.status !== 0)
|
|
192
|
+
return null;
|
|
193
|
+
const n = parseInt(r.stdout.trim(), 10);
|
|
194
|
+
return Number.isFinite(n) ? n : null;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Reads observer spend (observation count and summed `discovery_tokens`) grouped by day
|
|
198
|
+
* window and `generated_by_model`, through `sqlite3 -readonly` (same invocation shape as
|
|
199
|
+
* `dbSchemaVersion`). The seven-day window includes today, matching the running total an
|
|
200
|
+
* owner reads off `memory status` (not "the other six days").
|
|
201
|
+
*
|
|
202
|
+
* Args:
|
|
203
|
+
* dbPath: Path of claude-mem.db.
|
|
204
|
+
* spawn: Injectable spawnSync, so the missing-binary path is deterministic in tests
|
|
205
|
+
* regardless of whether sqlite3 happens to be on the test machine's PATH.
|
|
206
|
+
*
|
|
207
|
+
* Returns:
|
|
208
|
+
* One row per (window, model) with a row, or the reason spend could not be read.
|
|
209
|
+
*/
|
|
210
|
+
export function observerSpend(dbPath, spawn = spawnSync) {
|
|
211
|
+
if (!fs.existsSync(dbPath))
|
|
212
|
+
return [];
|
|
213
|
+
const query = [
|
|
214
|
+
"SELECT 'today', generated_by_model, COUNT(*), COALESCE(SUM(discovery_tokens),0)",
|
|
215
|
+
"FROM observations WHERE date(created_at) = date('now') GROUP BY generated_by_model",
|
|
216
|
+
"UNION ALL",
|
|
217
|
+
"SELECT 'seven_day', generated_by_model, COUNT(*), COALESCE(SUM(discovery_tokens),0)",
|
|
218
|
+
"FROM observations WHERE created_at >= datetime('now', '-7 days') GROUP BY generated_by_model;",
|
|
219
|
+
].join(" ");
|
|
220
|
+
const r = spawn("sqlite3", ["-readonly", dbPath, query], { encoding: "utf8" });
|
|
221
|
+
if (r.error)
|
|
222
|
+
return { reason: r.error.message || "sqlite3 not found on PATH", missingBinary: true };
|
|
223
|
+
if (r.status !== 0)
|
|
224
|
+
return { reason: (r.stderr || "").trim().split("\n")[0] || `sqlite3 exited ${r.status}`, missingBinary: false };
|
|
225
|
+
const rows = [];
|
|
226
|
+
for (const line of r.stdout.split("\n")) {
|
|
227
|
+
if (!line.trim())
|
|
228
|
+
continue;
|
|
229
|
+
const [window, model, observations, tokens] = line.split("|");
|
|
230
|
+
if (window !== "today" && window !== "seven_day")
|
|
231
|
+
continue;
|
|
232
|
+
rows.push({ window, model: model || "(unknown)", observations: parseInt(observations, 10) || 0, tokens: parseInt(tokens, 10) || 0 });
|
|
233
|
+
}
|
|
234
|
+
return rows;
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Model ids that name metered Claude spend: the `claude-*` families, a bare tier alias
|
|
238
|
+
* (`haiku`, `sonnet`, `opus`, alone or with a `-` suffix) and the `$TIER:` placeholder the
|
|
239
|
+
* engine writes before a tier is resolved. `generated_by_model` stores the id as configured,
|
|
240
|
+
* and the engine's own default simple-tier model is the bare alias `haiku`, so a plain
|
|
241
|
+
* `claude` prefix test would file the whole simple tier as free local spend.
|
|
242
|
+
*
|
|
243
|
+
* The same rule, with the same expression, lives in the cost-tracker hook
|
|
244
|
+
* (`canonical/hooks/freeharness-cost-tracker.mjs`): the two split the same rows and a
|
|
245
|
+
* divergence would make `memory status` and `cost report` disagree on one machine.
|
|
246
|
+
*
|
|
247
|
+
* Args:
|
|
248
|
+
* model: A `generated_by_model` value.
|
|
249
|
+
*
|
|
250
|
+
* Returns:
|
|
251
|
+
* True for a Claude id, false for a local one.
|
|
252
|
+
*/
|
|
253
|
+
export function isClaudeModelId(model) {
|
|
254
|
+
return /^(?:claude|\$TIER:|(?:haiku|sonnet|opus)(?:-|$))/.test(model);
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Renders `observerSpend`'s result as printable INFO lines: for each window (today, then the
|
|
258
|
+
* seven-day window) that has at least one row, the Claude model rows, then the local model
|
|
259
|
+
* rows, then a `subscription observer tokens` subtotal summing the Claude rows of that window
|
|
260
|
+
* only — the subscription is the metered resource, so a local row never inflates it and a
|
|
261
|
+
* local-only window still gets a printed (zero) subtotal, since the window itself had
|
|
262
|
+
* activity. A window with no rows at all (a fresh install, or a provider not yet run today)
|
|
263
|
+
* prints nothing, matching the pre-split behavior for an empty result. A single line instead
|
|
264
|
+
* when spend could not be read at all (the fixed sqlite3-missing wording, or the
|
|
265
|
+
* query/database error's own reason).
|
|
266
|
+
*
|
|
267
|
+
* Args:
|
|
268
|
+
* spend: `observerSpend`'s return value.
|
|
269
|
+
*
|
|
270
|
+
* Returns:
|
|
271
|
+
* Lines in today-rows/today-subtotal/seven-day-rows/seven-day-subtotal order; `[]` when
|
|
272
|
+
* `spend` is an empty array (no observations in either window).
|
|
273
|
+
*/
|
|
274
|
+
export function spendLines(spend) {
|
|
275
|
+
if (!Array.isArray(spend)) {
|
|
276
|
+
return [spend.missingBinary ? "INFO observer spend unavailable without sqlite3" : `INFO observer spend unavailable: ${spend.reason}`];
|
|
277
|
+
}
|
|
278
|
+
const line = (r) => `INFO observer spend ${r.window === "today" ? "today" : "last 7 days"}: ${r.model} ${r.observations} observations, ${r.tokens} tokens`;
|
|
279
|
+
const windowLines = (window, label) => {
|
|
280
|
+
const rows = spend.filter((r) => r.window === window);
|
|
281
|
+
if (rows.length === 0)
|
|
282
|
+
return [];
|
|
283
|
+
const claude = rows.filter((r) => isClaudeModelId(r.model));
|
|
284
|
+
const local = rows.filter((r) => !isClaudeModelId(r.model));
|
|
285
|
+
const subtotal = claude.reduce((sum, r) => sum + r.tokens, 0);
|
|
286
|
+
return [...claude.map(line), ...local.map(line), `INFO subscription observer tokens ${label}: ${subtotal}`];
|
|
287
|
+
};
|
|
288
|
+
return [...windowLines("today", "today"), ...windowLines("seven_day", "last 7 days")];
|
|
289
|
+
}
|
|
290
|
+
/** Start of the UTC calendar day containing `nowMs` (matches `observerSpend`'s `date('now')`, which sqlite evaluates in UTC). */
|
|
291
|
+
function startOfUtcDayMs(nowMs) {
|
|
292
|
+
const d = new Date(nowMs);
|
|
293
|
+
return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
|
|
294
|
+
}
|
|
295
|
+
/** Default cap for `readLocalProviderLog`'s read: an unbounded read of an append-only, never-rotated log is unbounded work per `memory status` run. */
|
|
296
|
+
const DEFAULT_LOCAL_PROVIDER_LOG_CAP_BYTES = 4 * 1024 * 1024;
|
|
297
|
+
/**
|
|
298
|
+
* Reads the last `maxBytes` of a file, dropping the leading partial line when the read did not
|
|
299
|
+
* start at byte 0 (the day boundary and the log's true start are both inside the discarded
|
|
300
|
+
* head, so nothing counted from a truncated read double-counts or half-counts a batch).
|
|
301
|
+
*
|
|
302
|
+
* Args:
|
|
303
|
+
* filePath: File to read.
|
|
304
|
+
* maxBytes: Cap.
|
|
305
|
+
*
|
|
306
|
+
* Returns:
|
|
307
|
+
* The tail text (whole file when it fits) and whether it was truncated.
|
|
308
|
+
*/
|
|
309
|
+
function readTail(filePath, maxBytes) {
|
|
310
|
+
const size = fs.statSync(filePath).size;
|
|
311
|
+
if (size <= maxBytes)
|
|
312
|
+
return { text: fs.readFileSync(filePath, "utf8"), truncated: false };
|
|
313
|
+
const start = size - maxBytes;
|
|
314
|
+
const fd = fs.openSync(filePath, "r");
|
|
315
|
+
try {
|
|
316
|
+
const buffer = Buffer.alloc(maxBytes);
|
|
317
|
+
// readSync may return fewer bytes than asked; converting the whole buffer would turn the
|
|
318
|
+
// untouched zero padding into a garbage trailing line the caller counts as unparseable.
|
|
319
|
+
const read = fs.readSync(fd, buffer, 0, maxBytes, start);
|
|
320
|
+
const chunk = buffer.subarray(0, read).toString("utf8");
|
|
321
|
+
const firstNewline = chunk.indexOf("\n");
|
|
322
|
+
return { text: firstNewline === -1 ? "" : chunk.slice(firstNewline + 1), truncated: true };
|
|
323
|
+
}
|
|
324
|
+
finally {
|
|
325
|
+
fs.closeSync(fd);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Reads today's local-provider activity from the append-only batch log, at most its last
|
|
330
|
+
* `maxBytes` (default 4 MB) so an unrotated, ever-growing log never makes `memory status`
|
|
331
|
+
* read the whole file.
|
|
332
|
+
*
|
|
333
|
+
* Args:
|
|
334
|
+
* logPath: `<data-dir>/local-provider.jsonl`.
|
|
335
|
+
* dayStartMs: Start of the UTC day being summarized. The engine writes `ts` as an ISO
|
|
336
|
+
* instant and the only caller passes `startOfUtcDayMs`, so the window matches the UTC
|
|
337
|
+
* boundary `observerSpend`'s `date('now')` already uses and the two lines describe one day.
|
|
338
|
+
* nowMs: End of the window (the day so far).
|
|
339
|
+
* maxBytes: Read cap, injectable so a test can exercise truncation without writing a
|
|
340
|
+
* multi-megabyte fixture.
|
|
341
|
+
*
|
|
342
|
+
* Returns:
|
|
343
|
+
* The day's batch/fallback counts and whether the read was truncated; null when the log file
|
|
344
|
+
* does not exist yet (a fresh `CLAUDE_MEM_PROVIDER=claude` machine has never written one);
|
|
345
|
+
* the reason when the file is there but could not be read, which is a different state from
|
|
346
|
+
* an absent log and gets its own printed line.
|
|
347
|
+
*/
|
|
348
|
+
export function readLocalProviderLog(logPath, dayStartMs, nowMs, maxBytes = DEFAULT_LOCAL_PROVIDER_LOG_CAP_BYTES) {
|
|
349
|
+
let tail;
|
|
350
|
+
try {
|
|
351
|
+
tail = readTail(logPath, maxBytes);
|
|
352
|
+
}
|
|
353
|
+
catch (e) {
|
|
354
|
+
const err = e;
|
|
355
|
+
if (err && err.code === "ENOENT")
|
|
356
|
+
return null;
|
|
357
|
+
return { reason: err && err.message ? err.message : String(e) };
|
|
358
|
+
}
|
|
359
|
+
const summary = { batches: 0, local: 0, fallbacks: {}, skippedLines: 0, truncated: tail.truncated };
|
|
360
|
+
for (const rawLine of tail.text.split("\n")) {
|
|
361
|
+
const line = rawLine.trim();
|
|
362
|
+
if (!line)
|
|
363
|
+
continue;
|
|
364
|
+
let entry;
|
|
365
|
+
try {
|
|
366
|
+
entry = JSON.parse(line);
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
summary.skippedLines++;
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
if (!entry || typeof entry !== "object") {
|
|
373
|
+
summary.skippedLines++;
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
const e = entry;
|
|
377
|
+
const tsMs = typeof e.ts === "string" ? Date.parse(e.ts) : NaN;
|
|
378
|
+
const attempted = e.attempted;
|
|
379
|
+
if (!Number.isFinite(tsMs) || (attempted !== "local" && attempted !== "claude")) {
|
|
380
|
+
summary.skippedLines++;
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
if (attempted !== "local")
|
|
384
|
+
continue; // a claude-attempted line: well-formed, not counted here
|
|
385
|
+
if (tsMs < dayStartMs || tsMs > nowMs)
|
|
386
|
+
continue; // outside the day: well-formed, not today
|
|
387
|
+
summary.batches++;
|
|
388
|
+
if (e.answered === "local") {
|
|
389
|
+
summary.local++;
|
|
390
|
+
}
|
|
391
|
+
else {
|
|
392
|
+
const kind = typeof e.kind === "string" && e.kind.length > 0 ? e.kind : "unknown";
|
|
393
|
+
summary.fallbacks[kind] = (summary.fallbacks[kind] ?? 0) + 1;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return summary;
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Reads the near-duplicate rate of the locally answered observations, for the UTC day so far
|
|
400
|
+
* and for the one before it: rows are grouped by `(generated_by_model, memory_session_id,
|
|
401
|
+
* title)` and every row past the first of a group counts as a near-duplicate. Only rows whose
|
|
402
|
+
* `generated_by_model` fails `isClaudeModelId` are counted — a stateless local batch has no
|
|
403
|
+
* prior context to avoid repeating itself, which is the cost the figure exists to measure, and
|
|
404
|
+
* a Claude row would dilute it. The previous day is read in the same pass so the rate has a
|
|
405
|
+
* local-to-local baseline: the day so far compared against the day before it, both counting
|
|
406
|
+
* only locally answered rows.
|
|
407
|
+
*
|
|
408
|
+
* Same unavailability shape as `observerSpend`.
|
|
409
|
+
*
|
|
410
|
+
* Args:
|
|
411
|
+
* dbPath: Path of claude-mem.db.
|
|
412
|
+
* spawn: Injectable spawnSync (see `observerSpend`).
|
|
413
|
+
*
|
|
414
|
+
* Returns:
|
|
415
|
+
* Both days' observation and duplicate counts, or the reason the rate could not be read.
|
|
416
|
+
*/
|
|
417
|
+
export function observerDuplicateRate(dbPath, spawn = spawnSync) {
|
|
418
|
+
const empty = { today: { observations: 0, duplicates: 0 }, yesterday: { observations: 0, duplicates: 0 } };
|
|
419
|
+
if (!fs.existsSync(dbPath))
|
|
420
|
+
return empty;
|
|
421
|
+
const query = [
|
|
422
|
+
"SELECT day, model, COUNT(*) FROM (",
|
|
423
|
+
"SELECT CASE WHEN date(created_at) = date('now') THEN 'today' ELSE 'yesterday' END AS day,",
|
|
424
|
+
"COALESCE(generated_by_model,'') AS model, memory_session_id, title FROM observations",
|
|
425
|
+
"WHERE date(created_at) IN (date('now'), date('now','-1 day'))",
|
|
426
|
+
") GROUP BY day, model, memory_session_id, title;",
|
|
427
|
+
].join(" ");
|
|
428
|
+
const r = spawn("sqlite3", ["-readonly", dbPath, query], { encoding: "utf8" });
|
|
429
|
+
if (r.error)
|
|
430
|
+
return { reason: r.error.message || "sqlite3 not found on PATH", missingBinary: true };
|
|
431
|
+
if (r.status !== 0)
|
|
432
|
+
return { reason: (r.stderr || "").trim().split("\n")[0] || `sqlite3 exited ${r.status}`, missingBinary: false };
|
|
433
|
+
const stats = { today: { observations: 0, duplicates: 0 }, yesterday: { observations: 0, duplicates: 0 } };
|
|
434
|
+
for (const line of r.stdout.split("\n")) {
|
|
435
|
+
if (!line.trim())
|
|
436
|
+
continue;
|
|
437
|
+
const parts = line.split("|");
|
|
438
|
+
if (parts.length < 3)
|
|
439
|
+
continue;
|
|
440
|
+
const day = parts[0];
|
|
441
|
+
const model = parts.slice(1, -1).join("|");
|
|
442
|
+
const count = parseInt(parts[parts.length - 1], 10) || 0;
|
|
443
|
+
if (day !== "today" && day !== "yesterday")
|
|
444
|
+
continue;
|
|
445
|
+
if (isClaudeModelId(model))
|
|
446
|
+
continue;
|
|
447
|
+
stats[day].observations += count;
|
|
448
|
+
stats[day].duplicates += count - 1;
|
|
449
|
+
}
|
|
450
|
+
return stats;
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Renders the local-provider log summary and near-duplicate rate as printable INFO lines: the
|
|
454
|
+
* batch/fallback line, then the near-duplicate-rate line (omitted when the rate could not be
|
|
455
|
+
* read). Nothing at all when `summary` is null (no `local-provider.jsonl` yet); a single WARN
|
|
456
|
+
* naming the reason when the log is present but unreadable, so a permission or I/O failure
|
|
457
|
+
* never reads like a machine that has not run the local provider.
|
|
458
|
+
*
|
|
459
|
+
* Args:
|
|
460
|
+
* summary: `readLocalProviderLog`'s return value.
|
|
461
|
+
* duplicates: `observerDuplicateRate`'s return value.
|
|
462
|
+
*
|
|
463
|
+
* Returns:
|
|
464
|
+
* Zero, one or two lines.
|
|
465
|
+
*/
|
|
466
|
+
export function localProviderLines(summary, duplicates) {
|
|
467
|
+
if (!summary)
|
|
468
|
+
return [];
|
|
469
|
+
if ("reason" in summary)
|
|
470
|
+
return [`WARN local observer log unreadable: ${summary.reason}`];
|
|
471
|
+
const fallbackTotal = summary.batches - summary.local;
|
|
472
|
+
const kindText = Object.keys(summary.fallbacks)
|
|
473
|
+
.sort()
|
|
474
|
+
.map((kind) => `${kind} ${summary.fallbacks[kind]}`)
|
|
475
|
+
.join(", ");
|
|
476
|
+
const out = [
|
|
477
|
+
`INFO local observer today: ${summary.batches} batches, ${summary.local} local, ${fallbackTotal} fallback${fallbackTotal === 1 ? "" : "s"}${kindText ? ` (${kindText})` : ""}${summary.truncated ? " (log truncated)" : ""}`,
|
|
478
|
+
];
|
|
479
|
+
if (!("reason" in duplicates)) {
|
|
480
|
+
const share = (d) => {
|
|
481
|
+
const pct = d.observations > 0 ? ((d.duplicates / d.observations) * 100).toFixed(1) : "0.0";
|
|
482
|
+
return `${d.duplicates}/${d.observations} (${pct}%)`;
|
|
483
|
+
};
|
|
484
|
+
out.push(`INFO local observer near-duplicates today: ${share(duplicates.today)}, yesterday ${share(duplicates.yesterday)}`);
|
|
485
|
+
}
|
|
486
|
+
return out;
|
|
487
|
+
}
|
|
488
|
+
/** A `haiku` alias (bare or `claude-haiku-*`), the FreeHarness-recommended observer model kind. */
|
|
489
|
+
export function isHaikuAlias(model) {
|
|
490
|
+
return model === "haiku" || model.startsWith("claude-haiku");
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Defensive fallback for `engineDefaultObserverModels` when the vendored engine tree is not a
|
|
494
|
+
* sibling on disk (an npx-bridge install with no vendored source next to it). Mirrors
|
|
495
|
+
* SettingsDefaultsManager.ts's current defaults; the parser above is the source of truth when
|
|
496
|
+
* the tree is present, so a future default change there needs no edit here to take effect on a
|
|
497
|
+
* checkout — this fallback only covers the tree-absent case.
|
|
498
|
+
*/
|
|
499
|
+
const FALLBACK_ENGINE_OBSERVER_DEFAULTS = { base: "claude-haiku-4-5-20251001", simple: "haiku", summary: "" };
|
|
500
|
+
/**
|
|
501
|
+
* Reads the engine's own default observer models straight from
|
|
502
|
+
* packages/memory/vendor/claude-mem/engine/shared/SettingsDefaultsManager.ts, so a change to
|
|
503
|
+
* that file's DEFAULTS reaches `memory status` without a hand-maintained copy going stale.
|
|
504
|
+
*
|
|
505
|
+
* Returns:
|
|
506
|
+
* Base, simple and (raw, possibly empty) summary defaults.
|
|
507
|
+
*/
|
|
508
|
+
function engineDefaultObserverModels() {
|
|
509
|
+
try {
|
|
510
|
+
const repo = sourceRepoRoot(resolveCanonicalDir());
|
|
511
|
+
if (!repo)
|
|
512
|
+
return FALLBACK_ENGINE_OBSERVER_DEFAULTS;
|
|
513
|
+
const managerPath = path.join(repo, "packages", "memory", "vendor", UPSTREAM_PLUGIN_NAME, "engine", "shared", "SettingsDefaultsManager.ts");
|
|
514
|
+
const src = fs.readFileSync(managerPath, "utf8");
|
|
515
|
+
const pick = (key) => src.match(new RegExp(`${key}:\\s*'([^']*)'`))?.[1];
|
|
516
|
+
return {
|
|
517
|
+
base: pick("CLAUDE_MEM_MODEL") ?? FALLBACK_ENGINE_OBSERVER_DEFAULTS.base,
|
|
518
|
+
simple: pick("CLAUDE_MEM_TIER_SIMPLE_MODEL") ?? FALLBACK_ENGINE_OBSERVER_DEFAULTS.simple,
|
|
519
|
+
summary: pick("CLAUDE_MEM_TIER_SUMMARY_MODEL") ?? FALLBACK_ENGINE_OBSERVER_DEFAULTS.summary,
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
catch {
|
|
523
|
+
return FALLBACK_ENGINE_OBSERVER_DEFAULTS;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
/** Parses a settings.json (flat schema) into a string map; `{}` when absent or malformed. */
|
|
527
|
+
function readFlatSettings(settingsPath) {
|
|
528
|
+
try {
|
|
529
|
+
return JSON.parse(fs.readFileSync(settingsPath, "utf8"));
|
|
530
|
+
}
|
|
531
|
+
catch {
|
|
532
|
+
return {};
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Effective observer model per tier: the engine defaults with the owner's settings.json layered
|
|
537
|
+
* on top, and the summary tier's empty-default resolved to the base model (the engine's own
|
|
538
|
+
* "empty = use default model for summaries" rule) so the printed value is what actually runs.
|
|
539
|
+
*
|
|
540
|
+
* Args:
|
|
541
|
+
* settingsPath: Path of the data dir's settings.json (absent or malformed = engine defaults).
|
|
542
|
+
*
|
|
543
|
+
* Returns:
|
|
544
|
+
* Base, simple and summary model ids.
|
|
545
|
+
*/
|
|
546
|
+
export function effectiveObserverModels(settingsPath) {
|
|
547
|
+
const defaults = engineDefaultObserverModels();
|
|
548
|
+
const file = readFlatSettings(settingsPath);
|
|
549
|
+
const str = (v, fallback) => (typeof v === "string" && v.length > 0 ? v : fallback);
|
|
550
|
+
const base = str(file.CLAUDE_MEM_MODEL, defaults.base);
|
|
551
|
+
const simple = str(file.CLAUDE_MEM_TIER_SIMPLE_MODEL, defaults.simple);
|
|
552
|
+
const rawSummary = typeof file.CLAUDE_MEM_TIER_SUMMARY_MODEL === "string" ? file.CLAUDE_MEM_TIER_SUMMARY_MODEL : defaults.summary;
|
|
553
|
+
const summary = rawSummary.length > 0 ? rawSummary : base;
|
|
554
|
+
return { base, simple, summary };
|
|
555
|
+
}
|
|
556
|
+
/** Engine fallbacks for an absent local key (LocalProvider.ts's own FALLBACK_BASE_URL / FALLBACK_MODEL). */
|
|
557
|
+
const ENGINE_LOCAL_BASE_URL_DEFAULT = "http://127.0.0.1:8080/v1";
|
|
558
|
+
const ENGINE_LOCAL_MODEL_DEFAULT = "local-model";
|
|
559
|
+
/**
|
|
560
|
+
* Effective observer provider: `CLAUDE_MEM_PROVIDER` from settings.json (default `claude`; any
|
|
561
|
+
* value other than the literal `local` is treated as `claude`, mirroring the engine's own
|
|
562
|
+
* `SettingsDefaultsManager` fallback), and when local, the configured `CLAUDE_MEM_LOCAL_MODEL`
|
|
563
|
+
* or the engine's `local-model` default.
|
|
564
|
+
*
|
|
565
|
+
* Args:
|
|
566
|
+
* settingsPath: Path of the data dir's settings.json (absent or malformed = claude).
|
|
567
|
+
*
|
|
568
|
+
* Returns:
|
|
569
|
+
* The effective provider and, for local, its model id.
|
|
570
|
+
*/
|
|
571
|
+
export function effectiveObserverProvider(settingsPath) {
|
|
572
|
+
const values = readFlatSettings(settingsPath);
|
|
573
|
+
if (values.CLAUDE_MEM_PROVIDER !== "local")
|
|
574
|
+
return { provider: "claude", model: null };
|
|
575
|
+
const raw = values.CLAUDE_MEM_LOCAL_MODEL;
|
|
576
|
+
return { provider: "local", model: typeof raw === "string" && raw.length > 0 ? raw : ENGINE_LOCAL_MODEL_DEFAULT };
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* The FreeHarness-recommended observer settings: a haiku base and simple tier (the daily-spend
|
|
580
|
+
* gate), a sonnet summary tier (session summaries read by every future session, worth the
|
|
581
|
+
* larger model), the upstream skip list extended with the tools whose decisions matter for
|
|
582
|
+
* recall (Read, Glob, Grep and the multi-agent tools), and a concurrency cap of 2. Shared by
|
|
583
|
+
* `memory status`'s WARN check and the `memory settings` command.
|
|
584
|
+
*/
|
|
585
|
+
export const RECOMMENDED_OBSERVER_SETTINGS = {
|
|
586
|
+
CLAUDE_MEM_MODEL: "haiku",
|
|
587
|
+
CLAUDE_MEM_TIER_SIMPLE_MODEL: "haiku",
|
|
588
|
+
CLAUDE_MEM_TIER_SUMMARY_MODEL: "sonnet",
|
|
589
|
+
CLAUDE_MEM_SKIP_TOOLS: "ListMcpResourcesTool,SlashCommand,Skill,TodoWrite,AskUserQuestion,Read,Glob,Grep,ListAgents,SendMessage,Monitor,TaskOutput,ToolSearch,TaskStop",
|
|
590
|
+
CLAUDE_MEM_MAX_CONCURRENT_AGENTS: "2",
|
|
591
|
+
};
|
|
592
|
+
/** `memory settings --local`'s fixed defaults for the two tuning keys. */
|
|
593
|
+
const LOCAL_OBSERVER_SETTINGS_DEFAULTS = {
|
|
594
|
+
CLAUDE_MEM_LOCAL_MAX_PROMPT_TOKENS: "4000",
|
|
595
|
+
CLAUDE_MEM_LOCAL_TIMEOUT_MS: "90000",
|
|
596
|
+
};
|
|
597
|
+
/** The read-only tools the local provider itself observes once it is on. */
|
|
598
|
+
const LOCAL_PROVIDER_SERVED_TOOLS = ["Read", "Glob", "Grep"];
|
|
599
|
+
/**
|
|
600
|
+
* Derives `memory settings --local`'s `CLAUDE_MEM_SKIP_TOOLS` value from the recommended
|
|
601
|
+
* default with `Read`, `Glob` and `Grep` removed, order kept: those three cost subscription
|
|
602
|
+
* tokens for little signal at ingest, which is why the recommended default skips them, but the
|
|
603
|
+
* local provider observes them at zero subscription cost, so the skip list follows the provider.
|
|
604
|
+
* Deriving from the default at call time, rather than a second hand-maintained literal, means a
|
|
605
|
+
* later change to the default carries over here without an edit.
|
|
606
|
+
*
|
|
607
|
+
* Returns:
|
|
608
|
+
* The recommended default's tool list minus the three served tools, comma-joined.
|
|
609
|
+
*/
|
|
610
|
+
function localProviderSkipTools() {
|
|
611
|
+
return RECOMMENDED_OBSERVER_SETTINGS.CLAUDE_MEM_SKIP_TOOLS.split(",")
|
|
612
|
+
.filter((tool) => !LOCAL_PROVIDER_SERVED_TOOLS.includes(tool))
|
|
613
|
+
.join(",");
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* `memory settings --no-local`: the two keys that return the observer to the Claude provider —
|
|
617
|
+
* `CLAUDE_MEM_PROVIDER` and `CLAUDE_MEM_SKIP_TOOLS` restored to the recommended default, so the
|
|
618
|
+
* skip list follows the provider back.
|
|
619
|
+
*/
|
|
620
|
+
export const NO_LOCAL_OBSERVER_SETTINGS = {
|
|
621
|
+
CLAUDE_MEM_PROVIDER: "claude",
|
|
622
|
+
CLAUDE_MEM_SKIP_TOOLS: RECOMMENDED_OBSERVER_SETTINGS.CLAUDE_MEM_SKIP_TOOLS,
|
|
623
|
+
};
|
|
624
|
+
/**
|
|
625
|
+
* Builds the six `CLAUDE_MEM_LOCAL_*` / `CLAUDE_MEM_PROVIDER` / `CLAUDE_MEM_SKIP_TOOLS` keys for
|
|
626
|
+
* `memory settings --local`.
|
|
627
|
+
*
|
|
628
|
+
* Args:
|
|
629
|
+
* baseUrl: Candidate `CLAUDE_MEM_LOCAL_BASE_URL` value.
|
|
630
|
+
* model: Candidate `CLAUDE_MEM_LOCAL_MODEL` value.
|
|
631
|
+
*
|
|
632
|
+
* Returns:
|
|
633
|
+
* The six local-provider keys.
|
|
634
|
+
*
|
|
635
|
+
* Raises:
|
|
636
|
+
* Error: The base URL does not parse as an `http:`/`https:` URL, or the model id is empty or
|
|
637
|
+
* matches `isClaudeModelId` (a likely misconfiguration, not a local model).
|
|
638
|
+
*/
|
|
639
|
+
function localObserverSettings(baseUrl, model) {
|
|
640
|
+
let parsed;
|
|
641
|
+
try {
|
|
642
|
+
parsed = new URL(baseUrl);
|
|
643
|
+
}
|
|
644
|
+
catch {
|
|
645
|
+
throw new Error(`FAIL invalid --local base URL: ${baseUrl}`);
|
|
646
|
+
}
|
|
647
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
648
|
+
throw new Error(`FAIL invalid --local base URL: ${baseUrl}`);
|
|
649
|
+
}
|
|
650
|
+
if (!model || isClaudeModelId(model)) {
|
|
651
|
+
throw new Error(`FAIL invalid --local model id: ${model}`);
|
|
652
|
+
}
|
|
653
|
+
return {
|
|
654
|
+
CLAUDE_MEM_PROVIDER: "local",
|
|
655
|
+
CLAUDE_MEM_LOCAL_BASE_URL: baseUrl,
|
|
656
|
+
CLAUDE_MEM_LOCAL_MODEL: model,
|
|
657
|
+
...LOCAL_OBSERVER_SETTINGS_DEFAULTS,
|
|
658
|
+
CLAUDE_MEM_SKIP_TOOLS: localProviderSkipTools(),
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Resolves the key set `memory settings` previews or writes: the local set (`CLAUDE_MEM_PROVIDER`
|
|
663
|
+
* plus four `CLAUDE_MEM_LOCAL_*` keys plus the narrowed `CLAUDE_MEM_SKIP_TOOLS`), the two no-local
|
|
664
|
+
* keys, or the five recommended keys when neither `--local` nor `--no-local` is given.
|
|
665
|
+
*
|
|
666
|
+
* Args:
|
|
667
|
+
* opts: The command's `--local`/`--no-local` request, if any.
|
|
668
|
+
*
|
|
669
|
+
* Returns:
|
|
670
|
+
* The key set to preview or write.
|
|
671
|
+
*
|
|
672
|
+
* Raises:
|
|
673
|
+
* Error: `--local` and `--no-local` given together, or `--local`'s values fail validation.
|
|
674
|
+
*/
|
|
675
|
+
function resolveObserverKeySet(opts) {
|
|
676
|
+
if (opts?.local && opts.noLocal) {
|
|
677
|
+
throw new Error("FAIL --local and --no-local cannot be combined");
|
|
678
|
+
}
|
|
679
|
+
if (opts?.local)
|
|
680
|
+
return localObserverSettings(opts.local.baseUrl, opts.local.model);
|
|
681
|
+
if (opts?.noLocal)
|
|
682
|
+
return NO_LOCAL_OBSERVER_SETTINGS;
|
|
683
|
+
return RECOMMENDED_OBSERVER_SETTINGS;
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* Previews `memory settings`: the selected key set against the current file, same or
|
|
687
|
+
* would-change, writing nothing. Plain (no options): the five recommended keys. `--local`:
|
|
688
|
+
* `CLAUDE_MEM_PROVIDER` plus the four `CLAUDE_MEM_LOCAL_*` keys plus the narrowed
|
|
689
|
+
* `CLAUDE_MEM_SKIP_TOOLS`. `--no-local`: `CLAUDE_MEM_PROVIDER` and `CLAUDE_MEM_SKIP_TOOLS`
|
|
690
|
+
* restored to the recommended default.
|
|
691
|
+
*
|
|
692
|
+
* Args:
|
|
693
|
+
* settingsPath: Path of the data dir's settings.json.
|
|
694
|
+
* opts: The command's `--local`/`--no-local` request, if any.
|
|
695
|
+
*
|
|
696
|
+
* Returns:
|
|
697
|
+
* One row per key in the selected set, and the parse error when the file is present but
|
|
698
|
+
* invalid.
|
|
699
|
+
*
|
|
700
|
+
* Raises:
|
|
701
|
+
* Error: `opts` fails validation (see `resolveObserverKeySet`); nothing is read or written.
|
|
702
|
+
*/
|
|
703
|
+
export function previewObserverSettings(settingsPath, opts) {
|
|
704
|
+
const keySet = resolveObserverKeySet(opts);
|
|
705
|
+
let values = {};
|
|
706
|
+
let parseError;
|
|
707
|
+
if (fs.existsSync(settingsPath)) {
|
|
708
|
+
try {
|
|
709
|
+
values = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
|
|
710
|
+
}
|
|
711
|
+
catch (e) {
|
|
712
|
+
parseError = e instanceof Error ? e.message : String(e);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
const rows = Object.entries(keySet).map(([key, recommended]) => {
|
|
716
|
+
const raw = values[key];
|
|
717
|
+
const current = raw === undefined ? undefined : typeof raw === "string" ? raw : JSON.stringify(raw);
|
|
718
|
+
return { key, current, recommended, changed: current !== recommended };
|
|
719
|
+
});
|
|
720
|
+
return { rows, parseError };
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* Applies `memory settings`: adds or updates the selected key set (see `previewObserverSettings`
|
|
724
|
+
* for which set), preserving every other key, after backing up an existing file first. Refuses
|
|
725
|
+
* (throws) without writing anything when `opts` fails validation or the existing file is not
|
|
726
|
+
* valid JSON.
|
|
727
|
+
*
|
|
728
|
+
* Args:
|
|
729
|
+
* settingsPath: Path of the data dir's settings.json.
|
|
730
|
+
* opts: The command's `--local`/`--no-local` request, if any.
|
|
731
|
+
*
|
|
732
|
+
* Returns:
|
|
733
|
+
* Whether the file was created, and the backup path when one was made.
|
|
734
|
+
*
|
|
735
|
+
* Raises:
|
|
736
|
+
* Error: `opts` fails validation, or the existing file is not valid JSON (its message names
|
|
737
|
+
* the parse error).
|
|
738
|
+
*/
|
|
739
|
+
export function applyObserverSettings(settingsPath, opts) {
|
|
740
|
+
const keySet = resolveObserverKeySet(opts);
|
|
741
|
+
let values = {};
|
|
742
|
+
let created = false;
|
|
743
|
+
let backupPath;
|
|
744
|
+
if (fs.existsSync(settingsPath)) {
|
|
745
|
+
const raw = fs.readFileSync(settingsPath, "utf8");
|
|
746
|
+
try {
|
|
747
|
+
values = JSON.parse(raw);
|
|
748
|
+
}
|
|
749
|
+
catch (e) {
|
|
750
|
+
throw new Error(`refusing to write ${settingsPath}: not valid JSON (${e instanceof Error ? e.message : String(e)})`);
|
|
751
|
+
}
|
|
752
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
753
|
+
backupPath = `${settingsPath}.${stamp}.bak`;
|
|
754
|
+
fs.copyFileSync(settingsPath, backupPath);
|
|
755
|
+
}
|
|
756
|
+
else {
|
|
757
|
+
created = true;
|
|
758
|
+
}
|
|
759
|
+
for (const [key, value] of Object.entries(keySet)) {
|
|
760
|
+
values[key] = value;
|
|
761
|
+
}
|
|
762
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
763
|
+
fs.writeFileSync(settingsPath, JSON.stringify(values, null, 2) + "\n");
|
|
764
|
+
return { created, backupPath };
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* Short text for a probe request's failure (the cause's code when present, e.g. ECONNREFUSED).
|
|
768
|
+
*
|
|
769
|
+
* Args:
|
|
770
|
+
* e: Thrown value.
|
|
771
|
+
* timeoutMs: Timeout used, for the abort message.
|
|
772
|
+
*
|
|
773
|
+
* Returns:
|
|
774
|
+
* Text.
|
|
775
|
+
*/
|
|
776
|
+
function describeProbeError(e, timeoutMs) {
|
|
777
|
+
if (e instanceof Error) {
|
|
778
|
+
if (e.name === "AbortError")
|
|
779
|
+
return `timeout after ${timeoutMs} ms`;
|
|
780
|
+
const cause = e.cause;
|
|
781
|
+
return cause?.code ?? cause?.message ?? e.message;
|
|
782
|
+
}
|
|
783
|
+
return String(e);
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* GETs a URL under a caller-owned `AbortSignal`, parsing a JSON body when present.
|
|
787
|
+
*
|
|
788
|
+
* Args:
|
|
789
|
+
* url: URL.
|
|
790
|
+
* signal: Abort signal shared across every request of one probe call, so the probe's total
|
|
791
|
+
* time is bounded by one deadline rather than one timeout per request.
|
|
792
|
+
* timeoutMs: The probe's configured timeout, quoted in the abort message only.
|
|
793
|
+
*
|
|
794
|
+
* Returns:
|
|
795
|
+
* The status and parsed body, or a connection-error reason.
|
|
796
|
+
*/
|
|
797
|
+
async function getWithSignal(url, signal, timeoutMs) {
|
|
798
|
+
try {
|
|
799
|
+
const r = await fetch(url, { signal, redirect: "error" });
|
|
800
|
+
let body;
|
|
801
|
+
try {
|
|
802
|
+
body = await r.json();
|
|
803
|
+
}
|
|
804
|
+
catch {
|
|
805
|
+
body = undefined;
|
|
806
|
+
}
|
|
807
|
+
return { status: r.status, body };
|
|
808
|
+
}
|
|
809
|
+
catch (e) {
|
|
810
|
+
return { error: describeProbeError(e, timeoutMs) };
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
/**
|
|
814
|
+
* Splits a `CLAUDE_MEM_LOCAL_BASE_URL` into its health root (no `/v1`) and models base (with
|
|
815
|
+
* `/v1`), accepting the configured value with or without the `/v1` suffix.
|
|
816
|
+
*
|
|
817
|
+
* Args:
|
|
818
|
+
* baseUrl: The configured base URL.
|
|
819
|
+
*
|
|
820
|
+
* Returns:
|
|
821
|
+
* The health root and the models base.
|
|
822
|
+
*/
|
|
823
|
+
function providerUrls(baseUrl) {
|
|
824
|
+
const trimmed = baseUrl.replace(/\/+$/, "");
|
|
825
|
+
return trimmed.endsWith("/v1") ? { root: trimmed.slice(0, -"/v1".length), base: trimmed } : { root: trimmed, base: `${trimmed}/v1` };
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* Probes a local memory-provider server: `<root>/health` (200 expected), then `<base>/models`
|
|
829
|
+
* (the configured model id expected in `data[].id`), both requests bound by one
|
|
830
|
+
* `AbortController` armed once for `timeoutMs` — so a slow-but-live server cannot push the
|
|
831
|
+
* total probe time past the configured deadline by stacking a full timeout on each request.
|
|
832
|
+
* Down is a one-line reason, never a throw.
|
|
833
|
+
*
|
|
834
|
+
* Args:
|
|
835
|
+
* baseUrl: `CLAUDE_MEM_LOCAL_BASE_URL`.
|
|
836
|
+
* model: `CLAUDE_MEM_LOCAL_MODEL`.
|
|
837
|
+
* timeoutMs: The whole probe's deadline (both requests together).
|
|
838
|
+
*
|
|
839
|
+
* Returns:
|
|
840
|
+
* ok, or ok:false with a one-line reason.
|
|
841
|
+
*/
|
|
842
|
+
export async function probeMemoryProvider(baseUrl, model, timeoutMs = 3000) {
|
|
843
|
+
const { root, base } = providerUrls(baseUrl);
|
|
844
|
+
const ctl = new AbortController();
|
|
845
|
+
const timer = setTimeout(() => ctl.abort(), timeoutMs);
|
|
846
|
+
try {
|
|
847
|
+
const health = await getWithSignal(`${root}/health`, ctl.signal, timeoutMs);
|
|
848
|
+
if ("error" in health)
|
|
849
|
+
return { ok: false, reason: health.error };
|
|
850
|
+
if (health.status !== 200)
|
|
851
|
+
return { ok: false, reason: `health ${health.status}` };
|
|
852
|
+
const models = await getWithSignal(`${base}/models`, ctl.signal, timeoutMs);
|
|
853
|
+
if ("error" in models)
|
|
854
|
+
return { ok: false, reason: models.error };
|
|
855
|
+
const data = models.body?.data;
|
|
856
|
+
const ids = Array.isArray(data) ? data.map((d) => (typeof d.id === "string" ? d.id : undefined)).filter((id) => id !== undefined) : [];
|
|
857
|
+
if (!ids.includes(model))
|
|
858
|
+
return { ok: false, reason: `model ${model} not listed` };
|
|
859
|
+
return { ok: true };
|
|
860
|
+
}
|
|
861
|
+
finally {
|
|
862
|
+
clearTimeout(timer);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
/**
|
|
866
|
+
* The `memory provider` doctor line: only when the data dir's settings.json sets
|
|
867
|
+
* `CLAUDE_MEM_PROVIDER=local`, a probe of the configured base URL and model, OK or WARN with
|
|
868
|
+
* the reason; absent or `claude` prints nothing.
|
|
869
|
+
*
|
|
870
|
+
* Args:
|
|
871
|
+
* settingsPath: Path of the data dir's settings.json.
|
|
872
|
+
* probe: Injectable probe function.
|
|
873
|
+
*
|
|
874
|
+
* Returns:
|
|
875
|
+
* Zero or one printable line.
|
|
876
|
+
*/
|
|
877
|
+
export async function memoryProviderLines(settingsPath, probe = probeMemoryProvider) {
|
|
878
|
+
const values = readFlatSettings(settingsPath);
|
|
879
|
+
if (values.CLAUDE_MEM_PROVIDER !== "local")
|
|
880
|
+
return [];
|
|
881
|
+
// The worker applies these same defaults for an absent key, so probing "" would report a
|
|
882
|
+
// URL-parse failure for a server the observer is in fact reaching.
|
|
883
|
+
const baseUrl = typeof values.CLAUDE_MEM_LOCAL_BASE_URL === "string" && values.CLAUDE_MEM_LOCAL_BASE_URL.trim() !== ""
|
|
884
|
+
? values.CLAUDE_MEM_LOCAL_BASE_URL
|
|
885
|
+
: ENGINE_LOCAL_BASE_URL_DEFAULT;
|
|
886
|
+
const model = typeof values.CLAUDE_MEM_LOCAL_MODEL === "string" && values.CLAUDE_MEM_LOCAL_MODEL.trim() !== ""
|
|
887
|
+
? values.CLAUDE_MEM_LOCAL_MODEL
|
|
888
|
+
: ENGINE_LOCAL_MODEL_DEFAULT;
|
|
889
|
+
const r = await probe(baseUrl, model);
|
|
890
|
+
return [`${r.ok ? "OK " : "WARN"} memory provider: ${baseUrl} ${model} ${r.ok ? "OK" : r.reason}`];
|
|
891
|
+
}
|
|
892
|
+
/** Resolve $CLAUDE_CONFIG_DIR or ~/.claude. */
|
|
893
|
+
export function configDir() {
|
|
894
|
+
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
|
|
895
|
+
}
|
|
896
|
+
/** Mirror engine shared/paths.ts resolveDataDir (env, then settings.json, then default). */
|
|
897
|
+
export function dataDir() {
|
|
898
|
+
const expand = (p) => (p.startsWith("~/") ? path.join(os.homedir(), p.slice(2)) : p);
|
|
899
|
+
if (process.env.CLAUDE_MEM_DATA_DIR)
|
|
900
|
+
return expand(process.env.CLAUDE_MEM_DATA_DIR);
|
|
901
|
+
const def = path.join(os.homedir(), ".claude-mem");
|
|
902
|
+
try {
|
|
903
|
+
const raw = JSON.parse(fs.readFileSync(path.join(def, "settings.json"), "utf8"));
|
|
904
|
+
const s = raw.env ?? raw;
|
|
905
|
+
if (typeof s.CLAUDE_MEM_DATA_DIR === "string")
|
|
906
|
+
return expand(s.CLAUDE_MEM_DATA_DIR);
|
|
907
|
+
}
|
|
908
|
+
catch {
|
|
909
|
+
/* default */
|
|
910
|
+
}
|
|
911
|
+
return def;
|
|
912
|
+
}
|
|
913
|
+
/** Mirror engine SettingsDefaultsManager: CLAUDE_MEM_WORKER_PORT or 37700 + uid % 100. */
|
|
914
|
+
function workerPort(dir) {
|
|
915
|
+
try {
|
|
916
|
+
const raw = JSON.parse(fs.readFileSync(path.join(dir, "settings.json"), "utf8"));
|
|
917
|
+
const s = raw.env ?? raw;
|
|
918
|
+
const p = parseInt(String(s.CLAUDE_MEM_WORKER_PORT ?? ""), 10);
|
|
919
|
+
if (Number.isFinite(p) && p > 0)
|
|
920
|
+
return p;
|
|
921
|
+
}
|
|
922
|
+
catch {
|
|
923
|
+
/* default */
|
|
924
|
+
}
|
|
925
|
+
return 37700 + ((process.getuid?.() ?? 77) % 100);
|
|
926
|
+
}
|
|
927
|
+
async function probeWorker(port) {
|
|
928
|
+
const base = `http://127.0.0.1:${port}`;
|
|
929
|
+
const get = async (p) => {
|
|
930
|
+
try {
|
|
931
|
+
return await fetch(base + p, { signal: AbortSignal.timeout(1500) });
|
|
932
|
+
}
|
|
933
|
+
catch {
|
|
934
|
+
return null;
|
|
935
|
+
}
|
|
936
|
+
};
|
|
937
|
+
const health = await get("/api/health");
|
|
938
|
+
if (!health)
|
|
939
|
+
return { port, reachable: false };
|
|
940
|
+
let version;
|
|
941
|
+
try {
|
|
942
|
+
version = String((await health.json()).version ?? "");
|
|
943
|
+
}
|
|
944
|
+
catch {
|
|
945
|
+
/* non-JSON */
|
|
946
|
+
}
|
|
947
|
+
const root = await get("/");
|
|
948
|
+
let body = "";
|
|
949
|
+
try {
|
|
950
|
+
body = root ? await root.text() : "";
|
|
951
|
+
}
|
|
952
|
+
catch {
|
|
953
|
+
/* ignore */
|
|
954
|
+
}
|
|
955
|
+
const viewer = root?.status === 200;
|
|
956
|
+
const ours = root ? root.status === 404 || body.includes(VIEWER_MARKER) : undefined;
|
|
957
|
+
return { port, reachable: true, version, viewer, ours };
|
|
958
|
+
}
|
|
959
|
+
/**
|
|
960
|
+
* Lists cache versions under one plugin-cache root.
|
|
961
|
+
* @param root The `<cfg>/plugins/cache/<owner>/<name>` directory.
|
|
962
|
+
* @param legacy Whether `root` is the upstream (thedotmack/claude-mem) path.
|
|
963
|
+
* @returns One entry per version directory; empty when `root` is absent.
|
|
964
|
+
*/
|
|
965
|
+
function listCacheRoot(root, legacy) {
|
|
966
|
+
if (!fs.existsSync(root))
|
|
967
|
+
return [];
|
|
968
|
+
return fs
|
|
969
|
+
.readdirSync(root, { withFileTypes: true })
|
|
970
|
+
.filter((e) => e.isDirectory() && /^\d/.test(e.name))
|
|
971
|
+
.map((e) => {
|
|
972
|
+
const dir = path.join(root, e.name);
|
|
973
|
+
const skillsDir = path.join(dir, "skills");
|
|
974
|
+
const skills = fs.existsSync(skillsDir) ? fs.readdirSync(skillsDir).filter((s) => !s.startsWith(".")) : [];
|
|
975
|
+
return { version: e.name, dir, ours: fs.existsSync(path.join(dir, ".install-version")), skills, legacy };
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
/**
|
|
979
|
+
* Lists every plugin-cache version doctor knows to look for: our native cache
|
|
980
|
+
* (`<cfg>/plugins/cache/freeharness/free-mem`) and the upstream cache
|
|
981
|
+
* (`<cfg>/plugins/cache/thedotmack/claude-mem`), which after a migration holds either a
|
|
982
|
+
* previous FreeHarness build (pre-rename) or a genuine upstream install.
|
|
983
|
+
* @param cfg Claude config dir.
|
|
984
|
+
* @returns Native entries first, then legacy entries.
|
|
985
|
+
*/
|
|
986
|
+
function listCache(cfg) {
|
|
987
|
+
const nativeRoot = path.join(cfg, "plugins", "cache", MARKETPLACE_OWNER, PLUGIN_NAME);
|
|
988
|
+
const legacyRoot = path.join(cfg, "plugins", "cache", UPSTREAM_MARKETPLACE_OWNER, UPSTREAM_PLUGIN_NAME);
|
|
989
|
+
return [...listCacheRoot(nativeRoot, false), ...listCacheRoot(legacyRoot, true)];
|
|
990
|
+
}
|
|
991
|
+
/**
|
|
992
|
+
* Human-readable tag for one plugin-cache entry in the `doctor` cache table.
|
|
993
|
+
* @param c The cache entry.
|
|
994
|
+
* @returns One of: a native FreeHarness build, a legacy (pre-rename) FreeHarness build or
|
|
995
|
+
* upstream install, or a foreign entry with no `.install-version` marker.
|
|
996
|
+
*/
|
|
997
|
+
function cacheTag(c) {
|
|
998
|
+
if (!c.ours)
|
|
999
|
+
return "UPSTREAM/foreign (no .install-version)";
|
|
1000
|
+
if (c.legacy)
|
|
1001
|
+
return "previous FreeHarness build or upstream (legacy thedotmack/claude-mem cache)";
|
|
1002
|
+
return "FreeHarness build";
|
|
1003
|
+
}
|
|
1004
|
+
/**
|
|
1005
|
+
* Diagnose which claude-mem is active.
|
|
1006
|
+
*
|
|
1007
|
+
* Returns:
|
|
1008
|
+
* A MemoryDiagnosis with a verdict and printable lines (OK/WARN prefixed).
|
|
1009
|
+
*/
|
|
1010
|
+
export async function diagnoseMemory() {
|
|
1011
|
+
const cfg = configDir();
|
|
1012
|
+
const data = dataDir();
|
|
1013
|
+
const dbPath = path.join(data, "claude-mem.db");
|
|
1014
|
+
const dbBytes = fs.existsSync(dbPath) ? fs.statSync(dbPath).size : 0;
|
|
1015
|
+
const chromaPresent = fs.existsSync(path.join(data, "chroma"));
|
|
1016
|
+
const cache = listCache(cfg);
|
|
1017
|
+
const lines = [];
|
|
1018
|
+
let activeInstallPath;
|
|
1019
|
+
let activeVersion;
|
|
1020
|
+
try {
|
|
1021
|
+
const ip = JSON.parse(fs.readFileSync(path.join(cfg, "plugins", "installed_plugins.json"), "utf8"));
|
|
1022
|
+
const entry = ip.plugins?.[PLUGIN_KEY]?.[0];
|
|
1023
|
+
activeInstallPath = entry?.installPath;
|
|
1024
|
+
activeVersion = entry?.version;
|
|
1025
|
+
}
|
|
1026
|
+
catch {
|
|
1027
|
+
/* not registered */
|
|
1028
|
+
}
|
|
1029
|
+
const activeIsOurs = activeInstallPath ? fs.existsSync(path.join(activeInstallPath, ".install-version")) : undefined;
|
|
1030
|
+
const worker = await probeWorker(workerPort(data));
|
|
1031
|
+
const observer = readObserverStatus(data);
|
|
1032
|
+
for (const c of cache) {
|
|
1033
|
+
lines.push(`${c.ours && !c.legacy ? "OK " : "WARN"} plugin cache ${c.version}: ${cacheTag(c)}; skills: ${c.skills.join(", ") || "(none)"}`);
|
|
1034
|
+
}
|
|
1035
|
+
if (cache.length === 0)
|
|
1036
|
+
lines.push(`WARN no ${PLUGIN_NAME} plugin in the cache (run install.sh --with-memory)`);
|
|
1037
|
+
if (activeInstallPath) {
|
|
1038
|
+
lines.push(`${activeIsOurs ? "OK " : "WARN"} ${PLUGIN_KEY} registered -> v${activeVersion} at ${activeInstallPath} (${activeIsOurs ? "ours" : "NOT ours"})`);
|
|
1039
|
+
}
|
|
1040
|
+
else {
|
|
1041
|
+
lines.push(`WARN ${PLUGIN_KEY} not registered in installed_plugins.json`);
|
|
1042
|
+
}
|
|
1043
|
+
if (!worker.reachable) {
|
|
1044
|
+
lines.push(`OK no worker listening on :${worker.port} (next SessionStart starts the registered plugin's worker)`);
|
|
1045
|
+
}
|
|
1046
|
+
else {
|
|
1047
|
+
const isOurs = worker.ours === true;
|
|
1048
|
+
lines.push(`${isOurs ? "OK " : "WARN"} worker on :${worker.port} version ${worker.version ?? "?"} — ${isOurs ? `FreeHarness build (viewer: http://localhost:${worker.port}/)` : "serves the React viewer at / => UPSTREAM daemon still running"}`);
|
|
1049
|
+
// A worker outlives the plugin upgrade that replaced its files: it keeps serving the old
|
|
1050
|
+
// version until restarted. The verdict is unaffected (both are ours when both are ours).
|
|
1051
|
+
if (worker.version && activeVersion && worker.version !== activeVersion) {
|
|
1052
|
+
lines.push(`WARN worker on :${worker.port} runs ${worker.version} but the registered plugin is ${activeVersion} — run \`freeharness memory restart\``);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
lines.push(...observerLines(observer));
|
|
1056
|
+
const now = Date.now();
|
|
1057
|
+
if (isObserverVerdictStale(observer.health, now)) {
|
|
1058
|
+
const at = new Date(observer.health.lastErrorAt).toISOString();
|
|
1059
|
+
lines.push(`INFO observer verdict stale: last failure ${at} (${describeDuration(now - observer.health.lastErrorAt)} ago), no warning injected`);
|
|
1060
|
+
}
|
|
1061
|
+
const models = effectiveObserverModels(path.join(data, "settings.json"));
|
|
1062
|
+
const baseIsHaiku = isHaikuAlias(models.base);
|
|
1063
|
+
const provider = effectiveObserverProvider(path.join(data, "settings.json"));
|
|
1064
|
+
const providerSuffix = provider.provider === "local" ? ` provider=local ${provider.model}` : " provider=claude";
|
|
1065
|
+
lines.push(`${baseIsHaiku ? "OK " : "WARN"} observer model base=${models.base} simple=${models.simple} summary=${models.summary}${providerSuffix}${baseIsHaiku ? "" : " — not a haiku alias; run `freeharness memory settings`"}`);
|
|
1066
|
+
lines.push(...(await memoryProviderLines(path.join(data, "settings.json"))));
|
|
1067
|
+
lines.push(...spendLines(observerSpend(dbPath)));
|
|
1068
|
+
const localLogSummary = readLocalProviderLog(path.join(data, "local-provider.jsonl"), startOfUtcDayMs(now), now);
|
|
1069
|
+
// Only a machine with a batch log has locally answered rows to count; a Claude-only
|
|
1070
|
+
// install would pay one sqlite3 spawn per status run for a guaranteed pair of zeros.
|
|
1071
|
+
const duplicateStats = localLogSummary && !("reason" in localLogSummary)
|
|
1072
|
+
? observerDuplicateRate(dbPath)
|
|
1073
|
+
: { today: { observations: 0, duplicates: 0 }, yesterday: { observations: 0, duplicates: 0 } };
|
|
1074
|
+
lines.push(...localProviderLines(localLogSummary, duplicateStats));
|
|
1075
|
+
lines.push(`INFO data dir ${data}: claude-mem.db ${(dbBytes / 1048576).toFixed(1)} MB${chromaPresent ? " + chroma/" : ""} (shared by both builds; back up before switching)`);
|
|
1076
|
+
const schema = dbSchemaVersion(dbPath);
|
|
1077
|
+
if (schema !== null) {
|
|
1078
|
+
lines.push(schema <= OUR_SCHEMA_VERSION
|
|
1079
|
+
? `OK claude-mem.db schema v${schema} (our engine knows up to v${OUR_SCHEMA_VERSION})`
|
|
1080
|
+
: schema === 50
|
|
1081
|
+
? `OK claude-mem.db schema v50 (upstream 13.2x; v50 only adds nullable sdk_sessions columns our engine v${OUR_SCHEMA_VERSION} ignores)`
|
|
1082
|
+
: `WARN claude-mem.db schema v${schema} is NEWER than our engine (v${OUR_SCHEMA_VERSION}) — migrated by an upstream build; migrations are additive so it usually works, but keep the backup and watch the first worker start`);
|
|
1083
|
+
}
|
|
1084
|
+
const foreign = cache.some((c) => !c.ours);
|
|
1085
|
+
const upstreamWorker = worker.reachable && worker.ours !== true;
|
|
1086
|
+
let verdict = "none";
|
|
1087
|
+
if (cache.length > 0) {
|
|
1088
|
+
if (activeIsOurs && !foreign && !upstreamWorker)
|
|
1089
|
+
verdict = "ours";
|
|
1090
|
+
else if (activeIsOurs === false && !cache.some((c) => c.ours))
|
|
1091
|
+
verdict = "upstream";
|
|
1092
|
+
else
|
|
1093
|
+
verdict = "mixed";
|
|
1094
|
+
}
|
|
1095
|
+
lines.push(verdict === "ours"
|
|
1096
|
+
? "OK VERDICT: the FreeHarness build is the only active memory plugin"
|
|
1097
|
+
: verdict === "none"
|
|
1098
|
+
? "WARN VERDICT: memory plugin not installed"
|
|
1099
|
+
: `WARN VERDICT: ${verdict === "upstream" ? "the UPSTREAM install is active" : "MIXED — upstream pieces still active beside ours"} (run: freeharness memory backup && freeharness memory activate)`);
|
|
1100
|
+
return { configDir: cfg, dataDir: data, dbPath, dbBytes, chromaPresent, cache, activeInstallPath, activeVersion, activeIsOurs, worker, observer, verdict, lines };
|
|
1101
|
+
}
|
|
1102
|
+
/**
|
|
1103
|
+
* Copy the claude-mem data dir to a timestamped sibling (never moves the original).
|
|
1104
|
+
*
|
|
1105
|
+
* Returns:
|
|
1106
|
+
* The backup directory path.
|
|
1107
|
+
*/
|
|
1108
|
+
export function backupMemoryData() {
|
|
1109
|
+
const src = dataDir();
|
|
1110
|
+
if (!fs.existsSync(src))
|
|
1111
|
+
throw new Error(`data dir not found: ${src}`);
|
|
1112
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
1113
|
+
const dest = `${src}-backup-${stamp}`;
|
|
1114
|
+
fs.cpSync(src, dest, { recursive: true, filter: (p) => !/\/(logs|\.pid|.*\.lock)$/.test(p) });
|
|
1115
|
+
return dest;
|
|
1116
|
+
}
|
|
1117
|
+
/**
|
|
1118
|
+
* Restart the claude-mem worker of the registered FreeHarness build.
|
|
1119
|
+
*
|
|
1120
|
+
* Prefers the plugin's own launcher (`node <install>/scripts/bun-runner.js
|
|
1121
|
+
* <install>/scripts/worker-service.cjs restart`, detached) so the new worker is the
|
|
1122
|
+
* registered version; without a registered FreeHarness install it only asks a running
|
|
1123
|
+
* worker to shut down (the next SessionStart hook respawns it). The observer ledgers are
|
|
1124
|
+
* left alone: a restart does not clear an armed quota cooldown (that is by design in the
|
|
1125
|
+
* engine; the breaker is persisted so restarts cannot reopen a request storm).
|
|
1126
|
+
*
|
|
1127
|
+
* Args:
|
|
1128
|
+
* dryRun: Report only.
|
|
1129
|
+
* diagnosis: A diagnosis already computed by the caller (a fresh one otherwise).
|
|
1130
|
+
*
|
|
1131
|
+
* Returns:
|
|
1132
|
+
* What was (or would be) done.
|
|
1133
|
+
*/
|
|
1134
|
+
export async function restartWorker(dryRun, diagnosis) {
|
|
1135
|
+
const d = diagnosis ?? (await diagnoseMemory());
|
|
1136
|
+
const install = d.activeIsOurs && d.activeInstallPath ? d.activeInstallPath : undefined;
|
|
1137
|
+
const runner = install ? path.join(install, "scripts", "bun-runner.js") : undefined;
|
|
1138
|
+
const service = install ? path.join(install, "scripts", "worker-service.cjs") : undefined;
|
|
1139
|
+
if (runner && service && fs.existsSync(runner) && fs.existsSync(service)) {
|
|
1140
|
+
const detail = `node ${runner} ${service} restart`;
|
|
1141
|
+
if (!dryRun) {
|
|
1142
|
+
const child = spawn(process.execPath, [runner, service, "restart"], { detached: true, stdio: "ignore", env: { ...process.env } });
|
|
1143
|
+
child.unref();
|
|
1144
|
+
}
|
|
1145
|
+
return { method: "worker-service restart", detail };
|
|
1146
|
+
}
|
|
1147
|
+
if (d.worker.reachable) {
|
|
1148
|
+
const url = `http://127.0.0.1:${d.worker.port}/api/admin/shutdown`;
|
|
1149
|
+
if (!dryRun) {
|
|
1150
|
+
try {
|
|
1151
|
+
await fetch(url, { method: "POST", signal: AbortSignal.timeout(5000) });
|
|
1152
|
+
}
|
|
1153
|
+
catch {
|
|
1154
|
+
/* it may exit before answering */
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
return { method: "shutdown-only", detail: `POST ${url}; the next Claude Code SessionStart respawns the registered plugin's worker` };
|
|
1158
|
+
}
|
|
1159
|
+
return { method: "nothing", detail: `no worker on :${d.worker.port} and no registered FreeHarness build to launch (run install.sh --with-memory)` };
|
|
1160
|
+
}
|
|
1161
|
+
/**
|
|
1162
|
+
* Make the FreeHarness build the only active claude-mem: stop a running worker, move
|
|
1163
|
+
* foreign cache versions aside (never deleted), point installed_plugins at ours.
|
|
1164
|
+
*
|
|
1165
|
+
* Args:
|
|
1166
|
+
* dryRun: Report only.
|
|
1167
|
+
*
|
|
1168
|
+
* Returns:
|
|
1169
|
+
* What was (or would be) done.
|
|
1170
|
+
*/
|
|
1171
|
+
export async function activateOurs(dryRun) {
|
|
1172
|
+
const d = await diagnoseMemory();
|
|
1173
|
+
// A native-cache build always wins over a legacy (pre-rename) one, even at a lower version:
|
|
1174
|
+
// the native path is what the current vendored engine's constants resolve to.
|
|
1175
|
+
const ours = d.cache
|
|
1176
|
+
.filter((c) => c.ours)
|
|
1177
|
+
.sort((a, b) => Number(a.legacy) - Number(b.legacy) || b.version.localeCompare(a.version, undefined, { numeric: true }))[0];
|
|
1178
|
+
if (!ours)
|
|
1179
|
+
throw new Error("no FreeHarness build in the plugin cache — run install.sh --with-memory first");
|
|
1180
|
+
const res = { stoppedWorker: false, disabled: [], registered: false };
|
|
1181
|
+
if (d.worker.reachable && !dryRun) {
|
|
1182
|
+
try {
|
|
1183
|
+
await fetch(`http://127.0.0.1:${d.worker.port}/api/admin/shutdown`, { method: "POST", signal: AbortSignal.timeout(5000) });
|
|
1184
|
+
res.stoppedWorker = true;
|
|
1185
|
+
}
|
|
1186
|
+
catch {
|
|
1187
|
+
/* it may have exited before answering */
|
|
1188
|
+
res.stoppedWorker = true;
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
else if (d.worker.reachable)
|
|
1192
|
+
res.stoppedWorker = true;
|
|
1193
|
+
// Foreign cache versions are always found under the upstream root (see `listCache`): a
|
|
1194
|
+
// genuine upstream install registers itself there, and so does a pre-rename FreeHarness
|
|
1195
|
+
// build. Parking keeps that same root, only appending `.disabled`.
|
|
1196
|
+
const disabledRoot = path.join(d.configDir, "plugins", "cache", UPSTREAM_MARKETPLACE_OWNER, `${UPSTREAM_PLUGIN_NAME}.disabled`);
|
|
1197
|
+
for (const c of d.cache.filter((x) => !x.ours)) {
|
|
1198
|
+
const dest = path.join(disabledRoot, c.version);
|
|
1199
|
+
if (!dryRun) {
|
|
1200
|
+
fs.mkdirSync(disabledRoot, { recursive: true });
|
|
1201
|
+
if (fs.existsSync(dest))
|
|
1202
|
+
fs.rmSync(dest, { recursive: true, force: true });
|
|
1203
|
+
fs.renameSync(c.dir, dest);
|
|
1204
|
+
}
|
|
1205
|
+
res.disabled.push(`${c.dir} -> ${dest}`);
|
|
1206
|
+
}
|
|
1207
|
+
const ipPath = path.join(d.configDir, "plugins", "installed_plugins.json");
|
|
1208
|
+
if (!dryRun && fs.existsSync(ipPath)) {
|
|
1209
|
+
const ip = JSON.parse(fs.readFileSync(ipPath, "utf8"));
|
|
1210
|
+
const now = new Date().toISOString();
|
|
1211
|
+
ip.plugins = ip.plugins ?? {};
|
|
1212
|
+
ip.plugins[PLUGIN_KEY] = [{ scope: "user", installPath: ours.dir, version: ours.version, installedAt: now, lastUpdated: now }];
|
|
1213
|
+
fs.writeFileSync(ipPath, JSON.stringify(ip, null, 2) + "\n");
|
|
1214
|
+
}
|
|
1215
|
+
res.registered = true;
|
|
1216
|
+
return res;
|
|
1217
|
+
}
|