@event4u/agent-config 1.12.0 → 1.14.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 +3 -0
- 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 +5 -1
- 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 +5 -0
- package/.agent-src/commands/chat-history-resume.md +5 -0
- package/.agent-src/commands/chat-history.md +5 -0
- package/.agent-src/commands/check-current-md.md +126 -0
- package/.agent-src/commands/commit-in-chunks.md +98 -0
- package/.agent-src/commands/commit.md +4 -0
- package/.agent-src/commands/compress.md +3 -0
- 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 +3 -0
- 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 +24 -0
- package/.agent-src/commands/optimize-agents.md +4 -0
- package/.agent-src/commands/optimize-augmentignore.md +3 -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 +4 -1
- package/.agent-src/commands/review-changes.md +4 -0
- package/.agent-src/commands/review-routing.md +4 -0
- package/.agent-src/commands/roadmap-create.md +7 -0
- package/.agent-src/commands/roadmap-execute.md +12 -1
- package/.agent-src/commands/rule-compliance-audit.md +4 -0
- package/.agent-src/commands/set-cost-profile.md +3 -0
- package/.agent-src/commands/sync-agent-settings.md +3 -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 +4 -0
- 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 +8 -0
- package/.agent-src/rules/autonomous-execution.md +158 -0
- package/.agent-src/rules/chat-history.md +147 -118
- package/.agent-src/rules/cli-output-handling.md +26 -3
- package/.agent-src/rules/command-suggestion.md +133 -0
- package/.agent-src/rules/commit-policy.md +99 -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 +81 -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 +103 -30
- package/.agent-src/rules/scope-control.md +42 -1
- package/.agent-src/rules/size-enforcement.md +1 -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 +81 -3
- package/.agent-src/scripts/update_roadmap_progress.py +48 -6
- package/.agent-src/skills/blade-ui/SKILL.md +30 -5
- package/.agent-src/skills/command-routing/SKILL.md +32 -0
- package/.agent-src/skills/command-writing/SKILL.md +41 -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 +187 -0
- package/.agent-src/skills/fe-design/SKILL.md +72 -60
- 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 +30 -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 +2 -4
- package/.agent-src/skills/roadmap-management/SKILL.md +10 -3
- package/.agent-src/skills/rule-writing/SKILL.md +23 -1
- package/.agent-src/skills/skill-writing/SKILL.md +1 -3
- package/.agent-src/skills/upstream-contribute/SKILL.md +1 -1
- 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/github-workflows/roadmap-progress-check.yml +63 -0
- package/.agent-src/templates/hooks/pre-commit-roadmap-progress +60 -0
- package/.agent-src/templates/roadmaps.md +8 -2
- 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 +382 -21
- package/.agent-src/templates/scripts/memory_status.py +110 -9
- 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 +592 -0
- package/.agent-src/templates/scripts/{implement_ticket → work_engine}/delivery_state.py +7 -0
- 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 +2 -2
- package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/memory.py +1 -1
- package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/plan.py +1 -1
- 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 +36 -4
- 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/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/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 +199 -0
- 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/.claude-plugin/marketplace.json +105 -2
- package/AGENTS.md +36 -8
- package/CHANGELOG.md +558 -0
- package/README.md +146 -4
- package/composer.json +3 -0
- package/config/agent-settings.template.yml +45 -0
- package/config/gitignore-block.txt +4 -0
- package/docs/architecture.md +28 -1
- package/docs/development.md +1 -1
- package/docs/getting-started.md +3 -2
- package/docs/installation.md +86 -0
- package/docs/showcase.md +204 -0
- package/package.json +9 -1
- package/scripts/agent-config +274 -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 +36 -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/memory_lookup.py +143 -7
- package/scripts/memory_status.py +76 -14
- package/scripts/migrate_command_suggestions.py +151 -0
- package/scripts/postinstall.sh +16 -0
- package/scripts/schemas/command.schema.json +41 -0
- package/scripts/skill_linter.py +67 -0
- package/scripts/sync_agent_settings.py +42 -12
- package/templates/consumer-settings/augment-cli-hooks.json +54 -0
- package/templates/consumer-settings/claude-settings.json +55 -1
- 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
- /package/.agent-src/templates/scripts/{implement_ticket → work_engine}/persona_policy.py +0 -0
|
@@ -1,21 +1,28 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
2
|
+
"""Hybrid retrieval — file-first with optional package augmentation.
|
|
3
3
|
|
|
4
4
|
Implements the shared `retrieve(types, keys, limit)` abstraction used
|
|
5
5
|
by skills. Reads YAML under `agents/memory/<type>/` (curated, hand-
|
|
6
6
|
reviewed) and JSONL under `agents/memory/intake/*.jsonl` (agent-written,
|
|
7
7
|
append-only, supersede-chain aware).
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
When the `@event4u/agent-memory` package is present (see
|
|
10
|
+
`scripts/memory_status.py`), callers can pass the result of
|
|
11
|
+
:func:`package_operational_provider` to route additional retrieval
|
|
12
|
+
through the package's semantic CLI. Repo entries always win on
|
|
13
|
+
conflict — see `_apply_conflict_rule`.
|
|
11
14
|
|
|
12
15
|
Usage:
|
|
13
16
|
python3 scripts/memory_lookup.py --types domain-invariants,ownership \\
|
|
14
17
|
--key "app/Http/Controllers/Foo" --limit 5
|
|
15
18
|
python3 scripts/memory_lookup.py --types incident-learnings --format json
|
|
19
|
+
python3 scripts/memory_lookup.py --types ownership --key billing --auto
|
|
16
20
|
|
|
17
|
-
from scripts.memory_lookup import retrieve
|
|
18
|
-
hits = retrieve(
|
|
21
|
+
from scripts.memory_lookup import retrieve, package_operational_provider
|
|
22
|
+
hits = retrieve(
|
|
23
|
+
types=["ownership"], keys=["app/Http"], limit=3,
|
|
24
|
+
operational_provider=package_operational_provider(),
|
|
25
|
+
)
|
|
19
26
|
"""
|
|
20
27
|
|
|
21
28
|
from __future__ import annotations
|
|
@@ -23,10 +30,12 @@ from __future__ import annotations
|
|
|
23
30
|
import argparse
|
|
24
31
|
import fnmatch
|
|
25
32
|
import json
|
|
33
|
+
import os
|
|
34
|
+
import subprocess
|
|
26
35
|
import sys
|
|
27
36
|
from dataclasses import dataclass, asdict, field
|
|
28
37
|
from pathlib import Path
|
|
29
|
-
from typing import Any, Iterable
|
|
38
|
+
from typing import Any, Callable, Iterable, Optional, Union
|
|
30
39
|
|
|
31
40
|
MEMORY_ROOT = Path("agents/memory")
|
|
32
41
|
INTAKE_ROOT = MEMORY_ROOT / "intake"
|
|
@@ -45,8 +54,8 @@ CURATED_TYPES = {
|
|
|
45
54
|
class Hit:
|
|
46
55
|
id: str
|
|
47
56
|
type: str
|
|
48
|
-
source: str # "curated"
|
|
49
|
-
path: str # file that produced the hit
|
|
57
|
+
source: str # "curated" | "intake" | "operational"
|
|
58
|
+
path: str # file (or logical locator) that produced the hit
|
|
50
59
|
score: float # naive, content-match based [0..1]
|
|
51
60
|
entry: dict = field(default_factory=dict)
|
|
52
61
|
|
|
@@ -54,6 +63,38 @@ class Hit:
|
|
|
54
63
|
return asdict(self)
|
|
55
64
|
|
|
56
65
|
|
|
66
|
+
@dataclass
|
|
67
|
+
class Shadow:
|
|
68
|
+
"""An operational entry suppressed by the conflict rule."""
|
|
69
|
+
id: str
|
|
70
|
+
type: str
|
|
71
|
+
reason: str # "same-id" | "repo-deprecated"
|
|
72
|
+
operational_path: str # where the suppressed entry came from
|
|
73
|
+
repo_path: str # repo entry that shadowed it
|
|
74
|
+
|
|
75
|
+
def as_dict(self) -> dict:
|
|
76
|
+
return asdict(self)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class RetrievalResult:
|
|
81
|
+
"""Full retrieval payload with conflict-rule observability."""
|
|
82
|
+
hits: list
|
|
83
|
+
shadows: list = field(default_factory=list)
|
|
84
|
+
|
|
85
|
+
def as_dict(self) -> dict:
|
|
86
|
+
return {
|
|
87
|
+
"hits": [h.as_dict() for h in self.hits],
|
|
88
|
+
"shadows": [s.as_dict() for s in self.shadows],
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# An operational provider returns repo-shaped Hit objects with
|
|
93
|
+
# source="operational". Backend adapters (e.g. @event4u/agent-memory)
|
|
94
|
+
# are expected to translate their native payload into this shape.
|
|
95
|
+
OperationalProvider = Callable[[list[str], list[str]], Iterable[Hit]]
|
|
96
|
+
|
|
97
|
+
|
|
57
98
|
def _load_yaml(path: Path):
|
|
58
99
|
try:
|
|
59
100
|
import yaml
|
|
@@ -152,19 +193,195 @@ def _score(entry: dict, keys: list[str]) -> float:
|
|
|
152
193
|
return best
|
|
153
194
|
|
|
154
195
|
|
|
155
|
-
def
|
|
196
|
+
def _apply_conflict_rule(
|
|
197
|
+
repo_hits: list[Hit],
|
|
198
|
+
operational_hits: list[Hit],
|
|
199
|
+
) -> tuple[list[Hit], list[Shadow]]:
|
|
200
|
+
"""Enforce REPO WINS / OPERATIONAL AUGMENTS / NEVER CONTRADICTS SILENTLY.
|
|
201
|
+
|
|
202
|
+
Reference: `agents/roadmaps/road-to-memory-self-consumption.md` §
|
|
203
|
+
"Conflict rule: repo vs. operational". The four cases mapped below
|
|
204
|
+
are covered by `tests/test_conflict_rule.py`.
|
|
205
|
+
"""
|
|
206
|
+
# Repo entries index — curated AND intake both count as "repo" for
|
|
207
|
+
# the conflict rule. The operational store is the only non-repo side.
|
|
208
|
+
repo_by_id: dict[str, Hit] = {h.id: h for h in repo_hits if h.id}
|
|
209
|
+
|
|
210
|
+
merged: list[Hit] = list(repo_hits)
|
|
211
|
+
shadows: list[Shadow] = []
|
|
212
|
+
|
|
213
|
+
for op in operational_hits:
|
|
214
|
+
if op.id and op.id in repo_by_id:
|
|
215
|
+
# Case 1+2: same id → repo wins (including when repo is
|
|
216
|
+
# status:deprecated — operational cannot revive a retired
|
|
217
|
+
# entry). Suppress the operational entry and record shadow.
|
|
218
|
+
repo = repo_by_id[op.id]
|
|
219
|
+
reason = (
|
|
220
|
+
"repo-deprecated"
|
|
221
|
+
if repo.entry.get("status") == "deprecated"
|
|
222
|
+
else "same-id"
|
|
223
|
+
)
|
|
224
|
+
shadows.append(Shadow(
|
|
225
|
+
id=op.id,
|
|
226
|
+
type=op.type,
|
|
227
|
+
reason=reason,
|
|
228
|
+
operational_path=op.path,
|
|
229
|
+
repo_path=repo.path,
|
|
230
|
+
))
|
|
231
|
+
continue
|
|
232
|
+
# Case 3 (different ids on same logical key) and Case 4 (repo
|
|
233
|
+
# has no entry) — both simply include the operational hit.
|
|
234
|
+
# Repo entries naturally rank higher because their score is not
|
|
235
|
+
# discounted (see _score / operational scoring in retrieve()).
|
|
236
|
+
merged.append(op)
|
|
237
|
+
|
|
238
|
+
return merged, shadows
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ---------------------------------------------------------------------------
|
|
242
|
+
# Package-backed operational provider (the `present` path)
|
|
243
|
+
# ---------------------------------------------------------------------------
|
|
244
|
+
#
|
|
245
|
+
# When `memory_status.status() == "present"` the consumer-facing contract
|
|
246
|
+
# says retrieval should route through `@event4u/agent-memory`. The package
|
|
247
|
+
# CLI is purely **semantic** (`memory retrieve <query> --type T …`); the
|
|
248
|
+
# shared `retrieve(types, keys, …)` API is **key-based**. The hybrid
|
|
249
|
+
# resolution agreed in `agents/contexts/agent-memory-contract.md` synthesises
|
|
250
|
+
# `keys` into a single natural-language query for the package call, while
|
|
251
|
+
# the file fallback continues to do glob/substring matching on the same
|
|
252
|
+
# keys. Both legs land in the same `Hit` shape so the conflict rule can
|
|
253
|
+
# merge them transparently.
|
|
254
|
+
|
|
255
|
+
_CLI_TIMEOUT_SECONDS = 5.0
|
|
256
|
+
_CLI_RETRIEVE_LIMIT_DEFAULT = 20
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _synthesize_query(keys: list[str]) -> str:
|
|
260
|
+
"""Turn a list of retrieval keys into one natural-language query.
|
|
261
|
+
|
|
262
|
+
Keys are typically file paths (`app/Http/Controllers/Foo`), feature
|
|
263
|
+
names (`billing`), or short identifiers — joining them with spaces
|
|
264
|
+
gives the package's semantic search enough surface to score against
|
|
265
|
+
without inventing structure. Empty or whitespace-only keys are
|
|
266
|
+
dropped; if nothing remains the caller falls back to the file path.
|
|
267
|
+
"""
|
|
268
|
+
cleaned = [k.strip() for k in keys if isinstance(k, str) and k.strip()]
|
|
269
|
+
return " ".join(cleaned)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _cli_operational_provider(
|
|
273
|
+
types: list[str],
|
|
274
|
+
keys: list[str],
|
|
275
|
+
*,
|
|
276
|
+
cli_path: str = "memory",
|
|
277
|
+
timeout: float = _CLI_TIMEOUT_SECONDS,
|
|
278
|
+
limit: int = _CLI_RETRIEVE_LIMIT_DEFAULT,
|
|
279
|
+
) -> Iterable[Hit]:
|
|
280
|
+
"""Run `memory retrieve` and yield operational `Hit` objects.
|
|
281
|
+
|
|
282
|
+
Pino structured logs from the package go to stderr; stdout is a
|
|
283
|
+
clean v1 retrieval envelope. Any non-zero exit, timeout, or parse
|
|
284
|
+
failure degrades to "no operational hits" — `retrieve()` already
|
|
285
|
+
treats provider exceptions as a soft warning, so the caller still
|
|
286
|
+
gets the file-fallback result.
|
|
287
|
+
"""
|
|
288
|
+
query = _synthesize_query(keys)
|
|
289
|
+
if not query:
|
|
290
|
+
return
|
|
291
|
+
cmd: list[str] = [cli_path, "retrieve", query, "--limit", str(limit)]
|
|
292
|
+
for t in types:
|
|
293
|
+
cmd.extend(["--type", t])
|
|
294
|
+
try:
|
|
295
|
+
out = subprocess.run(
|
|
296
|
+
cmd,
|
|
297
|
+
capture_output=True, text=True, timeout=timeout,
|
|
298
|
+
)
|
|
299
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
300
|
+
return
|
|
301
|
+
if out.returncode != 0:
|
|
302
|
+
return
|
|
303
|
+
try:
|
|
304
|
+
envelope = json.loads(out.stdout)
|
|
305
|
+
except (ValueError, TypeError):
|
|
306
|
+
return
|
|
307
|
+
entries = envelope.get("entries") if isinstance(envelope, dict) else None
|
|
308
|
+
if not isinstance(entries, list):
|
|
309
|
+
return
|
|
310
|
+
for e in entries:
|
|
311
|
+
if not isinstance(e, dict):
|
|
312
|
+
continue
|
|
313
|
+
eid = e.get("id")
|
|
314
|
+
etype = e.get("type")
|
|
315
|
+
if not isinstance(eid, str) or not isinstance(etype, str):
|
|
316
|
+
continue
|
|
317
|
+
# The package returns `confidence` (0..1) per the v1 envelope;
|
|
318
|
+
# map it onto our internal `score` field so the conflict rule
|
|
319
|
+
# and ranking work uniformly across providers.
|
|
320
|
+
try:
|
|
321
|
+
score = float(e.get("confidence", 0.0))
|
|
322
|
+
except (TypeError, ValueError):
|
|
323
|
+
score = 0.0
|
|
324
|
+
body = e.get("body") if isinstance(e.get("body"), dict) else {}
|
|
325
|
+
yield Hit(
|
|
326
|
+
id=eid,
|
|
327
|
+
type=etype,
|
|
328
|
+
source="operational",
|
|
329
|
+
path=f"agent-memory:{eid}",
|
|
330
|
+
score=score,
|
|
331
|
+
entry=body,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def package_operational_provider() -> Optional[OperationalProvider]:
|
|
336
|
+
"""Return a CLI-backed provider when the package is `present`, else None.
|
|
337
|
+
|
|
338
|
+
Callers who want automatic backend routing pass the result directly
|
|
339
|
+
to :func:`retrieve` — `None` is a safe value that yields file-only
|
|
340
|
+
retrieval, so this is the recommended one-liner for skills:
|
|
341
|
+
|
|
342
|
+
retrieve(types, keys, operational_provider=package_operational_provider())
|
|
343
|
+
|
|
344
|
+
The status probe is bounded (≤ 2s, cached per process) — see
|
|
345
|
+
`scripts/memory_status.py`. We import lazily so pure file-fallback
|
|
346
|
+
callers never pay for the probe.
|
|
347
|
+
"""
|
|
348
|
+
# Late import: keeps `memory_lookup` importable even when
|
|
349
|
+
# `memory_status` is missing in stripped consumer installs.
|
|
350
|
+
try:
|
|
351
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
352
|
+
import memory_status # type: ignore[import-not-found]
|
|
353
|
+
except ImportError:
|
|
354
|
+
return None
|
|
355
|
+
if memory_status.status().status != "present":
|
|
356
|
+
return None
|
|
357
|
+
return _cli_operational_provider
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def retrieve(
|
|
361
|
+
types: list[str],
|
|
362
|
+
keys: list[str],
|
|
363
|
+
limit: int = 5,
|
|
364
|
+
operational_provider: Optional[OperationalProvider] = None,
|
|
365
|
+
with_shadows: bool = False,
|
|
366
|
+
) -> Union[list[Hit], RetrievalResult]:
|
|
156
367
|
"""Return up to `limit` hits across the requested types, highest score first.
|
|
157
368
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
369
|
+
Repo entries (curated + intake) are preferred on ties — they are
|
|
370
|
+
hand-reviewed or session-captured against the repo itself. When an
|
|
371
|
+
`operational_provider` is supplied (the `present` path of the
|
|
372
|
+
backend-detection contract), its results are merged under the
|
|
373
|
+
REPO WINS conflict rule; suppressed operational entries surface as
|
|
374
|
+
`shadows` when `with_shadows=True`.
|
|
375
|
+
|
|
376
|
+
The return type stays `list[Hit]` by default for backward
|
|
377
|
+
compatibility with existing skill call sites.
|
|
161
378
|
"""
|
|
162
|
-
|
|
379
|
+
repo_hits: list[Hit] = []
|
|
163
380
|
for mtype in types:
|
|
164
381
|
if mtype not in CURATED_TYPES:
|
|
165
382
|
continue
|
|
166
383
|
for path, entry in _iter_curated_entries(mtype):
|
|
167
|
-
|
|
384
|
+
repo_hits.append(Hit(
|
|
168
385
|
id=str(entry.get("id", "")),
|
|
169
386
|
type=mtype,
|
|
170
387
|
source="curated",
|
|
@@ -173,7 +390,7 @@ def retrieve(types: list[str], keys: list[str], limit: int = 5) -> list[Hit]:
|
|
|
173
390
|
entry=entry,
|
|
174
391
|
))
|
|
175
392
|
for path, entry in _iter_intake_entries(mtype):
|
|
176
|
-
|
|
393
|
+
repo_hits.append(Hit(
|
|
177
394
|
id=str(entry.get("id", "")),
|
|
178
395
|
type=mtype,
|
|
179
396
|
source="intake",
|
|
@@ -181,10 +398,122 @@ def retrieve(types: list[str], keys: list[str], limit: int = 5) -> list[Hit]:
|
|
|
181
398
|
score=_score(entry, keys) * 0.9, # slight discount vs curated
|
|
182
399
|
entry=entry,
|
|
183
400
|
))
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
401
|
+
|
|
402
|
+
operational_hits: list[Hit] = []
|
|
403
|
+
if operational_provider is not None:
|
|
404
|
+
try:
|
|
405
|
+
for oh in operational_provider(list(types), list(keys)) or []:
|
|
406
|
+
# Discount operational vs curated/intake so repo ranks
|
|
407
|
+
# higher on equal relevance. Providers may already return
|
|
408
|
+
# trust-adjusted scores; we only apply a floor discount.
|
|
409
|
+
oh.score = min(oh.score, 0.85)
|
|
410
|
+
operational_hits.append(oh)
|
|
411
|
+
except Exception as exc: # noqa: BLE001 — providers are external
|
|
412
|
+
print(f"warning: operational_provider raised "
|
|
413
|
+
f"{exc.__class__.__name__}: {exc}", file=sys.stderr)
|
|
414
|
+
|
|
415
|
+
merged, shadows = _apply_conflict_rule(repo_hits, operational_hits)
|
|
416
|
+
merged.sort(key=lambda h: (h.score, h.source == "curated"), reverse=True)
|
|
417
|
+
positives = [h for h in merged if h.score > 0]
|
|
418
|
+
final_hits = (positives or merged)[:limit]
|
|
419
|
+
|
|
420
|
+
if with_shadows:
|
|
421
|
+
return RetrievalResult(hits=final_hits, shadows=shadows)
|
|
422
|
+
return final_hits
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
CONTRACT_VERSION = 1
|
|
426
|
+
|
|
427
|
+
# Memory types this file-backed backend can answer. Types outside this
|
|
428
|
+
# set map to `unknown_type` per the retrieval contract.
|
|
429
|
+
_KNOWN_TYPES = CURATED_TYPES
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def retrieve_v1(
|
|
433
|
+
types: list[str],
|
|
434
|
+
keys: list[str],
|
|
435
|
+
limit: int = 20,
|
|
436
|
+
operational_provider: Optional[OperationalProvider] = None,
|
|
437
|
+
) -> dict:
|
|
438
|
+
"""Return a v1 retrieval-contract envelope.
|
|
439
|
+
|
|
440
|
+
Wraps :func:`retrieve` and projects the internal ``Hit`` shape into
|
|
441
|
+
the shape defined by
|
|
442
|
+
``schemas/retrieval-v1.schema.json``. Unknown types are reported as
|
|
443
|
+
``status: unknown_type`` for that slice only, rather than failing
|
|
444
|
+
the whole call.
|
|
445
|
+
"""
|
|
446
|
+
known = [t for t in types if t in _KNOWN_TYPES]
|
|
447
|
+
unknown = [t for t in types if t not in _KNOWN_TYPES]
|
|
448
|
+
|
|
449
|
+
result = retrieve(known, keys, limit=limit,
|
|
450
|
+
operational_provider=operational_provider,
|
|
451
|
+
with_shadows=True)
|
|
452
|
+
assert isinstance(result, RetrievalResult)
|
|
453
|
+
hits, shadows = result.hits, result.shadows
|
|
454
|
+
shadow_by_id = {s.id: s for s in shadows if s.id}
|
|
455
|
+
|
|
456
|
+
slice_counts: dict[str, int] = {t: 0 for t in known}
|
|
457
|
+
entries: list[dict] = []
|
|
458
|
+
for h in hits:
|
|
459
|
+
source = "operational" if h.source == "operational" else "repo"
|
|
460
|
+
envelope_entry: dict = {
|
|
461
|
+
"id": h.id,
|
|
462
|
+
"type": h.type,
|
|
463
|
+
"source": source,
|
|
464
|
+
"confidence": round(float(h.score), 4),
|
|
465
|
+
"body": dict(h.entry) if isinstance(h.entry, dict) else {},
|
|
466
|
+
"shadowed_by": None,
|
|
467
|
+
}
|
|
468
|
+
if h.type in slice_counts:
|
|
469
|
+
slice_counts[h.type] += 1
|
|
470
|
+
entries.append(envelope_entry)
|
|
471
|
+
|
|
472
|
+
# Surface shadowed operational entries as additional entries carrying
|
|
473
|
+
# `shadowed_by`. The conformance harness checks that only
|
|
474
|
+
# source="operational" entries ever set this field.
|
|
475
|
+
for sid, s in shadow_by_id.items():
|
|
476
|
+
entries.append({
|
|
477
|
+
"id": sid,
|
|
478
|
+
"type": s.type,
|
|
479
|
+
"source": "operational",
|
|
480
|
+
"confidence": 0.0,
|
|
481
|
+
"body": {},
|
|
482
|
+
"shadowed_by": f"repo:{sid}",
|
|
483
|
+
})
|
|
484
|
+
if s.type in slice_counts:
|
|
485
|
+
slice_counts[s.type] += 1
|
|
486
|
+
|
|
487
|
+
slices: dict[str, dict] = {
|
|
488
|
+
t: {"status": "ok", "count": slice_counts.get(t, 0)}
|
|
489
|
+
for t in known
|
|
490
|
+
}
|
|
491
|
+
errors: list[dict] = []
|
|
492
|
+
for t in unknown:
|
|
493
|
+
slices[t] = {"status": "unknown_type", "count": 0}
|
|
494
|
+
errors.append({
|
|
495
|
+
"type": t,
|
|
496
|
+
"code": "unknown_type",
|
|
497
|
+
"message": f"file-backed backend does not know type {t!r}",
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
oks = [s for s in slices.values() if s["status"] == "ok"]
|
|
501
|
+
fails = [s for s in slices.values() if s["status"] != "ok"]
|
|
502
|
+
envelope_status = (
|
|
503
|
+
"ok" if not fails
|
|
504
|
+
else "error" if not oks
|
|
505
|
+
else "partial"
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
envelope: dict = {
|
|
509
|
+
"contract_version": CONTRACT_VERSION,
|
|
510
|
+
"status": envelope_status,
|
|
511
|
+
"entries": entries,
|
|
512
|
+
"slices": slices,
|
|
513
|
+
}
|
|
514
|
+
if errors:
|
|
515
|
+
envelope["errors"] = errors
|
|
516
|
+
return envelope
|
|
188
517
|
|
|
189
518
|
|
|
190
519
|
def main() -> int:
|
|
@@ -195,20 +524,52 @@ def main() -> int:
|
|
|
195
524
|
help="Retrieval key (repeatable)")
|
|
196
525
|
ap.add_argument("--limit", type=int, default=5)
|
|
197
526
|
ap.add_argument("--format", choices=["text", "json"], default="text")
|
|
527
|
+
ap.add_argument("--envelope", choices=["legacy", "v1"], default="legacy",
|
|
528
|
+
help="Output shape: `legacy` (Hit list) or `v1` "
|
|
529
|
+
"(retrieval contract v1 envelope). `v1` implies JSON output.")
|
|
530
|
+
ap.add_argument("--with-shadows", action="store_true",
|
|
531
|
+
help="Include shadowed-operational entries in the output "
|
|
532
|
+
"(no-op until an operational backend is wired)")
|
|
533
|
+
ap.add_argument("--auto", action="store_true",
|
|
534
|
+
help="Auto-route to the @event4u/agent-memory package "
|
|
535
|
+
"when memory_status.status() == 'present'; "
|
|
536
|
+
"falls through to file-only retrieval otherwise")
|
|
198
537
|
args = ap.parse_args()
|
|
199
538
|
types = [t.strip() for t in args.types.split(",") if t.strip()]
|
|
200
539
|
if not types:
|
|
201
540
|
print("error: --types is required", file=sys.stderr)
|
|
202
541
|
return 2
|
|
203
|
-
|
|
542
|
+
op_provider = package_operational_provider() if args.auto else None
|
|
543
|
+
if args.envelope == "v1":
|
|
544
|
+
envelope = retrieve_v1(types, args.key, args.limit,
|
|
545
|
+
operational_provider=op_provider)
|
|
546
|
+
print(json.dumps(envelope, indent=2, default=str))
|
|
547
|
+
return 0
|
|
548
|
+
result = retrieve(types, args.key, args.limit,
|
|
549
|
+
operational_provider=op_provider,
|
|
550
|
+
with_shadows=args.with_shadows)
|
|
551
|
+
if args.with_shadows:
|
|
552
|
+
assert isinstance(result, RetrievalResult)
|
|
553
|
+
hits, shadows = result.hits, result.shadows
|
|
554
|
+
else:
|
|
555
|
+
hits, shadows = result, [] # type: ignore[assignment]
|
|
204
556
|
if args.format == "json":
|
|
205
|
-
|
|
557
|
+
payload = {"hits": [h.as_dict() for h in hits],
|
|
558
|
+
"shadows": [s.as_dict() for s in shadows]}
|
|
559
|
+
print(json.dumps(payload, indent=2, default=str))
|
|
206
560
|
else:
|
|
207
561
|
if not hits:
|
|
208
562
|
print(" (no hits)")
|
|
209
563
|
for h in hits:
|
|
210
564
|
print(f" [{h.source}] {h.type} score={h.score:.2f} "
|
|
211
565
|
f"id={h.id or '-'} path={h.path}")
|
|
566
|
+
if shadows:
|
|
567
|
+
print(f"\n shadows: {len(shadows)} operational entr"
|
|
568
|
+
f"{'y' if len(shadows) == 1 else 'ies'} suppressed by "
|
|
569
|
+
f"the conflict rule")
|
|
570
|
+
for s in shadows:
|
|
571
|
+
print(f" [{s.reason}] {s.type} id={s.id} "
|
|
572
|
+
f"op={s.operational_path} repo={s.repo_path}")
|
|
212
573
|
return 0
|
|
213
574
|
|
|
214
575
|
|
|
@@ -35,11 +35,17 @@ from typing import Literal
|
|
|
35
35
|
|
|
36
36
|
Status = Literal["absent", "misconfigured", "present"]
|
|
37
37
|
|
|
38
|
-
_CLI_CANDIDATES = ("agent-memory", "agentmem")
|
|
38
|
+
_CLI_CANDIDATES = ("memory", "agent-memory", "agentmem")
|
|
39
39
|
_HEALTH_TIMEOUT_SECONDS = 2.0
|
|
40
40
|
_CACHE_ENV = "AGENT_MEMORY_STATUS"
|
|
41
41
|
_CACHE_FILE = Path(".agent-memory") / "status.cache"
|
|
42
42
|
|
|
43
|
+
# Retrieval contract version served by the file-backed fallback.
|
|
44
|
+
# Source of truth: schemas/retrieval-v1.schema.json.
|
|
45
|
+
CONTRACT_VERSION = 1
|
|
46
|
+
_FILE_BACKEND_VERSION = "0.0.0-file"
|
|
47
|
+
_FILE_BACKEND_FEATURES = ("file-fallback",)
|
|
48
|
+
|
|
43
49
|
|
|
44
50
|
@dataclass
|
|
45
51
|
class Result:
|
|
@@ -48,6 +54,11 @@ class Result:
|
|
|
48
54
|
reason: str # short explanation
|
|
49
55
|
elapsed_ms: int # time spent probing (0 if cached)
|
|
50
56
|
cli_path: str = "" # resolved CLI path, if any
|
|
57
|
+
# Populated only when status == "present" — sourced from the
|
|
58
|
+
# `health` CLI envelope so the v1 health() reports real package
|
|
59
|
+
# capabilities instead of file-fallback placeholders.
|
|
60
|
+
backend_version: str = ""
|
|
61
|
+
features: tuple = ()
|
|
51
62
|
|
|
52
63
|
|
|
53
64
|
def _find_cli() -> str:
|
|
@@ -58,8 +69,45 @@ def _find_cli() -> str:
|
|
|
58
69
|
return ""
|
|
59
70
|
|
|
60
71
|
|
|
61
|
-
def
|
|
62
|
-
"""
|
|
72
|
+
def _parse_health_envelope(stdout: str) -> dict | None:
|
|
73
|
+
"""Extract the v1 health envelope from `memory health` stdout.
|
|
74
|
+
|
|
75
|
+
The package emits a single JSON object on stdout (pino structured
|
|
76
|
+
logs go to stderr). We tolerate older builds that may have leaked
|
|
77
|
+
log lines into stdout by scanning for the first top-level object
|
|
78
|
+
that carries ``contract_version``.
|
|
79
|
+
"""
|
|
80
|
+
text = (stdout or "").strip()
|
|
81
|
+
if not text:
|
|
82
|
+
return None
|
|
83
|
+
try:
|
|
84
|
+
obj = json.loads(text)
|
|
85
|
+
except ValueError:
|
|
86
|
+
obj = None
|
|
87
|
+
if isinstance(obj, dict) and obj.get("contract_version"):
|
|
88
|
+
return obj
|
|
89
|
+
# Fallback: line-by-line scan for an envelope-shaped object — covers
|
|
90
|
+
# the case where structured logs accidentally share stdout.
|
|
91
|
+
for line in text.splitlines():
|
|
92
|
+
line = line.strip()
|
|
93
|
+
if not line.startswith("{"):
|
|
94
|
+
continue
|
|
95
|
+
try:
|
|
96
|
+
cand = json.loads(line)
|
|
97
|
+
except ValueError:
|
|
98
|
+
continue
|
|
99
|
+
if isinstance(cand, dict) and cand.get("contract_version"):
|
|
100
|
+
return cand
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _probe_health(cli_path: str) -> tuple[bool, str, dict | None]:
|
|
105
|
+
"""Returns (healthy, reason, envelope).
|
|
106
|
+
|
|
107
|
+
On success ``envelope`` is the parsed v1 health envelope (may still
|
|
108
|
+
be ``None`` for very old CLIs that don't emit one). On failure it
|
|
109
|
+
is always ``None``.
|
|
110
|
+
"""
|
|
63
111
|
try:
|
|
64
112
|
out = subprocess.run(
|
|
65
113
|
[cli_path, "health"],
|
|
@@ -67,15 +115,16 @@ def _probe_health(cli_path: str) -> tuple[bool, str]:
|
|
|
67
115
|
timeout=_HEALTH_TIMEOUT_SECONDS,
|
|
68
116
|
)
|
|
69
117
|
except subprocess.TimeoutExpired:
|
|
70
|
-
return False, f"health() timed out after {_HEALTH_TIMEOUT_SECONDS}s"
|
|
118
|
+
return False, f"health() timed out after {_HEALTH_TIMEOUT_SECONDS}s", None
|
|
71
119
|
except FileNotFoundError:
|
|
72
|
-
return False, "CLI vanished between which() and invoke"
|
|
120
|
+
return False, "CLI vanished between which() and invoke", None
|
|
73
121
|
if out.returncode != 0:
|
|
74
122
|
# First line of combined output, capped, for the reason field.
|
|
75
123
|
msg = (out.stderr or out.stdout or "exit != 0").strip().splitlines()
|
|
76
124
|
head = msg[0][:120] if msg else "exit != 0"
|
|
77
|
-
return False, f"health() returned {out.returncode}: {head}"
|
|
78
|
-
|
|
125
|
+
return False, f"health() returned {out.returncode}: {head}", None
|
|
126
|
+
envelope = _parse_health_envelope(out.stdout)
|
|
127
|
+
return True, "ok", envelope
|
|
79
128
|
|
|
80
129
|
|
|
81
130
|
def _read_cache() -> Result | None:
|
|
@@ -125,22 +174,74 @@ def status(refresh: bool = False) -> Result:
|
|
|
125
174
|
result = Result("absent", "file",
|
|
126
175
|
"agent-memory CLI not on PATH", 0)
|
|
127
176
|
else:
|
|
128
|
-
healthy, reason = _probe_health(cli)
|
|
177
|
+
healthy, reason, envelope = _probe_health(cli)
|
|
129
178
|
elapsed = int((time.monotonic() - t0) * 1000)
|
|
130
179
|
if healthy:
|
|
131
|
-
|
|
180
|
+
backend_version = ""
|
|
181
|
+
features: tuple = ()
|
|
182
|
+
if isinstance(envelope, dict):
|
|
183
|
+
bv = envelope.get("backend_version")
|
|
184
|
+
if isinstance(bv, str):
|
|
185
|
+
backend_version = bv
|
|
186
|
+
feats = envelope.get("features")
|
|
187
|
+
if isinstance(feats, list) and all(
|
|
188
|
+
isinstance(f, str) for f in feats
|
|
189
|
+
):
|
|
190
|
+
features = tuple(feats)
|
|
191
|
+
result = Result("present", "package", reason, elapsed, cli,
|
|
192
|
+
backend_version=backend_version,
|
|
193
|
+
features=features)
|
|
132
194
|
else:
|
|
133
195
|
result = Result("misconfigured", "file", reason, elapsed, cli)
|
|
134
196
|
_write_cache(result)
|
|
135
197
|
return result
|
|
136
198
|
|
|
137
199
|
|
|
200
|
+
def health(refresh: bool = False) -> dict:
|
|
201
|
+
"""Return a v1 retrieval-contract health envelope.
|
|
202
|
+
|
|
203
|
+
Schema: ``schemas/retrieval-v1.schema.json`` (HealthResponse).
|
|
204
|
+
Maps the three-state :func:`status` result onto the contract's
|
|
205
|
+
``ok | degraded | error`` so consumers can read
|
|
206
|
+
``contract_version`` without caring about the file-vs-package split.
|
|
207
|
+
|
|
208
|
+
When the package backs the call (``status == "present"``), the
|
|
209
|
+
envelope reports the package's own ``backend_version`` and
|
|
210
|
+
``features`` so consumers can feature-detect against real
|
|
211
|
+
capabilities. Otherwise the file-fallback markers are returned.
|
|
212
|
+
"""
|
|
213
|
+
r = status(refresh=refresh)
|
|
214
|
+
envelope_status = {
|
|
215
|
+
"present": "ok",
|
|
216
|
+
"misconfigured": "degraded",
|
|
217
|
+
"absent": "ok",
|
|
218
|
+
}[r.status]
|
|
219
|
+
if r.status == "present" and (r.backend_version or r.features):
|
|
220
|
+
backend_version = r.backend_version or _FILE_BACKEND_VERSION
|
|
221
|
+
features = list(r.features) if r.features else list(_FILE_BACKEND_FEATURES)
|
|
222
|
+
else:
|
|
223
|
+
backend_version = _FILE_BACKEND_VERSION
|
|
224
|
+
features = list(_FILE_BACKEND_FEATURES)
|
|
225
|
+
return {
|
|
226
|
+
"contract_version": CONTRACT_VERSION,
|
|
227
|
+
"status": envelope_status,
|
|
228
|
+
"backend_version": backend_version,
|
|
229
|
+
"features": features,
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
|
|
138
233
|
def main() -> int:
|
|
139
234
|
ap = argparse.ArgumentParser(description=__doc__)
|
|
140
235
|
ap.add_argument("--format", choices=["text", "json"], default="text")
|
|
141
236
|
ap.add_argument("--refresh", action="store_true",
|
|
142
237
|
help="Bypass the session cache and probe fresh")
|
|
238
|
+
ap.add_argument("--health", action="store_true",
|
|
239
|
+
help="Emit a v1 retrieval-contract health envelope "
|
|
240
|
+
"instead of the legacy status line")
|
|
143
241
|
args = ap.parse_args()
|
|
242
|
+
if args.health:
|
|
243
|
+
print(json.dumps(health(refresh=args.refresh)))
|
|
244
|
+
return 0
|
|
144
245
|
r = status(refresh=args.refresh)
|
|
145
246
|
if args.format == "json":
|
|
146
247
|
print(json.dumps(asdict(r)))
|