@event4u/agent-config 1.13.0 → 1.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agent-src/commands/agent-handoff.md +4 -1
- package/.agent-src/commands/agent-status.md +3 -0
- package/.agent-src/commands/agents-audit.md +4 -0
- package/.agent-src/commands/agents-cleanup.md +6 -1
- package/.agent-src/commands/agents-prepare.md +3 -0
- package/.agent-src/commands/analyze-reference-repo.md +4 -0
- package/.agent-src/commands/bug-fix.md +7 -3
- package/.agent-src/commands/bug-investigate.md +4 -0
- package/.agent-src/commands/chat-history-checkpoint.md +126 -0
- package/.agent-src/commands/chat-history-clear.md +6 -1
- package/.agent-src/commands/chat-history-resume.md +7 -2
- package/.agent-src/commands/chat-history.md +7 -2
- package/.agent-src/commands/check-current-md.md +137 -0
- package/.agent-src/commands/commit-in-chunks.md +118 -0
- package/.agent-src/commands/commit.md +4 -0
- package/.agent-src/commands/compress.md +37 -2
- package/.agent-src/commands/context-create.md +4 -0
- package/.agent-src/commands/context-refactor.md +4 -0
- package/.agent-src/commands/copilot-agents-init.md +3 -0
- package/.agent-src/commands/copilot-agents-optimize.md +3 -0
- package/.agent-src/commands/create-pr-description.md +4 -0
- package/.agent-src/commands/create-pr.md +4 -0
- package/.agent-src/commands/do-and-judge.md +4 -1
- package/.agent-src/commands/do-in-steps.md +3 -0
- package/.agent-src/commands/e2e-heal.md +4 -0
- package/.agent-src/commands/e2e-plan.md +4 -0
- package/.agent-src/commands/estimate-ticket.md +4 -1
- package/.agent-src/commands/feature-dev.md +4 -0
- package/.agent-src/commands/feature-explore.md +4 -0
- package/.agent-src/commands/feature-plan.md +4 -0
- package/.agent-src/commands/feature-refactor.md +4 -0
- package/.agent-src/commands/feature-roadmap.md +6 -0
- package/.agent-src/commands/fix-ci.md +4 -0
- package/.agent-src/commands/fix-portability.md +5 -2
- package/.agent-src/commands/fix-pr-bot-comments.md +4 -0
- package/.agent-src/commands/fix-pr-comments.md +4 -0
- package/.agent-src/commands/fix-pr-developer-comments.md +4 -0
- package/.agent-src/commands/fix-references.md +3 -0
- package/.agent-src/commands/fix-seeder.md +4 -0
- package/.agent-src/commands/implement-ticket.md +39 -13
- package/.agent-src/commands/jira-ticket.md +4 -0
- package/.agent-src/commands/judge.md +3 -0
- package/.agent-src/commands/memory-add.md +5 -3
- package/.agent-src/commands/memory-full.md +5 -2
- package/.agent-src/commands/memory-promote.md +7 -6
- package/.agent-src/commands/mode.md +3 -0
- package/.agent-src/commands/module-create.md +4 -0
- package/.agent-src/commands/module-explore.md +4 -0
- package/.agent-src/commands/onboard.md +33 -0
- package/.agent-src/commands/optimize-agents.md +4 -0
- package/.agent-src/commands/optimize-augmentignore.md +12 -0
- package/.agent-src/commands/optimize-rtk-filters.md +3 -0
- package/.agent-src/commands/optimize-skills.md +4 -0
- package/.agent-src/commands/override-create.md +4 -0
- package/.agent-src/commands/override-manage.md +4 -0
- package/.agent-src/commands/package-reset.md +3 -0
- package/.agent-src/commands/package-test.md +3 -0
- package/.agent-src/commands/prepare-for-review.md +4 -0
- package/.agent-src/commands/project-analyze.md +4 -0
- package/.agent-src/commands/project-health.md +4 -0
- package/.agent-src/commands/propose-memory.md +6 -8
- package/.agent-src/commands/quality-fix.md +4 -0
- package/.agent-src/commands/refine-ticket.md +12 -7
- package/.agent-src/commands/review-changes.md +39 -8
- package/.agent-src/commands/review-routing.md +4 -0
- package/.agent-src/commands/roadmap-create.md +18 -0
- package/.agent-src/commands/roadmap-execute.md +14 -1
- package/.agent-src/commands/rule-compliance-audit.md +4 -0
- package/.agent-src/commands/set-cost-profile.md +11 -0
- package/.agent-src/commands/sync-agent-settings.md +12 -0
- package/.agent-src/commands/sync-gitignore.md +3 -0
- package/.agent-src/commands/tests-create.md +4 -0
- package/.agent-src/commands/tests-execute.md +6 -3
- package/.agent-src/commands/threat-model.md +4 -0
- package/.agent-src/commands/update-form-request-messages.md +4 -0
- package/.agent-src/commands/upstream-contribute.md +4 -0
- package/.agent-src/commands/work.md +161 -0
- package/.agent-src/guidelines/agent-infra/engineering-memory-data-format.md +2 -6
- package/.agent-src/guidelines/agent-infra/layered-settings.md +0 -1
- package/.agent-src/guidelines/agent-infra/memory-access.md +0 -7
- package/.agent-src/guidelines/agent-infra/role-contracts.md +2 -4
- package/.agent-src/guidelines/agent-infra/self-improvement-pipeline.md +0 -1
- package/.agent-src/guidelines/php/patterns/strategy.md +180 -2
- package/.agent-src/personas/README.md +0 -1
- package/.agent-src/rules/artifact-drafting-protocol.md +7 -2
- package/.agent-src/rules/artifact-engagement-recording.md +133 -0
- package/.agent-src/rules/ask-when-uncertain.md +18 -13
- package/.agent-src/rules/augment-portability.md +64 -37
- package/.agent-src/rules/autonomous-execution.md +158 -0
- package/.agent-src/rules/chat-history-cadence.md +109 -0
- package/.agent-src/rules/chat-history-ownership.md +123 -0
- package/.agent-src/rules/chat-history-visibility.md +96 -0
- package/.agent-src/rules/cli-output-handling.md +27 -4
- package/.agent-src/rules/command-suggestion.md +134 -0
- package/.agent-src/rules/commit-policy.md +109 -0
- package/.agent-src/rules/direct-answers.md +114 -0
- package/.agent-src/rules/docs-sync.md +36 -0
- package/.agent-src/rules/downstream-changes.md +10 -9
- package/.agent-src/rules/improve-before-implement.md +9 -6
- package/.agent-src/rules/language-and-tone.md +85 -6
- package/.agent-src/rules/non-destructive-by-default.md +117 -0
- package/.agent-src/rules/package-ci-checks.md +4 -0
- package/.agent-src/rules/preservation-guard.md +20 -0
- package/.agent-src/rules/roadmap-progress-sync.md +159 -27
- package/.agent-src/rules/role-mode-adherence.md +1 -1
- package/.agent-src/rules/scope-control.md +42 -1
- package/.agent-src/rules/size-enforcement.md +2 -3
- package/.agent-src/rules/skill-quality.md +3 -8
- package/.agent-src/rules/ui-audit-before-build.md +106 -0
- package/.agent-src/rules/user-interaction.md +107 -51
- package/.agent-src/scripts/update_roadmap_progress.py +73 -9
- package/.agent-src/skills/blade-ui/SKILL.md +47 -3
- package/.agent-src/skills/command-routing/SKILL.md +32 -0
- package/.agent-src/skills/command-writing/SKILL.md +52 -2
- package/.agent-src/skills/description-assist/SKILL.md +21 -0
- package/.agent-src/skills/estimate-ticket/SKILL.md +0 -1
- package/.agent-src/skills/existing-ui-audit/SKILL.md +202 -0
- package/.agent-src/skills/fe-design/SKILL.md +78 -61
- package/.agent-src/skills/file-editor/SKILL.md +9 -0
- package/.agent-src/skills/finishing-a-development-branch/SKILL.md +4 -0
- package/.agent-src/skills/flux/SKILL.md +31 -4
- package/.agent-src/skills/guideline-writing/SKILL.md +24 -2
- package/.agent-src/skills/learning-to-rule-or-skill/SKILL.md +51 -9
- package/.agent-src/skills/livewire/SKILL.md +49 -4
- package/.agent-src/skills/md-language-check/SKILL.md +103 -0
- package/.agent-src/skills/php-coder/SKILL.md +24 -0
- package/.agent-src/skills/react-shadcn-ui/SKILL.md +121 -0
- package/.agent-src/skills/refine-prompt/SKILL.md +220 -0
- package/.agent-src/skills/refine-ticket/SKILL.md +32 -28
- package/.agent-src/skills/roadmap-management/SKILL.md +24 -11
- package/.agent-src/skills/rule-writing/SKILL.md +23 -1
- package/.agent-src/skills/skill-writing/SKILL.md +3 -5
- package/.agent-src/skills/upstream-contribute/SKILL.md +3 -3
- package/.agent-src/skills/using-git-worktrees/SKILL.md +3 -1
- package/.agent-src/templates/AGENTS.md +24 -6
- package/.agent-src/templates/agent-settings.md +149 -0
- package/.agent-src/templates/roadmaps.md +11 -4
- package/.agent-src/templates/scripts/implement_ticket/__init__.py +63 -26
- package/.agent-src/templates/scripts/implement_ticket/__main__.py +8 -2
- package/.agent-src/templates/scripts/memory_lookup.py +1 -1
- package/.agent-src/templates/scripts/telemetry/__init__.py +42 -0
- package/.agent-src/templates/scripts/telemetry/aggregator.py +154 -0
- package/.agent-src/templates/scripts/telemetry/boundary.py +171 -0
- package/.agent-src/templates/scripts/telemetry/engagement.py +238 -0
- package/.agent-src/templates/scripts/telemetry/report_renderer.py +170 -0
- package/.agent-src/templates/scripts/telemetry/settings.py +112 -0
- package/.agent-src/templates/scripts/telemetry_record.py +166 -0
- package/.agent-src/templates/scripts/telemetry_report.py +161 -0
- package/.agent-src/templates/scripts/telemetry_status.py +142 -0
- package/.agent-src/templates/scripts/work_engine/__init__.py +58 -0
- package/.agent-src/templates/scripts/work_engine/__main__.py +9 -0
- package/.agent-src/templates/scripts/work_engine/cli.py +195 -0
- package/.agent-src/templates/scripts/work_engine/cli_args.py +116 -0
- package/.agent-src/templates/scripts/{implement_ticket → work_engine}/delivery_state.py +10 -3
- package/.agent-src/templates/scripts/work_engine/directives/__init__.py +33 -0
- package/.agent-src/templates/scripts/work_engine/directives/backend/__init__.py +98 -0
- package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/analyze.py +1 -1
- package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/implement.py +3 -3
- package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/memory.py +2 -2
- package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/plan.py +2 -2
- package/.agent-src/templates/scripts/work_engine/directives/backend/refine.py +396 -0
- package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/report.py +37 -5
- package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/test.py +2 -2
- package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/verify.py +2 -2
- package/.agent-src/templates/scripts/work_engine/directives/mixed/__init__.py +116 -0
- package/.agent-src/templates/scripts/work_engine/directives/mixed/contract.py +254 -0
- package/.agent-src/templates/scripts/work_engine/directives/mixed/stitch.py +229 -0
- package/.agent-src/templates/scripts/work_engine/directives/mixed/ui.py +231 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui/__init__.py +113 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui/_passthrough.py +44 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui/apply.py +241 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui/audit.py +414 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui/design.py +335 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui/polish.py +510 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui/review.py +468 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/__init__.py +119 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/_skipped.py +37 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/apply.py +165 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/refine.py +66 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/report.py +62 -0
- package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/test.py +115 -0
- package/.agent-src/templates/scripts/work_engine/dispatcher.py +331 -0
- package/.agent-src/templates/scripts/work_engine/emitters.py +43 -0
- package/.agent-src/templates/scripts/work_engine/errors.py +19 -0
- package/.agent-src/templates/scripts/work_engine/hook_bootstrap.py +76 -0
- package/.agent-src/templates/scripts/work_engine/hooks/__init__.py +54 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/__init__.py +32 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/_chat_history_base.py +103 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_append.py +44 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_halt_append.py +42 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_heartbeat.py +50 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_turn_check.py +49 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/directive_set_guard.py +53 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/halt_surface_audit.py +50 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/state_shape_validation.py +52 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/trace.py +84 -0
- package/.agent-src/templates/scripts/work_engine/hooks/context.py +66 -0
- package/.agent-src/templates/scripts/work_engine/hooks/events.py +44 -0
- package/.agent-src/templates/scripts/work_engine/hooks/exceptions.py +79 -0
- package/.agent-src/templates/scripts/work_engine/hooks/registry.py +60 -0
- package/.agent-src/templates/scripts/work_engine/hooks/runner.py +73 -0
- package/.agent-src/templates/scripts/work_engine/hooks/settings.py +141 -0
- package/.agent-src/templates/scripts/work_engine/input_builders.py +163 -0
- package/.agent-src/templates/scripts/work_engine/intent/__init__.py +47 -0
- package/.agent-src/templates/scripts/work_engine/intent/classify.py +280 -0
- package/.agent-src/templates/scripts/work_engine/migration/__init__.py +8 -0
- package/.agent-src/templates/scripts/work_engine/migration/v0_to_v1.py +231 -0
- package/.agent-src/templates/scripts/{implement_ticket → work_engine}/persona_policy.py +1 -1
- package/.agent-src/templates/scripts/work_engine/resolvers/__init__.py +22 -0
- package/.agent-src/templates/scripts/work_engine/resolvers/diff.py +106 -0
- package/.agent-src/templates/scripts/work_engine/resolvers/file.py +113 -0
- package/.agent-src/templates/scripts/work_engine/resolvers/prompt.py +90 -0
- package/.agent-src/templates/scripts/work_engine/scoring/__init__.py +14 -0
- package/.agent-src/templates/scripts/work_engine/scoring/confidence.py +300 -0
- package/.agent-src/templates/scripts/work_engine/stack/__init__.py +31 -0
- package/.agent-src/templates/scripts/work_engine/stack/detect.py +187 -0
- package/.agent-src/templates/scripts/work_engine/state.py +641 -0
- package/.agent-src/templates/scripts/work_engine/state_io.py +202 -0
- package/.claude-plugin/marketplace.json +105 -2
- package/AGENTS.md +38 -8
- package/CHANGELOG.md +609 -0
- package/README.md +136 -14
- package/config/agent-settings.template.yml +45 -0
- package/config/gitignore-block.txt +4 -0
- package/docs/MIGRATION.md +122 -0
- package/docs/architecture.md +111 -35
- package/docs/contracts/STABILITY.md +95 -0
- package/docs/contracts/adr-chat-history-split.md +132 -0
- package/docs/contracts/adr-command-suggestion.md +146 -0
- package/docs/contracts/adr-implement-ticket-runtime.md +122 -0
- package/docs/contracts/adr-product-ui-track.md +384 -0
- package/docs/contracts/adr-prompt-driven-execution.md +187 -0
- package/docs/contracts/agent-memory-contract.md +149 -0
- package/docs/contracts/artifact-engagement-flow.md +262 -0
- package/docs/contracts/command-clusters.md +126 -0
- package/docs/contracts/command-suggestion-flow.md +148 -0
- package/docs/contracts/implement-ticket-flow.md +628 -0
- package/docs/contracts/linear-ai-rules-inclusion.md +143 -0
- package/docs/contracts/linear-ai-three-layers.md +131 -0
- package/docs/contracts/rule-interactions.md +107 -0
- package/docs/contracts/rule-interactions.yml +142 -0
- package/docs/contracts/ui-stack-extension.md +236 -0
- package/docs/contracts/ui-track-flow.md +338 -0
- package/docs/development.md +1 -1
- package/docs/getting-started.md +3 -3
- package/docs/installation.md +124 -2
- package/docs/migrations/commands-1.15.0.md +112 -0
- package/docs/showcase.md +204 -0
- package/docs/ui-track-mental-model.md +121 -0
- package/package.json +1 -1
- package/scripts/agent-config +199 -0
- package/scripts/audit_cloud_compatibility.py +288 -0
- package/scripts/build_cloud_bundle.py +458 -0
- package/scripts/build_linear_digest.py +263 -0
- package/scripts/chat_history.py +796 -7
- package/scripts/check_compression.py +139 -0
- package/scripts/check_iron_law_prominence.py +143 -0
- package/scripts/check_md_language.py +159 -0
- package/scripts/check_portability.py +38 -0
- package/scripts/check_public_links.py +185 -0
- package/scripts/check_references.py +1 -0
- package/scripts/check_reply_consistency.py +140 -0
- package/scripts/command_suggester/__init__.py +51 -0
- package/scripts/command_suggester/cooldown.py +132 -0
- package/scripts/command_suggester/loader.py +70 -0
- package/scripts/command_suggester/match.py +180 -0
- package/scripts/command_suggester/rank.py +120 -0
- package/scripts/command_suggester/render.py +86 -0
- package/scripts/command_suggester/sanitize.py +113 -0
- package/scripts/command_suggester/settings.py +125 -0
- package/scripts/command_suggester/types.py +78 -0
- package/scripts/hooks/augment-chat-history.sh +56 -0
- package/scripts/install-hooks.sh +67 -0
- package/scripts/install.py +150 -33
- package/scripts/lint_marketplace.py +27 -0
- package/scripts/lint_no_new_atomic_commands.py +179 -0
- package/scripts/lint_rule_interactions.py +149 -0
- package/scripts/memory_lookup.py +1 -1
- package/scripts/migrate_command_suggestions.py +151 -0
- package/scripts/release.py +297 -64
- package/scripts/schemas/command.schema.json +41 -0
- package/scripts/skill_linter.py +81 -0
- package/scripts/sync_agent_settings.py +42 -12
- package/scripts/update_counts.py +10 -0
- package/templates/consumer-settings/augment-cli-hooks.json +54 -0
- package/templates/consumer-settings/claude-settings.json +55 -1
- package/.agent-src/rules/chat-history.md +0 -171
- package/.agent-src/templates/scripts/implement_ticket/cli.py +0 -171
- package/.agent-src/templates/scripts/implement_ticket/dispatcher.py +0 -134
- package/.agent-src/templates/scripts/implement_ticket/steps/__init__.py +0 -49
- package/.agent-src/templates/scripts/implement_ticket/steps/refine.py +0 -140
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Score eligible commands against a user message + recent context.
|
|
2
|
+
|
|
3
|
+
Deterministic, no ML, no third-party deps. Two signals combine into
|
|
4
|
+
a 0.0–1.0 score:
|
|
5
|
+
|
|
6
|
+
* **Description match** — strongest single signal.
|
|
7
|
+
- Long phrase substring (≥ 10 chars) → 0.65.
|
|
8
|
+
- Short phrase substring (6–9 chars) → 0.4.
|
|
9
|
+
- Otherwise content-word overlap (≥ 4-char tokens, stop-words
|
|
10
|
+
stripped) scaled to 0.4.
|
|
11
|
+
* **Context match** — supporting evidence.
|
|
12
|
+
- Structural pattern (ticket key, file path, glob) co-occurring
|
|
13
|
+
in the message → 0.5.
|
|
14
|
+
- Otherwise content-word overlap scaled to 0.3.
|
|
15
|
+
|
|
16
|
+
Total `score = min(1.0, description_score + context_score)`. A long
|
|
17
|
+
phrase hit alone clears the default 0.6 floor; structural patterns
|
|
18
|
+
alone do not (anti-noise) — they need a description signal too.
|
|
19
|
+
A `Match.has_structural_bonus` flag lets the ranker know when a
|
|
20
|
+
short, otherwise-ambiguous prompt is actually specific (ticket
|
|
21
|
+
keys, paths) so it can override vague-input suppression.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import re
|
|
26
|
+
from typing import Iterable
|
|
27
|
+
|
|
28
|
+
from .sanitize import sanitize_context, sanitize_message
|
|
29
|
+
from .types import CommandSpec, Match
|
|
30
|
+
|
|
31
|
+
_TICKET_RE = re.compile(r"[A-Z][A-Z0-9]+-\d+")
|
|
32
|
+
_WORD_RE = re.compile(r"[A-Za-z][A-Za-z0-9_-]{3,}")
|
|
33
|
+
_PATH_RE = re.compile(r"[A-Za-z0-9_./-]+/[A-Za-z0-9_.*-]+")
|
|
34
|
+
_STOPWORDS: frozenset[str] = frozenset({
|
|
35
|
+
"this", "that", "with", "from", "have", "what", "when", "they",
|
|
36
|
+
"them", "into", "would", "could", "should", "about", "there",
|
|
37
|
+
"these", "those", "their", "your", "mine", "ours", "yours",
|
|
38
|
+
"show", "tell", "make", "want", "need", "like", "just", "some",
|
|
39
|
+
"many", "more", "most", "less", "than", "then", "also", "very",
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _tokens(text: str) -> set[str]:
|
|
44
|
+
return {
|
|
45
|
+
w.lower() for w in _WORD_RE.findall(text or "")
|
|
46
|
+
if w.lower() not in _STOPWORDS
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _phrases(trigger_description: str) -> list[str]:
|
|
51
|
+
return [
|
|
52
|
+
p.strip().lower() for p in (trigger_description or "").split(",")
|
|
53
|
+
if p.strip()
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _phrase_substring_hit(phrases: list[str], hay: str) -> str | None:
|
|
58
|
+
"""Return the longest phrase that occurs as a substring, else None.
|
|
59
|
+
|
|
60
|
+
Falls back to a hyphen-normalized hay so e.g. `"create pr"` still
|
|
61
|
+
matches `"create-pr"` in a path or branch reference.
|
|
62
|
+
"""
|
|
63
|
+
best: str | None = None
|
|
64
|
+
hay_norm = hay.replace("-", " ")
|
|
65
|
+
for p in sorted(phrases, key=len, reverse=True):
|
|
66
|
+
if len(p) < 6:
|
|
67
|
+
continue
|
|
68
|
+
if p in hay or p in hay_norm:
|
|
69
|
+
return p
|
|
70
|
+
return best
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _structural_bonus(spec: CommandSpec, message: str) -> str | None:
|
|
74
|
+
"""Heavy-signal patterns that score context fully (0.5).
|
|
75
|
+
|
|
76
|
+
Ticket keys (`ABC-123`) and file paths in the spec's
|
|
77
|
+
`trigger_context` only count when they actually appear in the
|
|
78
|
+
message — `trigger_context` advertises *which* signals matter,
|
|
79
|
+
the message provides them.
|
|
80
|
+
"""
|
|
81
|
+
ctx_lower = (spec.trigger_context or "").lower()
|
|
82
|
+
msg_lower = message.lower()
|
|
83
|
+
if "ticket" in ctx_lower or "proj-" in ctx_lower or "[a-z]+-[0-9]+" in ctx_lower:
|
|
84
|
+
m = _TICKET_RE.search(message)
|
|
85
|
+
if m:
|
|
86
|
+
return m.group(0)
|
|
87
|
+
for path in _PATH_RE.findall(spec.trigger_context or ""):
|
|
88
|
+
if path.lower() in msg_lower:
|
|
89
|
+
return path
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _description_score(spec: CommandSpec, message: str, ctx_text: str) -> tuple[float, str]:
|
|
94
|
+
phrases = _phrases(spec.trigger_description)
|
|
95
|
+
hay = (message + " \n " + ctx_text).lower()
|
|
96
|
+
hit = _phrase_substring_hit(phrases, hay)
|
|
97
|
+
if hit:
|
|
98
|
+
# Long phrase substring is the strongest signal — clears the
|
|
99
|
+
# default 0.6 floor on its own. Short phrases need context.
|
|
100
|
+
return (0.65 if len(hit) >= 10 else 0.4), hit
|
|
101
|
+
spec_tokens = _tokens(spec.trigger_description)
|
|
102
|
+
if not spec_tokens:
|
|
103
|
+
return 0.0, ""
|
|
104
|
+
msg_tokens = _tokens(message) | _tokens(ctx_text)
|
|
105
|
+
common = spec_tokens & msg_tokens
|
|
106
|
+
if not common:
|
|
107
|
+
return 0.0, ""
|
|
108
|
+
score = 0.4 * (len(common) / len(spec_tokens))
|
|
109
|
+
return score, sorted(common)[0]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _context_score(
|
|
113
|
+
spec: CommandSpec, message: str, ctx_text: str
|
|
114
|
+
) -> tuple[float, str, bool]:
|
|
115
|
+
"""Returns (score, evidence, has_structural_bonus)."""
|
|
116
|
+
bonus = _structural_bonus(spec, message)
|
|
117
|
+
if bonus:
|
|
118
|
+
return 0.5, bonus, True
|
|
119
|
+
spec_tokens = _tokens(spec.trigger_context)
|
|
120
|
+
if not spec_tokens:
|
|
121
|
+
return 0.0, "", False
|
|
122
|
+
msg_tokens = _tokens(message) | _tokens(ctx_text)
|
|
123
|
+
common = spec_tokens & msg_tokens
|
|
124
|
+
if not common:
|
|
125
|
+
return 0.0, "", False
|
|
126
|
+
score = 0.3 * (len(common) / len(spec_tokens))
|
|
127
|
+
return score, sorted(common)[0], False
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def match(
|
|
131
|
+
message: str,
|
|
132
|
+
context: Iterable[str] = (),
|
|
133
|
+
commands: Iterable[CommandSpec] = (),
|
|
134
|
+
*,
|
|
135
|
+
sanitize: bool = True,
|
|
136
|
+
) -> list[Match]:
|
|
137
|
+
"""Return scored matches sorted by descending score (ties stable).
|
|
138
|
+
|
|
139
|
+
Eligible commands only; ineligible ones are silently skipped. The
|
|
140
|
+
caller is responsible for ranking, cooldown, and rendering.
|
|
141
|
+
|
|
142
|
+
``sanitize`` (default ``True``) strips fenced/inline code and
|
|
143
|
+
previous suggestion-block echoes from both the message and the
|
|
144
|
+
last 2 turns of context. The flag is exposed for tests that
|
|
145
|
+
exercise the raw scoring path; runtime callers should leave it
|
|
146
|
+
on.
|
|
147
|
+
"""
|
|
148
|
+
if sanitize:
|
|
149
|
+
message = sanitize_message(message)
|
|
150
|
+
cleaned_ctx = sanitize_context(context)
|
|
151
|
+
else:
|
|
152
|
+
cleaned_ctx = list(context)
|
|
153
|
+
ctx_text = "\n".join(cleaned_ctx[-2:]) # last 2 turns max
|
|
154
|
+
matches: list[Match] = []
|
|
155
|
+
for spec in commands:
|
|
156
|
+
if not spec.eligible:
|
|
157
|
+
continue
|
|
158
|
+
d_score, d_evidence = _description_score(spec, message, ctx_text)
|
|
159
|
+
c_score, c_evidence, structural = _context_score(spec, message, ctx_text)
|
|
160
|
+
score = round(min(1.0, d_score + c_score), 4)
|
|
161
|
+
if score <= 0:
|
|
162
|
+
continue
|
|
163
|
+
if d_score > 0 and c_score > 0:
|
|
164
|
+
kind = "both"
|
|
165
|
+
evidence = d_evidence if len(d_evidence) >= len(c_evidence) else c_evidence
|
|
166
|
+
elif d_score > 0:
|
|
167
|
+
kind = "description"
|
|
168
|
+
evidence = d_evidence
|
|
169
|
+
else:
|
|
170
|
+
kind = "context"
|
|
171
|
+
evidence = c_evidence
|
|
172
|
+
matches.append(Match(
|
|
173
|
+
command=spec.name,
|
|
174
|
+
score=score,
|
|
175
|
+
matched_trigger=kind,
|
|
176
|
+
evidence=evidence,
|
|
177
|
+
has_structural_bonus=structural,
|
|
178
|
+
))
|
|
179
|
+
matches.sort(key=lambda m: (-m.score, m.command))
|
|
180
|
+
return matches
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Rank scored matches into the final candidate list.
|
|
2
|
+
|
|
3
|
+
Pipeline:
|
|
4
|
+
1. Drop commands whose name is in `settings.blocklist`.
|
|
5
|
+
2. Drop matches below the effective `confidence_floor` (per-command
|
|
6
|
+
override if set, else global).
|
|
7
|
+
3. Anti-noise:
|
|
8
|
+
- vague-input suppression (short message + many candidates, no structural bonus)
|
|
9
|
+
- lonely-band suppression (single match below `floor + 0.1`, no structural bonus)
|
|
10
|
+
- continuation suppression (message is pure follow-through, no new intent)
|
|
11
|
+
4. Sort by score desc; tie-break:
|
|
12
|
+
- structural bonus wins (named entities outrank generic verbs)
|
|
13
|
+
- longer matched evidence wins (more specific trigger)
|
|
14
|
+
- alphabetic command name wins (stable, deterministic)
|
|
15
|
+
5. Cap at `settings.max_options`.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
from typing import Iterable, Mapping
|
|
21
|
+
|
|
22
|
+
from .types import CommandSpec, Match, Settings
|
|
23
|
+
|
|
24
|
+
_LONELY_BAND = 0.1 # roadmap Phase 4: floor + 0.1 lonely-match threshold
|
|
25
|
+
|
|
26
|
+
_CONTINUATION_PHRASES: frozenset[str] = frozenset({
|
|
27
|
+
# English
|
|
28
|
+
"ok", "okay", "yes", "no", "sure", "go", "do it", "go on",
|
|
29
|
+
"continue", "next", "proceed", "more", "again",
|
|
30
|
+
# German
|
|
31
|
+
"ja", "nein", "weiter", "mach weiter", "los", "machen",
|
|
32
|
+
"weitermachen", "fortfahren", "nochmal",
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _floor_for(name: str, specs_by_name: Mapping[str, CommandSpec], settings: Settings) -> float:
|
|
37
|
+
spec = specs_by_name.get(name)
|
|
38
|
+
if spec and spec.confidence_floor is not None:
|
|
39
|
+
return spec.confidence_floor
|
|
40
|
+
return settings.confidence_floor
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _vague_input_suppression(message: str, matches: list[Match]) -> bool:
|
|
44
|
+
"""Short prompts hitting many commands are usually too ambiguous.
|
|
45
|
+
|
|
46
|
+
Suppress when:
|
|
47
|
+
- message has < 6 words
|
|
48
|
+
- more than 2 matches survived the floor
|
|
49
|
+
- none of the matches carry a structural bonus (ticket key, path)
|
|
50
|
+
|
|
51
|
+
A structural bonus means the prompt was specific even if short
|
|
52
|
+
— `"setze ABC-123 um"` is 3 words but unambiguous.
|
|
53
|
+
"""
|
|
54
|
+
word_count = len(message.split())
|
|
55
|
+
if word_count >= 6 or len(matches) <= 2:
|
|
56
|
+
return False
|
|
57
|
+
return not any(m.has_structural_bonus for m in matches)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _sub_floor_lonely_suppression(matches: list[Match], floor: float) -> bool:
|
|
61
|
+
"""Single match whose score sits within `floor + _LONELY_BAND`.
|
|
62
|
+
|
|
63
|
+
Roadmap Phase 4 sets this band at 0.1 — a single signal that
|
|
64
|
+
barely clears the floor is too uncertain to interrupt for. A
|
|
65
|
+
structural bonus (ticket key, path) overrides the suppression
|
|
66
|
+
because the match is already grounded in a specific entity.
|
|
67
|
+
"""
|
|
68
|
+
if len(matches) != 1:
|
|
69
|
+
return False
|
|
70
|
+
if matches[0].has_structural_bonus:
|
|
71
|
+
return False
|
|
72
|
+
return matches[0].score < floor + _LONELY_BAND
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _continuation_suppression(message: str, matches: list[Match]) -> bool:
|
|
76
|
+
"""Pure follow-through messages carry no new intent — suppress.
|
|
77
|
+
|
|
78
|
+
Triggers when the message reduces to a known continuation phrase
|
|
79
|
+
(`ok`, `weiter`, `mach weiter`, …) once trailing punctuation is
|
|
80
|
+
stripped. A structural bonus (ticket key, path) overrides — even
|
|
81
|
+
`"weiter mit ABC-123"` is a fresh intent signal.
|
|
82
|
+
"""
|
|
83
|
+
stripped = re.sub(r"[\s\W_]+", " ", message or "", flags=re.UNICODE).strip().lower()
|
|
84
|
+
if not stripped:
|
|
85
|
+
return False
|
|
86
|
+
if stripped not in _CONTINUATION_PHRASES:
|
|
87
|
+
return False
|
|
88
|
+
return not any(m.has_structural_bonus for m in matches)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _tie_break_key(m: Match) -> tuple[float, int, int, str]:
|
|
92
|
+
# Score desc, structural bonus first, longer evidence first, alpha last.
|
|
93
|
+
return (-m.score, 0 if m.has_structural_bonus else 1, -len(m.evidence), m.command)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def rank(
|
|
97
|
+
matches: Iterable[Match],
|
|
98
|
+
settings: Settings,
|
|
99
|
+
specs_by_name: Mapping[str, CommandSpec],
|
|
100
|
+
*,
|
|
101
|
+
raw_message: str = "",
|
|
102
|
+
) -> list[Match]:
|
|
103
|
+
if not settings.enabled:
|
|
104
|
+
return []
|
|
105
|
+
blocked = set(settings.blocklist)
|
|
106
|
+
candidates: list[Match] = [m for m in matches if m.command not in blocked]
|
|
107
|
+
above_floor: list[Match] = [
|
|
108
|
+
m for m in candidates
|
|
109
|
+
if m.score >= _floor_for(m.command, specs_by_name, settings)
|
|
110
|
+
]
|
|
111
|
+
if _continuation_suppression(raw_message, above_floor):
|
|
112
|
+
return []
|
|
113
|
+
if _vague_input_suppression(raw_message, above_floor):
|
|
114
|
+
return []
|
|
115
|
+
if _sub_floor_lonely_suppression(above_floor, settings.confidence_floor):
|
|
116
|
+
return []
|
|
117
|
+
above_floor.sort(key=_tie_break_key)
|
|
118
|
+
if settings.max_options > 0:
|
|
119
|
+
return above_floor[: settings.max_options]
|
|
120
|
+
return above_floor
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Render ranked matches as a numbered-options block.
|
|
2
|
+
|
|
3
|
+
Output strictly conforms to `user-interaction` Iron Law:
|
|
4
|
+
* Every option is one numbered line.
|
|
5
|
+
* Options block stays neutral (no inline `(recommended)` tag).
|
|
6
|
+
* Exactly one `Recommendation: N — …` line follows the block.
|
|
7
|
+
* The last numbered option is the **as-is** escape hatch — always
|
|
8
|
+
present, no exceptions.
|
|
9
|
+
|
|
10
|
+
The renderer is purely structural — it does not pick a recommendation
|
|
11
|
+
based on free judgment. The first match (highest score, most
|
|
12
|
+
specific evidence) becomes the recommendation; ties leave the line
|
|
13
|
+
out so the agent doesn't fabricate a tie-break the user didn't ask
|
|
14
|
+
for.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Mapping
|
|
19
|
+
|
|
20
|
+
from .types import CommandSpec, Match
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
AS_IS_LABEL = "Just run the prompt as-is, no command"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def render(
|
|
27
|
+
matches: list[Match],
|
|
28
|
+
specs_by_name: Mapping[str, CommandSpec],
|
|
29
|
+
*,
|
|
30
|
+
as_is_label: str = AS_IS_LABEL,
|
|
31
|
+
) -> str:
|
|
32
|
+
"""Return the numbered-options block as plain markdown text.
|
|
33
|
+
|
|
34
|
+
Empty `matches` ⇒ empty string. The rule never emits anything
|
|
35
|
+
when nothing crossed the floor.
|
|
36
|
+
"""
|
|
37
|
+
if not matches:
|
|
38
|
+
return ""
|
|
39
|
+
lines: list[str] = []
|
|
40
|
+
for i, m in enumerate(matches, start=1):
|
|
41
|
+
spec = specs_by_name.get(m.command)
|
|
42
|
+
desc = spec.description if spec and spec.description else ""
|
|
43
|
+
# Trim long descriptions for one-line option labels.
|
|
44
|
+
if len(desc) > 120:
|
|
45
|
+
desc = desc[:117].rstrip() + "..."
|
|
46
|
+
slash = f"/{m.command}"
|
|
47
|
+
if desc:
|
|
48
|
+
lines.append(f"> {i}. {slash} — {desc}")
|
|
49
|
+
else:
|
|
50
|
+
lines.append(f"> {i}. {slash}")
|
|
51
|
+
as_is_index = len(matches) + 1
|
|
52
|
+
lines.append(f"> {as_is_index}. {as_is_label}")
|
|
53
|
+
block = "\n".join(lines)
|
|
54
|
+
rec_line = _recommendation_line(matches, specs_by_name)
|
|
55
|
+
if rec_line:
|
|
56
|
+
return block + "\n\n" + rec_line
|
|
57
|
+
return block
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _recommendation_line(
|
|
61
|
+
matches: list[Match], specs_by_name: Mapping[str, CommandSpec]
|
|
62
|
+
) -> str:
|
|
63
|
+
"""Single-source recommendation per `user-interaction` Iron Law.
|
|
64
|
+
|
|
65
|
+
No recommendation when the top two matches are within 0.05 of
|
|
66
|
+
each other — surfacing a winner there would be fabrication.
|
|
67
|
+
"""
|
|
68
|
+
if not matches:
|
|
69
|
+
return ""
|
|
70
|
+
if len(matches) >= 2 and (matches[0].score - matches[1].score) < 0.05:
|
|
71
|
+
return ""
|
|
72
|
+
top = matches[0]
|
|
73
|
+
spec = specs_by_name.get(top.command)
|
|
74
|
+
name = top.command
|
|
75
|
+
rationale = _rationale_for(top, spec)
|
|
76
|
+
return f"**Recommendation: 1 — /{name}** — {rationale}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _rationale_for(match: Match, spec: CommandSpec | None) -> str:
|
|
80
|
+
if match.matched_trigger == "both":
|
|
81
|
+
why = f"both the request and context match (`{match.evidence}`)"
|
|
82
|
+
elif match.matched_trigger == "description":
|
|
83
|
+
why = f"the request matches its trigger description (`{match.evidence}`)"
|
|
84
|
+
else:
|
|
85
|
+
why = f"the surrounding context matches its trigger (`{match.evidence}`)"
|
|
86
|
+
return f"{why}. Pick the last option to skip the command and run the prompt as written."
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Sanitize matcher input to prevent self-echo and quoted-code triggering.
|
|
2
|
+
|
|
3
|
+
The suggestion engine scores against the user's raw message and the
|
|
4
|
+
last 2 turns of context. Two adversarial inputs would otherwise
|
|
5
|
+
re-trigger the engine on its own output or on user-pasted code:
|
|
6
|
+
|
|
7
|
+
* **Self-echo** — the previous turn's suggestion block (`> 1. /commit
|
|
8
|
+
— …`) is part of the conversation context. Scoring against it
|
|
9
|
+
re-surfaces the same commands turn after turn.
|
|
10
|
+
* **Quoted code** — user-pasted snippets that mention a command
|
|
11
|
+
(`` `/implement-ticket` ``, fenced ``` ```bash\ngit commit``` ```)
|
|
12
|
+
read like real intent signals to the substring matcher.
|
|
13
|
+
|
|
14
|
+
Both patterns are stripped here before the matcher sees them. The
|
|
15
|
+
sanitiser is **conservative**: only well-formed Markdown fences,
|
|
16
|
+
inline-code spans, and the engine's own suggestion-line shape are
|
|
17
|
+
removed. Plain prose is untouched so legitimate intent ("commit my
|
|
18
|
+
changes please") still scores.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
from typing import Iterable
|
|
24
|
+
|
|
25
|
+
# Triple-backtick fence — handles language hints (```bash …```) and
|
|
26
|
+
# unhinted blocks alike. Non-greedy so adjacent fences don't merge.
|
|
27
|
+
_CODE_FENCE_RE = re.compile(r"```.*?```", re.DOTALL)
|
|
28
|
+
# Inline code span. Excludes empty `` `` `` and respects single-line scope.
|
|
29
|
+
_INLINE_CODE_RE = re.compile(r"`[^`\n]+`")
|
|
30
|
+
# Suggestion-block line shape from `render.py`:
|
|
31
|
+
# > 1. /implement-ticket — drive ticket end-to-end…
|
|
32
|
+
# > 2. /refine-ticket — tighten the AC…
|
|
33
|
+
# Numbered-options lines starting with `>` and a `/command` token.
|
|
34
|
+
_SUGGESTION_LINE_RE = re.compile(
|
|
35
|
+
r"^\s*>\s*\d+\.\s*/[A-Za-z][A-Za-z0-9_-]*\b.*$",
|
|
36
|
+
re.MULTILINE,
|
|
37
|
+
)
|
|
38
|
+
# As-is escape hatch line — recognisable suffix from render.py.
|
|
39
|
+
_AS_IS_LINE_RE = re.compile(
|
|
40
|
+
r"^\s*>\s*\d+\.\s*Just run the prompt as-is.*$",
|
|
41
|
+
re.MULTILINE | re.IGNORECASE,
|
|
42
|
+
)
|
|
43
|
+
# Header line emitted by render.py.
|
|
44
|
+
_SUGGESTION_HEADER_RE = re.compile(
|
|
45
|
+
r"^\s*>\s*💡\s*Your request matches a command.*$",
|
|
46
|
+
re.MULTILINE,
|
|
47
|
+
)
|
|
48
|
+
# Recommendation line right after the block.
|
|
49
|
+
_RECOMMENDATION_LINE_RE = re.compile(
|
|
50
|
+
r"^\s*\*\*Recommendation:\s*\d+\b.*$",
|
|
51
|
+
re.MULTILINE,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def strip_code_blocks(text: str) -> str:
|
|
56
|
+
"""Remove fenced and inline code spans.
|
|
57
|
+
|
|
58
|
+
Fenced blocks first (greedy across newlines, non-greedy across
|
|
59
|
+
fences), then inline backticks. Plain text outside code is left
|
|
60
|
+
bit-identical.
|
|
61
|
+
"""
|
|
62
|
+
if not text:
|
|
63
|
+
return text
|
|
64
|
+
out = _CODE_FENCE_RE.sub(" ", text)
|
|
65
|
+
out = _INLINE_CODE_RE.sub(" ", out)
|
|
66
|
+
return out
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def strip_suggestion_echo(text: str) -> str:
|
|
70
|
+
"""Remove lines that look like the engine's own previous output.
|
|
71
|
+
|
|
72
|
+
Matches the four shapes `render.py` emits:
|
|
73
|
+
|
|
74
|
+
* the `> 💡 …` header
|
|
75
|
+
* `> N. /command — …` numbered options
|
|
76
|
+
* the `> N. Just run the prompt as-is …` escape hatch
|
|
77
|
+
* the `**Recommendation: N — …` follow-up line
|
|
78
|
+
|
|
79
|
+
Anything else (including user-authored quotes that happen to
|
|
80
|
+
mention a command) is preserved — only the engine's distinctive
|
|
81
|
+
block shape is filtered.
|
|
82
|
+
"""
|
|
83
|
+
if not text:
|
|
84
|
+
return text
|
|
85
|
+
out = _SUGGESTION_HEADER_RE.sub("", text)
|
|
86
|
+
out = _SUGGESTION_LINE_RE.sub("", out)
|
|
87
|
+
out = _AS_IS_LINE_RE.sub("", out)
|
|
88
|
+
out = _RECOMMENDATION_LINE_RE.sub("", out)
|
|
89
|
+
return out
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def sanitize_message(message: str) -> str:
|
|
93
|
+
"""Apply both filters to a single user message.
|
|
94
|
+
|
|
95
|
+
Order matters: strip code first (a `/command` inside a fence is
|
|
96
|
+
code, not an echo), then strip echoes from what remains.
|
|
97
|
+
"""
|
|
98
|
+
return strip_suggestion_echo(strip_code_blocks(message))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def sanitize_context(context_lines: Iterable[str]) -> list[str]:
|
|
102
|
+
"""Apply `sanitize_message` to each line of recent-turn context.
|
|
103
|
+
|
|
104
|
+
Returns a new list — the caller's list is untouched. Empty strings
|
|
105
|
+
after sanitising are kept out of the result so they don't dilute
|
|
106
|
+
token-overlap scoring.
|
|
107
|
+
"""
|
|
108
|
+
out: list[str] = []
|
|
109
|
+
for line in context_lines:
|
|
110
|
+
cleaned = sanitize_message(line)
|
|
111
|
+
if cleaned and cleaned.strip():
|
|
112
|
+
out.append(cleaned)
|
|
113
|
+
return out
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Read `commands.suggestion.*` from `.agent-settings.yml` into `Settings`.
|
|
2
|
+
|
|
3
|
+
Mirror of the chat-history pattern (`scripts/chat_history.py`):
|
|
4
|
+
|
|
5
|
+
* Lazy PyYAML import — the engine works without yaml installed when no
|
|
6
|
+
settings file is present (test fixtures, cloud bundles).
|
|
7
|
+
* Default-permissive: a missing file or missing section returns
|
|
8
|
+
`Settings()` defaults (suggestion layer enabled). Only an explicit
|
|
9
|
+
`enabled: false` flips the master switch off.
|
|
10
|
+
* Malformed YAML / unreadable file → defaults; the suggester degrades
|
|
11
|
+
silently rather than crashing the turn.
|
|
12
|
+
* Type-coerces with bounded fallbacks (floors clamped 0.0-1.0, ints
|
|
13
|
+
non-negative, blocklist forced to a tuple of strings).
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Iterable
|
|
19
|
+
|
|
20
|
+
from .types import Settings
|
|
21
|
+
|
|
22
|
+
DEFAULT_SETTINGS_FILE = ".agent-settings.yml"
|
|
23
|
+
|
|
24
|
+
_DEFAULT = Settings()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_settings(settings_path: Path | str | None = None) -> Settings:
|
|
28
|
+
"""Return a `Settings` instance hydrated from `.agent-settings.yml`.
|
|
29
|
+
|
|
30
|
+
Parameters
|
|
31
|
+
----------
|
|
32
|
+
settings_path:
|
|
33
|
+
Explicit override. ``None`` resolves to ``./.agent-settings.yml``
|
|
34
|
+
relative to the current working directory — same convention as
|
|
35
|
+
``chat_history``.
|
|
36
|
+
"""
|
|
37
|
+
path = Path(settings_path) if settings_path else Path(DEFAULT_SETTINGS_FILE)
|
|
38
|
+
raw = _read_section(path)
|
|
39
|
+
if raw is None:
|
|
40
|
+
return _DEFAULT
|
|
41
|
+
return _settings_from_raw(raw)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _read_section(path: Path) -> dict[str, Any] | None:
|
|
45
|
+
"""Return the ``commands.suggestion`` mapping or ``None`` on any miss."""
|
|
46
|
+
if not path.is_file():
|
|
47
|
+
return None
|
|
48
|
+
try:
|
|
49
|
+
import yaml # type: ignore[import-untyped]
|
|
50
|
+
except ImportError:
|
|
51
|
+
return None
|
|
52
|
+
try:
|
|
53
|
+
with path.open(encoding="utf-8") as fh:
|
|
54
|
+
data = yaml.safe_load(fh) or {}
|
|
55
|
+
except (OSError, yaml.YAMLError):
|
|
56
|
+
return None
|
|
57
|
+
if not isinstance(data, dict):
|
|
58
|
+
return None
|
|
59
|
+
commands = data.get("commands")
|
|
60
|
+
if not isinstance(commands, dict):
|
|
61
|
+
return None
|
|
62
|
+
section = commands.get("suggestion")
|
|
63
|
+
if not isinstance(section, dict):
|
|
64
|
+
return None
|
|
65
|
+
return section
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _settings_from_raw(raw: dict[str, Any]) -> Settings:
|
|
69
|
+
return Settings(
|
|
70
|
+
enabled=_coerce_bool(raw.get("enabled"), _DEFAULT.enabled),
|
|
71
|
+
confidence_floor=_coerce_floor(
|
|
72
|
+
raw.get("confidence_floor"), _DEFAULT.confidence_floor
|
|
73
|
+
),
|
|
74
|
+
cooldown_seconds=_coerce_nonneg_int(
|
|
75
|
+
raw.get("cooldown_seconds"), _DEFAULT.cooldown_seconds
|
|
76
|
+
),
|
|
77
|
+
max_options=_coerce_nonneg_int(
|
|
78
|
+
raw.get("max_options"), _DEFAULT.max_options
|
|
79
|
+
),
|
|
80
|
+
blocklist=_coerce_str_tuple(raw.get("blocklist")),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _coerce_bool(value: Any, default: bool) -> bool:
|
|
85
|
+
if isinstance(value, bool):
|
|
86
|
+
return value
|
|
87
|
+
if value is None:
|
|
88
|
+
return default
|
|
89
|
+
if isinstance(value, str):
|
|
90
|
+
s = value.strip().lower()
|
|
91
|
+
if s in ("true", "yes", "on", "1"):
|
|
92
|
+
return True
|
|
93
|
+
if s in ("false", "no", "off", "0"):
|
|
94
|
+
return False
|
|
95
|
+
return default
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _coerce_floor(value: Any, default: float) -> float:
|
|
99
|
+
try:
|
|
100
|
+
f = float(value) # type: ignore[arg-type]
|
|
101
|
+
except (TypeError, ValueError):
|
|
102
|
+
return default
|
|
103
|
+
if f < 0.0:
|
|
104
|
+
return 0.0
|
|
105
|
+
if f > 1.0:
|
|
106
|
+
return 1.0
|
|
107
|
+
return f
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _coerce_nonneg_int(value: Any, default: int) -> int:
|
|
111
|
+
try:
|
|
112
|
+
i = int(value) # type: ignore[arg-type]
|
|
113
|
+
except (TypeError, ValueError):
|
|
114
|
+
return default
|
|
115
|
+
return i if i >= 0 else default
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _coerce_str_tuple(value: Any) -> tuple[str, ...]:
|
|
119
|
+
if not isinstance(value, Iterable) or isinstance(value, (str, bytes)):
|
|
120
|
+
return ()
|
|
121
|
+
out: list[str] = []
|
|
122
|
+
for item in value:
|
|
123
|
+
if isinstance(item, str) and item.strip():
|
|
124
|
+
out.append(item.strip())
|
|
125
|
+
return tuple(out)
|