@event4u/agent-config 1.9.1 → 1.13.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 +15 -0
- package/.agent-src/commands/chat-history-clear.md +98 -0
- package/.agent-src/commands/chat-history-resume.md +178 -0
- package/.agent-src/commands/chat-history.md +102 -0
- package/.agent-src/commands/compress.md +9 -9
- package/.agent-src/commands/copilot-agents-init.md +1 -1
- package/.agent-src/commands/fix-portability.md +2 -2
- package/.agent-src/commands/fix-pr-bot-comments.md +1 -1
- package/.agent-src/commands/fix-pr-developer-comments.md +1 -1
- package/.agent-src/commands/fix-references.md +2 -2
- package/.agent-src/commands/mode.md +5 -5
- package/.agent-src/commands/onboard.md +171 -0
- package/.agent-src/commands/roadmap-create.md +7 -2
- package/.agent-src/commands/roadmap-execute.md +2 -2
- package/.agent-src/commands/set-cost-profile.md +101 -0
- package/.agent-src/commands/sync-agent-settings.md +122 -0
- package/.agent-src/commands/sync-gitignore.md +104 -0
- package/.agent-src/commands/tests-execute.md +6 -6
- package/.agent-src/commands/upstream-contribute.md +5 -4
- package/.agent-src/contexts/augment-infrastructure.md +2 -2
- package/.agent-src/contexts/override-system.md +1 -1
- package/.agent-src/contexts/subagent-configuration.md +3 -3
- package/.agent-src/guidelines/agent-infra/layered-settings.md +48 -5
- package/.agent-src/rules/ask-when-uncertain.md +56 -3
- package/.agent-src/rules/augment-portability.md +52 -1
- package/.agent-src/rules/augment-source-of-truth.md +10 -10
- package/.agent-src/rules/chat-history.md +171 -0
- package/.agent-src/rules/docker-commands.md +5 -7
- package/.agent-src/rules/docs-sync.md +13 -9
- package/.agent-src/rules/improve-before-implement.md +2 -0
- package/.agent-src/rules/onboarding-gate.md +94 -0
- package/.agent-src/rules/package-ci-checks.md +6 -5
- package/.agent-src/rules/roadmap-progress-sync.md +24 -13
- package/.agent-src/rules/size-enforcement.md +1 -1
- package/.agent-src/rules/skill-quality.md +1 -1
- package/.agent-src/rules/think-before-action.md +1 -0
- package/.agent-src/rules/user-interaction.md +53 -7
- package/.agent-src/scripts/update_roadmap_progress.py +57 -10
- package/.agent-src/skills/check-refs/SKILL.md +1 -1
- package/.agent-src/skills/command-routing/SKILL.md +1 -1
- package/.agent-src/skills/command-writing/SKILL.md +4 -3
- package/.agent-src/skills/file-editor/SKILL.md +2 -2
- package/.agent-src/skills/guideline-writing/SKILL.md +4 -3
- package/.agent-src/skills/learning-to-rule-or-skill/SKILL.md +2 -2
- package/.agent-src/skills/lint-skills/SKILL.md +1 -1
- package/.agent-src/skills/roadmap-management/SKILL.md +13 -10
- package/.agent-src/skills/rtk-output-filtering/SKILL.md +20 -30
- package/.agent-src/skills/rule-writing/SKILL.md +5 -5
- package/.agent-src/skills/terragrunt/SKILL.md +0 -8
- package/.agent-src/skills/upstream-contribute/SKILL.md +5 -4
- package/.agent-src/templates/agent-settings.md +86 -34
- 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/scripts/memory_lookup.py +382 -21
- package/.agent-src/templates/scripts/memory_status.py +110 -9
- package/.claude-plugin/marketplace.json +1 -1
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +320 -0
- package/CONTRIBUTING.md +89 -40
- package/README.md +24 -3
- package/composer.json +5 -1
- package/config/agent-settings.template.yml +45 -6
- package/config/gitignore-block.txt +24 -0
- package/config/profiles/balanced.ini +5 -0
- package/config/profiles/full.ini +5 -0
- package/config/profiles/minimal.ini +5 -0
- package/docs/customization.md +30 -4
- package/docs/getting-started.md +53 -3
- package/docs/mcp.md +15 -4
- package/package.json +21 -2
- package/scripts/agent-config +230 -0
- package/scripts/chat_history.py +519 -0
- package/scripts/check_portability.py +151 -1
- package/scripts/install.py +55 -3
- package/scripts/install.sh +50 -21
- package/scripts/mcp_render.py +30 -16
- package/scripts/memory_lookup.py +143 -7
- package/scripts/memory_status.py +76 -14
- package/scripts/postinstall.sh +16 -0
- package/scripts/release.py +588 -0
- package/scripts/sync_agent_settings.py +211 -0
- package/scripts/sync_gitignore.py +226 -0
- package/templates/agent-config-wrapper.sh +47 -0
- package/.agent-src/commands/config-agent-settings.md +0 -126
- package/.agent-src/skills/eloquent/evals/last-run.json +0 -99
package/scripts/install.py
CHANGED
|
@@ -223,11 +223,17 @@ def _parse_legacy_settings(text: str) -> "tuple[dict, list]":
|
|
|
223
223
|
return values, unknown
|
|
224
224
|
|
|
225
225
|
|
|
226
|
+
_BARE_ID_RE = re.compile(r"^[a-z][a-z0-9_]*$")
|
|
227
|
+
|
|
228
|
+
|
|
226
229
|
def _yaml_scalar(value: str) -> str:
|
|
227
230
|
"""Format a string value as a YAML scalar with minimal quoting.
|
|
228
231
|
|
|
229
|
-
Booleans and non-negative integers are emitted unquoted
|
|
230
|
-
|
|
232
|
+
Booleans and non-negative integers are emitted unquoted. Bare
|
|
233
|
+
lowercase identifiers (``per_turn``, ``rotate``, ``getters_setters``
|
|
234
|
+
— the shape of profile values and enum-like strings) are emitted
|
|
235
|
+
unquoted so `sync_agent_settings.py` stays idempotent against its
|
|
236
|
+
own output. Everything else is double-quoted.
|
|
231
237
|
"""
|
|
232
238
|
if value == "":
|
|
233
239
|
return '""'
|
|
@@ -235,6 +241,8 @@ def _yaml_scalar(value: str) -> str:
|
|
|
235
241
|
return value
|
|
236
242
|
if value.isdigit():
|
|
237
243
|
return value
|
|
244
|
+
if _BARE_ID_RE.match(value):
|
|
245
|
+
return value
|
|
238
246
|
# Escape backslashes and double-quotes, then wrap
|
|
239
247
|
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
|
240
248
|
return f'"{escaped}"'
|
|
@@ -330,6 +338,44 @@ def _migrate_legacy_if_present(project_root: Path, template_body: str) -> "str |
|
|
|
330
338
|
|
|
331
339
|
# --- Bridge generators ---
|
|
332
340
|
|
|
341
|
+
def _parse_profile_ini(path: Path) -> "dict[str, str]":
|
|
342
|
+
"""Parse a simple key=value profile preset (comments start with ; or #)."""
|
|
343
|
+
values: "dict[str, str]" = {}
|
|
344
|
+
for raw in path.read_text(encoding="utf-8").splitlines():
|
|
345
|
+
line = raw.strip()
|
|
346
|
+
if not line or line.startswith(";") or line.startswith("#"):
|
|
347
|
+
continue
|
|
348
|
+
if "=" not in line:
|
|
349
|
+
continue
|
|
350
|
+
key, _, val = line.partition("=")
|
|
351
|
+
values[key.strip()] = val.strip()
|
|
352
|
+
return values
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
_PLACEHOLDER_RE = re.compile(r"__[A-Z][A-Z0-9_]*__")
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _render_template(template: str, profile_values: "dict[str, str]") -> str:
|
|
359
|
+
"""Substitute __UPPER_KEY__ placeholders using ini values.
|
|
360
|
+
|
|
361
|
+
Each ini key `foo_bar` maps to the `__FOO_BAR__` placeholder. Fails
|
|
362
|
+
if any placeholder remains unfilled — catches typos and missing
|
|
363
|
+
profile entries early.
|
|
364
|
+
"""
|
|
365
|
+
body = template
|
|
366
|
+
for key, value in profile_values.items():
|
|
367
|
+
placeholder = f"__{key.upper()}__"
|
|
368
|
+
if placeholder in body:
|
|
369
|
+
body = body.replace(placeholder, value)
|
|
370
|
+
leftover = sorted(set(_PLACEHOLDER_RE.findall(body)))
|
|
371
|
+
if leftover:
|
|
372
|
+
fail(
|
|
373
|
+
"Template has unfilled placeholders after profile render: "
|
|
374
|
+
+ ", ".join(leftover)
|
|
375
|
+
)
|
|
376
|
+
return body
|
|
377
|
+
|
|
378
|
+
|
|
333
379
|
def ensure_agent_settings(project_root: Path, package_root: Path, profile: str, force: bool) -> None:
|
|
334
380
|
target = project_root / SETTINGS_FILE
|
|
335
381
|
profile_source = package_root / "config" / "profiles" / f"{profile}.ini"
|
|
@@ -343,7 +389,13 @@ def ensure_agent_settings(project_root: Path, package_root: Path, profile: str,
|
|
|
343
389
|
template = template_source.read_text(encoding="utf-8")
|
|
344
390
|
if COST_PROFILE_PLACEHOLDER not in template:
|
|
345
391
|
fail(f"Template is missing placeholder {COST_PROFILE_PLACEHOLDER}")
|
|
346
|
-
|
|
392
|
+
profile_values = _parse_profile_ini(profile_source)
|
|
393
|
+
if profile_values.get("cost_profile") != profile:
|
|
394
|
+
fail(
|
|
395
|
+
f"Profile preset {profile_source.name} has cost_profile="
|
|
396
|
+
f"{profile_values.get('cost_profile')!r} but --profile={profile}"
|
|
397
|
+
)
|
|
398
|
+
template_body = _render_template(template, profile_values)
|
|
347
399
|
|
|
348
400
|
legacy_target = project_root / LEGACY_SETTINGS_FILE
|
|
349
401
|
if legacy_target.is_file() and target.exists():
|
package/scripts/install.sh
CHANGED
|
@@ -19,7 +19,6 @@ set -euo pipefail
|
|
|
19
19
|
|
|
20
20
|
# --- Configuration ---
|
|
21
21
|
COPY_DIRS="rules" # Subdirectories where files must be real copies (space-separated)
|
|
22
|
-
GITIGNORE_MARKER="# event4u/agent-config"
|
|
23
22
|
|
|
24
23
|
# Rules that are internal to the agent-config package and should NOT be shipped to consumers.
|
|
25
24
|
# These are only relevant when developing the agent-config package itself.
|
|
@@ -35,6 +34,7 @@ TARGET_DIR=""
|
|
|
35
34
|
DRY_RUN=false
|
|
36
35
|
VERBOSE=false
|
|
37
36
|
QUIET=false
|
|
37
|
+
SKIP_GITIGNORE=false
|
|
38
38
|
|
|
39
39
|
# --- Logging ---
|
|
40
40
|
log_info() { $QUIET || echo " ✅ $*"; }
|
|
@@ -51,6 +51,7 @@ parse_args() {
|
|
|
51
51
|
--dry-run) DRY_RUN=true; shift ;;
|
|
52
52
|
--verbose) VERBOSE=true; shift ;;
|
|
53
53
|
--quiet) QUIET=true; shift ;;
|
|
54
|
+
--skip-gitignore) SKIP_GITIGNORE=true; shift ;;
|
|
54
55
|
--help|-h) show_help; exit 0 ;;
|
|
55
56
|
*) log_error "Unknown argument: $1"; show_help; exit 1 ;;
|
|
56
57
|
esac
|
|
@@ -103,6 +104,7 @@ Options:
|
|
|
103
104
|
--dry-run Show what would happen without making changes
|
|
104
105
|
--verbose Show detailed output
|
|
105
106
|
--quiet Suppress all output except errors
|
|
107
|
+
--skip-gitignore Do not touch the target project's .gitignore
|
|
106
108
|
--help, -h Show this help
|
|
107
109
|
|
|
108
110
|
Environment:
|
|
@@ -561,39 +563,63 @@ copy_if_missing() {
|
|
|
561
563
|
cp "$source" "$target"
|
|
562
564
|
}
|
|
563
565
|
|
|
564
|
-
# Ensure .gitignore contains agent-config
|
|
566
|
+
# Ensure .gitignore contains the managed agent-config block.
|
|
567
|
+
# Delegates to scripts/sync_gitignore.py so the installer and the
|
|
568
|
+
# standalone /sync-gitignore command share one source of truth
|
|
569
|
+
# (config/gitignore-block.txt). Honors --dry-run and --skip-gitignore.
|
|
565
570
|
ensure_gitignore() {
|
|
566
571
|
local project_root="$1"
|
|
567
572
|
local gitignore="$project_root/.gitignore"
|
|
573
|
+
local sync_script="$SOURCE_DIR/scripts/sync_gitignore.py"
|
|
574
|
+
local template="$SOURCE_DIR/config/gitignore-block.txt"
|
|
568
575
|
|
|
576
|
+
if $SKIP_GITIGNORE; then
|
|
577
|
+
log_verbose "skip .gitignore (--skip-gitignore)"
|
|
578
|
+
return 0
|
|
579
|
+
fi
|
|
580
|
+
|
|
581
|
+
# Match the pre-refactor behavior: don't create .gitignore in a
|
|
582
|
+
# project that doesn't use git / doesn't already have one.
|
|
569
583
|
if [[ ! -f "$gitignore" ]]; then
|
|
570
584
|
return 0
|
|
571
585
|
fi
|
|
572
586
|
|
|
573
|
-
if
|
|
574
|
-
|
|
587
|
+
if [[ ! -f "$sync_script" || ! -f "$template" ]]; then
|
|
588
|
+
log_warn ".gitignore sync skipped — script or template missing"
|
|
589
|
+
return 0
|
|
590
|
+
fi
|
|
591
|
+
|
|
592
|
+
local args=(--path "$gitignore" --template "$template" --quiet)
|
|
593
|
+
$DRY_RUN && args+=(--dry-run)
|
|
594
|
+
|
|
595
|
+
if python3 "$sync_script" "${args[@]}" >/dev/null 2>&1; then
|
|
596
|
+
log_verbose ".gitignore synced"
|
|
597
|
+
else
|
|
598
|
+
log_warn ".gitignore sync failed (exit $?)"
|
|
599
|
+
fi
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
# Install the consumer-facing CLI wrapper `./agent-config` at the project
|
|
603
|
+
# root. Gitignored, overwritten on every install, delegates to the master
|
|
604
|
+
# CLI shipped in the package (node_modules or vendor).
|
|
605
|
+
install_cli_wrapper() {
|
|
606
|
+
local project_root="$1"
|
|
607
|
+
local template="$SOURCE_DIR/templates/agent-config-wrapper.sh"
|
|
608
|
+
local target="$project_root/agent-config"
|
|
609
|
+
|
|
610
|
+
if [[ ! -f "$template" ]]; then
|
|
611
|
+
log_verbose "CLI wrapper template missing: $template — skipping"
|
|
612
|
+
return 0
|
|
575
613
|
fi
|
|
576
614
|
|
|
577
615
|
if $DRY_RUN; then
|
|
578
|
-
log_verbose "
|
|
616
|
+
log_verbose "install CLI wrapper → $target"
|
|
579
617
|
return
|
|
580
618
|
fi
|
|
581
619
|
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
# Agent config — symlinked from vendor (auto-managed)
|
|
586
|
-
.augment/skills/
|
|
587
|
-
.augment/commands/
|
|
588
|
-
.augment/guidelines/
|
|
589
|
-
.augment/templates/
|
|
590
|
-
.augment/contexts/
|
|
591
|
-
.augment/scripts/
|
|
592
|
-
.augment/README.md
|
|
593
|
-
|
|
594
|
-
# Agent config — NOT ignored (real copies, may contain project overrides)
|
|
595
|
-
# .augment/rules/
|
|
596
|
-
BLOCK
|
|
620
|
+
cp "$template" "$target"
|
|
621
|
+
chmod +x "$target"
|
|
622
|
+
log_info "Installed ./agent-config wrapper"
|
|
597
623
|
}
|
|
598
624
|
|
|
599
625
|
# --- Main ---
|
|
@@ -626,7 +652,10 @@ main() {
|
|
|
626
652
|
generate_windsurfrules "$TARGET_DIR"
|
|
627
653
|
create_gemini_md "$TARGET_DIR"
|
|
628
654
|
|
|
629
|
-
# 5.
|
|
655
|
+
# 5. Install consumer CLI wrapper (gitignored, overwritten on every install)
|
|
656
|
+
install_cli_wrapper "$TARGET_DIR"
|
|
657
|
+
|
|
658
|
+
# 6. Manage .gitignore
|
|
630
659
|
ensure_gitignore "$TARGET_DIR"
|
|
631
660
|
|
|
632
661
|
echo ""
|
package/scripts/mcp_render.py
CHANGED
|
@@ -31,16 +31,23 @@ import sys
|
|
|
31
31
|
from pathlib import Path
|
|
32
32
|
from typing import Any
|
|
33
33
|
|
|
34
|
-
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|
35
|
-
SOURCE_FILE = PROJECT_ROOT / "mcp.json"
|
|
36
|
-
|
|
37
34
|
ENV_PLACEHOLDER = re.compile(r"\$\{env:([^}]+)\}")
|
|
38
35
|
|
|
39
|
-
#
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
36
|
+
# Project root defaults to the current working directory so the renderer
|
|
37
|
+
# works both for package maintainers (running from the package root via
|
|
38
|
+
# Taskfile) and for consumer projects (running via `./agent-config
|
|
39
|
+
# mcp:render` from their own repo root). Override with --project-root.
|
|
40
|
+
def default_project_root() -> Path:
|
|
41
|
+
return Path.cwd().resolve()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def in_project_targets(project_root: Path) -> dict[str, Path]:
|
|
45
|
+
return {
|
|
46
|
+
"cursor": project_root / ".cursor" / "mcp.json",
|
|
47
|
+
"windsurf": project_root / ".windsurf" / "mcp.json",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
44
51
|
CLAUDE_DESKTOP_TARGET = Path.home() / ".config" / "claude-desktop" / "claude_desktop_config.json"
|
|
45
52
|
|
|
46
53
|
|
|
@@ -104,20 +111,25 @@ def write_target(path: Path, content: dict[str, Any]) -> None:
|
|
|
104
111
|
path.write_text(serialized, encoding="utf-8")
|
|
105
112
|
|
|
106
113
|
|
|
107
|
-
def collect_targets(include_claude_desktop: bool) -> dict[str, Path]:
|
|
108
|
-
targets = dict(
|
|
114
|
+
def collect_targets(project_root: Path, include_claude_desktop: bool) -> dict[str, Path]:
|
|
115
|
+
targets = dict(in_project_targets(project_root))
|
|
109
116
|
if include_claude_desktop:
|
|
110
117
|
targets["claude-desktop"] = CLAUDE_DESKTOP_TARGET
|
|
111
118
|
return targets
|
|
112
119
|
|
|
113
120
|
|
|
121
|
+
def resolve_source(args: argparse.Namespace, project_root: Path) -> Path:
|
|
122
|
+
return Path(args.source) if args.source else project_root / "mcp.json"
|
|
123
|
+
|
|
124
|
+
|
|
114
125
|
def cmd_render(args: argparse.Namespace) -> int:
|
|
115
|
-
|
|
126
|
+
project_root = Path(args.project_root).resolve() if args.project_root else default_project_root()
|
|
127
|
+
data = load_source(resolve_source(args, project_root))
|
|
116
128
|
rendered, missing = render(data)
|
|
117
129
|
if missing:
|
|
118
130
|
print(format_missing_report(missing), file=sys.stderr)
|
|
119
131
|
return 1
|
|
120
|
-
targets = collect_targets(args.claude_desktop)
|
|
132
|
+
targets = collect_targets(project_root, args.claude_desktop)
|
|
121
133
|
for name, path in targets.items():
|
|
122
134
|
write_target(path, rendered)
|
|
123
135
|
print(f"✅ {name:16} → {path}")
|
|
@@ -125,20 +137,21 @@ def cmd_render(args: argparse.Namespace) -> int:
|
|
|
125
137
|
|
|
126
138
|
|
|
127
139
|
def cmd_check(args: argparse.Namespace) -> int:
|
|
128
|
-
|
|
140
|
+
project_root = Path(args.project_root).resolve() if args.project_root else default_project_root()
|
|
141
|
+
data = load_source(resolve_source(args, project_root))
|
|
129
142
|
rendered, missing = render(data)
|
|
130
143
|
if missing:
|
|
131
144
|
print(format_missing_report(missing), file=sys.stderr)
|
|
132
145
|
return 1
|
|
133
146
|
serialized = json.dumps(rendered, indent=2, sort_keys=True) + "\n"
|
|
134
|
-
targets = collect_targets(args.claude_desktop)
|
|
147
|
+
targets = collect_targets(project_root, args.claude_desktop)
|
|
135
148
|
diffs = []
|
|
136
149
|
for name, path in targets.items():
|
|
137
150
|
actual = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
138
151
|
if actual != serialized:
|
|
139
152
|
diffs.append((name, path))
|
|
140
153
|
if diffs:
|
|
141
|
-
print("❌ Targets out of date (run
|
|
154
|
+
print("❌ Targets out of date (run `./agent-config mcp:render`):", file=sys.stderr)
|
|
142
155
|
for name, path in diffs:
|
|
143
156
|
print(f" - {name}: {path}", file=sys.stderr)
|
|
144
157
|
return 1
|
|
@@ -148,7 +161,8 @@ def cmd_check(args: argparse.Namespace) -> int:
|
|
|
148
161
|
|
|
149
162
|
def main(argv: list[str] | None = None) -> int:
|
|
150
163
|
parser = argparse.ArgumentParser(description="Render mcp.json → per-tool config files.")
|
|
151
|
-
parser.add_argument("--source", default=
|
|
164
|
+
parser.add_argument("--source", default=None, help="Source mcp.json (default: <project-root>/mcp.json)")
|
|
165
|
+
parser.add_argument("--project-root", default=None, help="Project root for resolving source and targets (default: CWD)")
|
|
152
166
|
parser.add_argument("--claude-desktop", action="store_true", help="Also write Claude Desktop user-scope config")
|
|
153
167
|
parser.add_argument("--check", action="store_true", help="Dry-run; exit non-zero if targets are stale")
|
|
154
168
|
args = parser.parse_args(argv)
|
package/scripts/memory_lookup.py
CHANGED
|
@@ -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,6 +30,8 @@ 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
|
|
@@ -229,6 +238,125 @@ def _apply_conflict_rule(
|
|
|
229
238
|
return merged, shadows
|
|
230
239
|
|
|
231
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
|
+
|
|
232
360
|
def retrieve(
|
|
233
361
|
types: list[str],
|
|
234
362
|
keys: list[str],
|
|
@@ -402,16 +530,24 @@ def main() -> int:
|
|
|
402
530
|
ap.add_argument("--with-shadows", action="store_true",
|
|
403
531
|
help="Include shadowed-operational entries in the output "
|
|
404
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")
|
|
405
537
|
args = ap.parse_args()
|
|
406
538
|
types = [t.strip() for t in args.types.split(",") if t.strip()]
|
|
407
539
|
if not types:
|
|
408
540
|
print("error: --types is required", file=sys.stderr)
|
|
409
541
|
return 2
|
|
542
|
+
op_provider = package_operational_provider() if args.auto else None
|
|
410
543
|
if args.envelope == "v1":
|
|
411
|
-
envelope = retrieve_v1(types, args.key, args.limit
|
|
544
|
+
envelope = retrieve_v1(types, args.key, args.limit,
|
|
545
|
+
operational_provider=op_provider)
|
|
412
546
|
print(json.dumps(envelope, indent=2, default=str))
|
|
413
547
|
return 0
|
|
414
|
-
result = retrieve(types, args.key, args.limit,
|
|
548
|
+
result = retrieve(types, args.key, args.limit,
|
|
549
|
+
operational_provider=op_provider,
|
|
550
|
+
with_shadows=args.with_shadows)
|
|
415
551
|
if args.with_shadows:
|
|
416
552
|
assert isinstance(result, RetrievalResult)
|
|
417
553
|
hits, shadows = result.hits, result.shadows
|
package/scripts/memory_status.py
CHANGED
|
@@ -35,7 +35,7 @@ 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"
|
|
@@ -54,6 +54,11 @@ class Result:
|
|
|
54
54
|
reason: str # short explanation
|
|
55
55
|
elapsed_ms: int # time spent probing (0 if cached)
|
|
56
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 = ()
|
|
57
62
|
|
|
58
63
|
|
|
59
64
|
def _find_cli() -> str:
|
|
@@ -64,8 +69,45 @@ def _find_cli() -> str:
|
|
|
64
69
|
return ""
|
|
65
70
|
|
|
66
71
|
|
|
67
|
-
def
|
|
68
|
-
"""
|
|
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
|
+
"""
|
|
69
111
|
try:
|
|
70
112
|
out = subprocess.run(
|
|
71
113
|
[cli_path, "health"],
|
|
@@ -73,15 +115,16 @@ def _probe_health(cli_path: str) -> tuple[bool, str]:
|
|
|
73
115
|
timeout=_HEALTH_TIMEOUT_SECONDS,
|
|
74
116
|
)
|
|
75
117
|
except subprocess.TimeoutExpired:
|
|
76
|
-
return False, f"health() timed out after {_HEALTH_TIMEOUT_SECONDS}s"
|
|
118
|
+
return False, f"health() timed out after {_HEALTH_TIMEOUT_SECONDS}s", None
|
|
77
119
|
except FileNotFoundError:
|
|
78
|
-
return False, "CLI vanished between which() and invoke"
|
|
120
|
+
return False, "CLI vanished between which() and invoke", None
|
|
79
121
|
if out.returncode != 0:
|
|
80
122
|
# First line of combined output, capped, for the reason field.
|
|
81
123
|
msg = (out.stderr or out.stdout or "exit != 0").strip().splitlines()
|
|
82
124
|
head = msg[0][:120] if msg else "exit != 0"
|
|
83
|
-
return False, f"health() returned {out.returncode}: {head}"
|
|
84
|
-
|
|
125
|
+
return False, f"health() returned {out.returncode}: {head}", None
|
|
126
|
+
envelope = _parse_health_envelope(out.stdout)
|
|
127
|
+
return True, "ok", envelope
|
|
85
128
|
|
|
86
129
|
|
|
87
130
|
def _read_cache() -> Result | None:
|
|
@@ -131,10 +174,23 @@ def status(refresh: bool = False) -> Result:
|
|
|
131
174
|
result = Result("absent", "file",
|
|
132
175
|
"agent-memory CLI not on PATH", 0)
|
|
133
176
|
else:
|
|
134
|
-
healthy, reason = _probe_health(cli)
|
|
177
|
+
healthy, reason, envelope = _probe_health(cli)
|
|
135
178
|
elapsed = int((time.monotonic() - t0) * 1000)
|
|
136
179
|
if healthy:
|
|
137
|
-
|
|
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)
|
|
138
194
|
else:
|
|
139
195
|
result = Result("misconfigured", "file", reason, elapsed, cli)
|
|
140
196
|
_write_cache(result)
|
|
@@ -148,6 +204,11 @@ def health(refresh: bool = False) -> dict:
|
|
|
148
204
|
Maps the three-state :func:`status` result onto the contract's
|
|
149
205
|
``ok | degraded | error`` so consumers can read
|
|
150
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.
|
|
151
212
|
"""
|
|
152
213
|
r = status(refresh=refresh)
|
|
153
214
|
envelope_status = {
|
|
@@ -155,11 +216,12 @@ def health(refresh: bool = False) -> dict:
|
|
|
155
216
|
"misconfigured": "degraded",
|
|
156
217
|
"absent": "ok",
|
|
157
218
|
}[r.status]
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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)
|
|
163
225
|
return {
|
|
164
226
|
"contract_version": CONTRACT_VERSION,
|
|
165
227
|
"status": envelope_status,
|
package/scripts/postinstall.sh
CHANGED
|
@@ -33,6 +33,22 @@ trap 'rm -f "$LOG"' EXIT
|
|
|
33
33
|
bash "$INSTALLER" --quiet >"$LOG" 2>&1
|
|
34
34
|
CODE=$?
|
|
35
35
|
if [[ $CODE -eq 0 ]]; then
|
|
36
|
+
# Optional companion: @event4u/agent-memory. Suggest it once, only if
|
|
37
|
+
# the consumer hasn't already installed it (locally or on PATH). The
|
|
38
|
+
# hint is purely informational; agent-config falls back to file-based
|
|
39
|
+
# memory when the backend is absent.
|
|
40
|
+
if ! command -v memory >/dev/null 2>&1 \
|
|
41
|
+
&& ! command -v agent-memory >/dev/null 2>&1 \
|
|
42
|
+
&& [[ ! -d "$SCRIPT_DIR/../../@event4u/agent-memory" ]]; then
|
|
43
|
+
cat >&2 <<'HINT'
|
|
44
|
+
💡 agent-config tip: install @event4u/agent-memory for persistent agent
|
|
45
|
+
learnings across sessions (optional, dev-only):
|
|
46
|
+
|
|
47
|
+
npm install --save-dev @event4u/agent-memory
|
|
48
|
+
|
|
49
|
+
Skip if you don't need it — agent-config falls back to file-based memory.
|
|
50
|
+
HINT
|
|
51
|
+
fi
|
|
36
52
|
exit 0
|
|
37
53
|
fi
|
|
38
54
|
|