@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.
Files changed (85) hide show
  1. package/.agent-src/commands/agent-handoff.md +15 -0
  2. package/.agent-src/commands/chat-history-clear.md +98 -0
  3. package/.agent-src/commands/chat-history-resume.md +178 -0
  4. package/.agent-src/commands/chat-history.md +102 -0
  5. package/.agent-src/commands/compress.md +9 -9
  6. package/.agent-src/commands/copilot-agents-init.md +1 -1
  7. package/.agent-src/commands/fix-portability.md +2 -2
  8. package/.agent-src/commands/fix-pr-bot-comments.md +1 -1
  9. package/.agent-src/commands/fix-pr-developer-comments.md +1 -1
  10. package/.agent-src/commands/fix-references.md +2 -2
  11. package/.agent-src/commands/mode.md +5 -5
  12. package/.agent-src/commands/onboard.md +171 -0
  13. package/.agent-src/commands/roadmap-create.md +7 -2
  14. package/.agent-src/commands/roadmap-execute.md +2 -2
  15. package/.agent-src/commands/set-cost-profile.md +101 -0
  16. package/.agent-src/commands/sync-agent-settings.md +122 -0
  17. package/.agent-src/commands/sync-gitignore.md +104 -0
  18. package/.agent-src/commands/tests-execute.md +6 -6
  19. package/.agent-src/commands/upstream-contribute.md +5 -4
  20. package/.agent-src/contexts/augment-infrastructure.md +2 -2
  21. package/.agent-src/contexts/override-system.md +1 -1
  22. package/.agent-src/contexts/subagent-configuration.md +3 -3
  23. package/.agent-src/guidelines/agent-infra/layered-settings.md +48 -5
  24. package/.agent-src/rules/ask-when-uncertain.md +56 -3
  25. package/.agent-src/rules/augment-portability.md +52 -1
  26. package/.agent-src/rules/augment-source-of-truth.md +10 -10
  27. package/.agent-src/rules/chat-history.md +171 -0
  28. package/.agent-src/rules/docker-commands.md +5 -7
  29. package/.agent-src/rules/docs-sync.md +13 -9
  30. package/.agent-src/rules/improve-before-implement.md +2 -0
  31. package/.agent-src/rules/onboarding-gate.md +94 -0
  32. package/.agent-src/rules/package-ci-checks.md +6 -5
  33. package/.agent-src/rules/roadmap-progress-sync.md +24 -13
  34. package/.agent-src/rules/size-enforcement.md +1 -1
  35. package/.agent-src/rules/skill-quality.md +1 -1
  36. package/.agent-src/rules/think-before-action.md +1 -0
  37. package/.agent-src/rules/user-interaction.md +53 -7
  38. package/.agent-src/scripts/update_roadmap_progress.py +57 -10
  39. package/.agent-src/skills/check-refs/SKILL.md +1 -1
  40. package/.agent-src/skills/command-routing/SKILL.md +1 -1
  41. package/.agent-src/skills/command-writing/SKILL.md +4 -3
  42. package/.agent-src/skills/file-editor/SKILL.md +2 -2
  43. package/.agent-src/skills/guideline-writing/SKILL.md +4 -3
  44. package/.agent-src/skills/learning-to-rule-or-skill/SKILL.md +2 -2
  45. package/.agent-src/skills/lint-skills/SKILL.md +1 -1
  46. package/.agent-src/skills/roadmap-management/SKILL.md +13 -10
  47. package/.agent-src/skills/rtk-output-filtering/SKILL.md +20 -30
  48. package/.agent-src/skills/rule-writing/SKILL.md +5 -5
  49. package/.agent-src/skills/terragrunt/SKILL.md +0 -8
  50. package/.agent-src/skills/upstream-contribute/SKILL.md +5 -4
  51. package/.agent-src/templates/agent-settings.md +86 -34
  52. package/.agent-src/templates/github-workflows/roadmap-progress-check.yml +63 -0
  53. package/.agent-src/templates/hooks/pre-commit-roadmap-progress +60 -0
  54. package/.agent-src/templates/scripts/memory_lookup.py +382 -21
  55. package/.agent-src/templates/scripts/memory_status.py +110 -9
  56. package/.claude-plugin/marketplace.json +1 -1
  57. package/AGENTS.md +2 -2
  58. package/CHANGELOG.md +320 -0
  59. package/CONTRIBUTING.md +89 -40
  60. package/README.md +24 -3
  61. package/composer.json +5 -1
  62. package/config/agent-settings.template.yml +45 -6
  63. package/config/gitignore-block.txt +24 -0
  64. package/config/profiles/balanced.ini +5 -0
  65. package/config/profiles/full.ini +5 -0
  66. package/config/profiles/minimal.ini +5 -0
  67. package/docs/customization.md +30 -4
  68. package/docs/getting-started.md +53 -3
  69. package/docs/mcp.md +15 -4
  70. package/package.json +21 -2
  71. package/scripts/agent-config +230 -0
  72. package/scripts/chat_history.py +519 -0
  73. package/scripts/check_portability.py +151 -1
  74. package/scripts/install.py +55 -3
  75. package/scripts/install.sh +50 -21
  76. package/scripts/mcp_render.py +30 -16
  77. package/scripts/memory_lookup.py +143 -7
  78. package/scripts/memory_status.py +76 -14
  79. package/scripts/postinstall.sh +16 -0
  80. package/scripts/release.py +588 -0
  81. package/scripts/sync_agent_settings.py +211 -0
  82. package/scripts/sync_gitignore.py +226 -0
  83. package/templates/agent-config-wrapper.sh +47 -0
  84. package/.agent-src/commands/config-agent-settings.md +0 -126
  85. package/.agent-src/skills/eloquent/evals/last-run.json +0 -99
