@event4u/agent-config 1.12.0 → 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.
@@ -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,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" or "intake"
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 retrieve(types: list[str], keys: list[str], limit: int = 5) -> list[Hit]:
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
- Curated entries are preferred on ties — they are hand-reviewed.
159
- The shape (`Hit`) matches the `present` backend adapter so skills
160
- can treat both sources identically.
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
- hits: list[Hit] = []
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
- hits.append(Hit(
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
- hits.append(Hit(
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
- hits.sort(key=lambda h: (h.score, h.source == "curated"), reverse=True)
185
- # Drop zero-score hits unless no better option exists.
186
- positives = [h for h in hits if h.score > 0]
187
- return (positives or hits)[:limit]
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
- hits = retrieve(types, args.key, args.limit)
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
- print(json.dumps([h.as_dict() for h in hits], indent=2, default=str))
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 _probe_health(cli_path: str) -> tuple[bool, str]:
62
- """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
+ """
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
- 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
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
- 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)
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)))
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Shared agent configuration \u2014 skills for AI coding tools (Claude Code, Augment, Cursor, Cline, Windsurf, Gemini CLI).",
9
- "version": "1.12.0"
9
+ "version": "1.13.0"
10
10
  },
11
11
  "plugins": [
12
12
  {