@@ -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; everything
230
- else is double-quoted so the migrated file is unambiguous.
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
- template_body = template.replace(COST_PROFILE_PLACEHOLDER, profile)
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():
@@ -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 entries
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 grep -qF "$GITIGNORE_MARKER" "$gitignore"; then
574
- return 0 # Already present
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 "append .gitignore block"
616
+ log_verbose "install CLI wrapper → $target"
579
617
  return
580
618
  fi
581
619
 
582
- cat >> "$gitignore" << 'BLOCK'
583
-
584
- # event4u/agent-config
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. Manage .gitignore
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 ""
@@ -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
- # In-project targets. Claude Desktop is user-scope, opt-in via --claude-desktop.
40
- IN_PROJECT_TARGETS: dict[str, Path] = {
41
- "cursor": PROJECT_ROOT / ".cursor" / "mcp.json",
42
- "windsurf": PROJECT_ROOT / ".windsurf" / "mcp.json",
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(IN_PROJECT_TARGETS)
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
- data = load_source(Path(args.source))
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
- data = load_source(Path(args.source))
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 `task mcp:render`):", file=sys.stderr)
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=str(SOURCE_FILE), help="Source mcp.json (default: repo root)")
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)
@@ -1,21 +1,28 @@
1
1
  #!/usr/bin/env python3
2
- """File-based retrieval for the `absent` path.
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
- The returned shape is identical to the `present`-path adapter over the
10
- `@event4u/agent-memory` API, so skills stay backend-agnostic.
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(types=["ownership"], keys=["app/Http"], limit=3)
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, with_shadows=args.with_shadows)
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
@@ -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 _probe_health(cli_path: str) -> tuple[bool, str]:
68
- """Returns (healthy, reason)."""
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
- return True, "ok"
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
- result = Result("present", "package", reason, elapsed, cli)
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
- # When the package is present, report its version from `health()`
159
- # output; until we parse that, keep the file-fallback marker so the
160
- # envelope never lies about what backed the response.
161
- backend_version = _FILE_BACKEND_VERSION
162
- features = list(_FILE_BACKEND_FEATURES)
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,
@@ -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