@event4u/agent-config 1.40.0 → 1.41.1

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.
@@ -0,0 +1,214 @@
1
+ """MCP telemetry healthcheck — Phase 1 J6.
2
+
3
+ Asserts that the per-consumer JSONL sink at
4
+ ``<consumer_root>/agents/.mcp-telemetry/calls.jsonl`` received at least
5
+ one record inside a configurable window (default 24 h). Exits non-zero
6
+ on silence so the caller's alert sink — Sentry, email, GitHub Actions
7
+ failure, cron mailer — fires.
8
+
9
+ Per ``agents/roadmaps/archive/road-to-mcp-full-coverage.md`` §Phase 1 J6, the
10
+ healthcheck protects Phase 2 K1 against waking to an empty dataset: a
11
+ silent telemetry pipeline must be visible *during* Phase 1, not after
12
+ the observation window closes.
13
+
14
+ Usage:
15
+
16
+ python3 scripts/mcp_telemetry_health.py # 24h window
17
+ python3 scripts/mcp_telemetry_health.py --window-hours 6
18
+ python3 scripts/mcp_telemetry_health.py --allow-missing # CI mode
19
+ python3 scripts/mcp_telemetry_health.py --json # machine-readable
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import json
25
+ import sys
26
+ import time
27
+ from dataclasses import dataclass
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ # Re-use the canonical sink location so a contract change in
32
+ # telemetry.py propagates automatically.
33
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
34
+ from mcp_server.telemetry import ( # noqa: E402
35
+ TELEMETRY_FILENAME,
36
+ TELEMETRY_REL_DIR,
37
+ )
38
+
39
+ DEFAULT_WINDOW_HOURS = 24
40
+ _ISO_FMT = "%Y-%m-%dT%H:%M:%SZ"
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class HealthReport:
45
+ """Outcome of a single healthcheck run. Serialised when --json fires."""
46
+
47
+ status: str # "healthy" | "silent" | "missing" | "unreadable"
48
+ path: str
49
+ window_hours: int
50
+ records_in_window: int
51
+ last_ts: str | None
52
+ message: str
53
+
54
+ def as_dict(self) -> dict[str, Any]:
55
+ return {
56
+ "status": self.status,
57
+ "path": self.path,
58
+ "window_hours": self.window_hours,
59
+ "records_in_window": self.records_in_window,
60
+ "last_ts": self.last_ts,
61
+ "message": self.message,
62
+ }
63
+
64
+
65
+ def _parse_iso(ts: str) -> float | None:
66
+ """Best-effort ISO-8601 → epoch. Returns None for malformed input."""
67
+ try:
68
+ return time.mktime(time.strptime(ts, _ISO_FMT)) - time.timezone
69
+ except (ValueError, TypeError):
70
+ return None
71
+
72
+
73
+ def resolve_log_path(consumer_root: Path | None = None) -> Path:
74
+ """Pick the JSONL location — matches telemetry.py's resolver."""
75
+ root = (consumer_root or Path.cwd()).resolve()
76
+ return root / TELEMETRY_REL_DIR / TELEMETRY_FILENAME
77
+
78
+
79
+ def evaluate(
80
+ *,
81
+ consumer_root: Path | None = None,
82
+ window_hours: int = DEFAULT_WINDOW_HOURS,
83
+ now: float | None = None,
84
+ ) -> HealthReport:
85
+ """Return a HealthReport — pure function, no exit calls."""
86
+ target = resolve_log_path(consumer_root)
87
+ cutoff = (now if now is not None else time.time()) - window_hours * 3600
88
+
89
+ if not target.exists():
90
+ return HealthReport(
91
+ status="missing",
92
+ path=str(target),
93
+ window_hours=window_hours,
94
+ records_in_window=0,
95
+ last_ts=None,
96
+ message=(
97
+ f"Telemetry sink not found at {target}. "
98
+ "Either the MCP server has never run, or the consumer root is wrong."
99
+ ),
100
+ )
101
+
102
+ try:
103
+ lines = target.read_text(encoding="utf-8").splitlines()
104
+ except OSError as exc:
105
+ return HealthReport(
106
+ status="unreadable",
107
+ path=str(target),
108
+ window_hours=window_hours,
109
+ records_in_window=0,
110
+ last_ts=None,
111
+ message=f"Telemetry sink unreadable: {exc}",
112
+ )
113
+
114
+ in_window = 0
115
+ last_ts: str | None = None
116
+ for line in lines:
117
+ if not line.strip():
118
+ continue
119
+ try:
120
+ record = json.loads(line)
121
+ except ValueError:
122
+ continue
123
+ ts = record.get("ts")
124
+ if not isinstance(ts, str):
125
+ continue
126
+ epoch = _parse_iso(ts)
127
+ if epoch is None:
128
+ continue
129
+ if last_ts is None or ts > last_ts:
130
+ last_ts = ts
131
+ if epoch >= cutoff:
132
+ in_window += 1
133
+
134
+ if in_window == 0:
135
+ return HealthReport(
136
+ status="silent",
137
+ path=str(target),
138
+ window_hours=window_hours,
139
+ records_in_window=0,
140
+ last_ts=last_ts,
141
+ message=(
142
+ f"No telemetry records in the past {window_hours}h. "
143
+ "Phase 2 K1 dataset is at risk — verify the MCP server is reachable "
144
+ "and that consumers are calling tools."
145
+ ),
146
+ )
147
+
148
+ return HealthReport(
149
+ status="healthy",
150
+ path=str(target),
151
+ window_hours=window_hours,
152
+ records_in_window=in_window,
153
+ last_ts=last_ts,
154
+ message=f"{in_window} record(s) logged in the past {window_hours}h.",
155
+ )
156
+
157
+
158
+ def _build_parser() -> argparse.ArgumentParser:
159
+ parser = argparse.ArgumentParser(
160
+ description=(
161
+ "MCP telemetry healthcheck — exits non-zero if no calls were "
162
+ "logged in the configured window."
163
+ ),
164
+ )
165
+ parser.add_argument(
166
+ "--consumer-root",
167
+ type=Path,
168
+ default=None,
169
+ help="Root directory containing agents/.mcp-telemetry/ (default: cwd).",
170
+ )
171
+ parser.add_argument(
172
+ "--window-hours",
173
+ type=int,
174
+ default=DEFAULT_WINDOW_HOURS,
175
+ help=f"Hours back to scan (default: {DEFAULT_WINDOW_HOURS}).",
176
+ )
177
+ parser.add_argument(
178
+ "--allow-missing",
179
+ action="store_true",
180
+ help="Treat 'sink missing' as success — useful for first-run / CI smoke.",
181
+ )
182
+ parser.add_argument(
183
+ "--json",
184
+ action="store_true",
185
+ help="Emit the HealthReport as JSON instead of plain text.",
186
+ )
187
+ return parser
188
+
189
+
190
+ def main(argv: list[str] | None = None) -> int:
191
+ args = _build_parser().parse_args(argv)
192
+ report = evaluate(
193
+ consumer_root=args.consumer_root,
194
+ window_hours=args.window_hours,
195
+ )
196
+
197
+ if args.json:
198
+ print(json.dumps(report.as_dict(), separators=(",", ":")))
199
+ else:
200
+ icon = {"healthy": "✅", "silent": "❌", "missing": "⚠️", "unreadable": "❌"}[report.status]
201
+ print(f"{icon} {report.message}")
202
+ if report.last_ts:
203
+ print(f" last record: {report.last_ts}")
204
+ print(f" sink: {report.path}")
205
+
206
+ if report.status == "healthy":
207
+ return 0
208
+ if report.status == "missing" and args.allow_missing:
209
+ return 0
210
+ return 1
211
+
212
+
213
+ if __name__ == "__main__":
214
+ raise SystemExit(main())
@@ -0,0 +1,203 @@
1
+ """MCP telemetry query CLI — Phase 2 K2.
2
+
3
+ Reads the SQLite store written by ``scripts/mcp_telemetry_store.py``
4
+ and surfaces:
5
+
6
+ - Per-tool attempt counts.
7
+ - Distinct-consumer counts (``client_id_hash``).
8
+ - Outcome ratios (``implemented`` / ``stub`` / ``latent_demand``).
9
+ - Latent-demand names — tool names not in the catalog.
10
+
11
+ Refresh cadence: cheap enough to run on every ``task mcp:report``
12
+ invocation. Stdlib-only; reads (never writes) the DB.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import sqlite3
19
+ import sys
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
25
+ from mcp_telemetry_store import resolve_db # noqa: E402
26
+
27
+ # Lazy import the catalog so the query CLI stays usable when only the
28
+ # JSONL is present (e.g. an analyst node without the full package).
29
+ try:
30
+ from mcp_server.catalog import load_catalog # noqa: E402
31
+
32
+ _CATALOG_AVAILABLE = True
33
+ except Exception: # pragma: no cover - defensive
34
+ _CATALOG_AVAILABLE = False
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class ToolRow:
39
+ tool_name: str
40
+ attempts: int
41
+ distinct_consumers: int
42
+ implemented: int
43
+ stub: int
44
+ latent_demand: int
45
+ last_ts: str | None
46
+
47
+ def as_dict(self) -> dict[str, Any]:
48
+ return {
49
+ "tool_name": self.tool_name,
50
+ "attempts": self.attempts,
51
+ "distinct_consumers": self.distinct_consumers,
52
+ "implemented": self.implemented,
53
+ "stub": self.stub,
54
+ "latent_demand": self.latent_demand,
55
+ "last_ts": self.last_ts,
56
+ }
57
+
58
+
59
+ def _connect_ro(db_path: Path) -> sqlite3.Connection:
60
+ if not db_path.exists():
61
+ raise FileNotFoundError(f"telemetry db not found: {db_path}")
62
+ uri = f"file:{db_path.as_posix()}?mode=ro"
63
+ return sqlite3.connect(uri, uri=True)
64
+
65
+
66
+ def _query_tools(conn: sqlite3.Connection) -> list[ToolRow]:
67
+ rows = conn.execute(
68
+ """
69
+ SELECT
70
+ tool_name,
71
+ COUNT(*) AS attempts,
72
+ COUNT(DISTINCT client_id_hash) AS distinct_consumers,
73
+ SUM(CASE WHEN outcome = 'implemented' THEN 1 ELSE 0 END),
74
+ SUM(CASE WHEN outcome = 'stub' THEN 1 ELSE 0 END),
75
+ SUM(CASE WHEN outcome = 'latent_demand' THEN 1 ELSE 0 END),
76
+ MAX(ts)
77
+ FROM calls
78
+ GROUP BY tool_name
79
+ ORDER BY attempts DESC, tool_name ASC
80
+ """
81
+ ).fetchall()
82
+ return [
83
+ ToolRow(
84
+ tool_name=r[0],
85
+ attempts=r[1],
86
+ distinct_consumers=r[2],
87
+ implemented=r[3] or 0,
88
+ stub=r[4] or 0,
89
+ latent_demand=r[5] or 0,
90
+ last_ts=r[6],
91
+ )
92
+ for r in rows
93
+ ]
94
+
95
+
96
+ def _catalog_names() -> frozenset[str]:
97
+ if not _CATALOG_AVAILABLE:
98
+ return frozenset()
99
+ try:
100
+ return frozenset(e.name for e in load_catalog())
101
+ except Exception:
102
+ return frozenset()
103
+
104
+
105
+ def summarise(db_path: Path) -> dict[str, Any]:
106
+ """Pure-function summary used by both the CLI and the tests."""
107
+ conn = _connect_ro(db_path)
108
+ try:
109
+ tools = _query_tools(conn)
110
+ finally:
111
+ conn.close()
112
+ catalog = _catalog_names()
113
+ total_attempts = sum(t.attempts for t in tools)
114
+ total_consumers = len({}) # placeholder; recomputed below
115
+ consumer_set: set[str] = set()
116
+ conn2 = _connect_ro(db_path)
117
+ try:
118
+ for (cid,) in conn2.execute(
119
+ "SELECT DISTINCT client_id_hash FROM calls"
120
+ ):
121
+ consumer_set.add(cid)
122
+ finally:
123
+ conn2.close()
124
+ total_consumers = len(consumer_set)
125
+ latent_names = [
126
+ t.tool_name for t in tools
127
+ if t.latent_demand > 0
128
+ and catalog
129
+ and t.tool_name not in catalog
130
+ ]
131
+ return {
132
+ "db_path": str(db_path),
133
+ "total_attempts": total_attempts,
134
+ "total_distinct_consumers": total_consumers,
135
+ "tools": [t.as_dict() for t in tools],
136
+ "latent_demand_names": sorted(set(latent_names)),
137
+ "catalog_known": len(catalog) > 0,
138
+ }
139
+
140
+
141
+ def _print_human(report: dict[str, Any]) -> None:
142
+ print(
143
+ f"📊 {report['total_attempts']} attempts across "
144
+ f"{len(report['tools'])} tool(s) — "
145
+ f"{report['total_distinct_consumers']} distinct consumer(s)"
146
+ )
147
+ print(f" db: {report['db_path']}")
148
+ print()
149
+ if not report["tools"]:
150
+ print("(no telemetry rows — run scripts/mcp_telemetry_store.py first)")
151
+ return
152
+ print(
153
+ f" {'tool':<28} {'att':>5} {'cons':>5} "
154
+ f"{'impl':>5} {'stub':>5} {'lat':>5} last_ts"
155
+ )
156
+ for t in report["tools"]:
157
+ print(
158
+ f" {t['tool_name']:<28} {t['attempts']:>5} "
159
+ f"{t['distinct_consumers']:>5} {t['implemented']:>5} "
160
+ f"{t['stub']:>5} {t['latent_demand']:>5} "
161
+ f"{t['last_ts'] or '—'}"
162
+ )
163
+ if report["latent_demand_names"]:
164
+ print()
165
+ print("⚠️ latent-demand names not in catalog:")
166
+ for n in report["latent_demand_names"]:
167
+ print(f" - {n}")
168
+
169
+
170
+ def _build_parser() -> argparse.ArgumentParser:
171
+ parser = argparse.ArgumentParser(
172
+ description="Query the MCP telemetry SQLite store (Phase 2 K2)."
173
+ )
174
+ parser.add_argument("--consumer-root", type=Path, default=None)
175
+ parser.add_argument("--db", type=Path, default=None)
176
+ parser.add_argument("--json", action="store_true")
177
+ return parser
178
+
179
+
180
+ def main(argv: list[str] | None = None) -> int:
181
+ args = _build_parser().parse_args(argv)
182
+ db_path = args.db or resolve_db(args.consumer_root)
183
+ try:
184
+ report = summarise(db_path)
185
+ except FileNotFoundError as exc:
186
+ msg = (
187
+ f"❌ {exc}\n"
188
+ " run `python3 scripts/mcp_telemetry_store.py` first."
189
+ )
190
+ if args.json:
191
+ print(json.dumps({"error": str(exc)}, separators=(",", ":")))
192
+ else:
193
+ print(msg, file=sys.stderr)
194
+ return 1
195
+ if args.json:
196
+ print(json.dumps(report, separators=(",", ":")))
197
+ else:
198
+ _print_human(report)
199
+ return 0
200
+
201
+
202
+ if __name__ == "__main__":
203
+ raise SystemExit(main())
@@ -0,0 +1,211 @@
1
+ """MCP telemetry SQLite store — Phase 2 K1.
2
+
3
+ Ingests the JSONL sink written by ``scripts/mcp_server/telemetry.py``
4
+ into a queryable SQLite database. Idempotent: each JSONL line is hashed
5
+ and stored as the primary key, so re-running ``ingest`` is safe and
6
+ won't double-count records.
7
+
8
+ Contract:
9
+
10
+ - Source of truth stays the JSONL file. SQLite is a derived view.
11
+ - Schema is documented in ``docs/contracts/mcp-telemetry-store.md``.
12
+ - Stdlib-only — no SQLAlchemy / pandas, so consumers can run this
13
+ without extra dependencies.
14
+
15
+ Phase 2 K2 (``scripts/mcp_telemetry_query.py``) reads from this store.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import hashlib
21
+ import json
22
+ import sqlite3
23
+ import sys
24
+ import time
25
+ from dataclasses import dataclass
26
+ from pathlib import Path
27
+ from typing import Any, Iterable
28
+
29
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
30
+ from mcp_server.telemetry import ( # noqa: E402
31
+ TELEMETRY_FILENAME,
32
+ TELEMETRY_REL_DIR,
33
+ )
34
+
35
+ DEFAULT_DB_REL = "agents/.mcp-telemetry/calls.sqlite3"
36
+ SCHEMA_VERSION = 1
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class IngestReport:
41
+ """Outcome of one ``ingest`` run."""
42
+
43
+ source_path: str
44
+ db_path: str
45
+ lines_read: int
46
+ lines_skipped: int
47
+ rows_inserted: int
48
+ rows_already_present: int
49
+
50
+ def as_dict(self) -> dict[str, Any]:
51
+ return {
52
+ "source_path": self.source_path,
53
+ "db_path": self.db_path,
54
+ "lines_read": self.lines_read,
55
+ "lines_skipped": self.lines_skipped,
56
+ "rows_inserted": self.rows_inserted,
57
+ "rows_already_present": self.rows_already_present,
58
+ }
59
+
60
+
61
+ def resolve_source(consumer_root: Path | None = None) -> Path:
62
+ root = (consumer_root or Path.cwd()).resolve()
63
+ return root / TELEMETRY_REL_DIR / TELEMETRY_FILENAME
64
+
65
+
66
+ def resolve_db(consumer_root: Path | None = None) -> Path:
67
+ root = (consumer_root or Path.cwd()).resolve()
68
+ return root / DEFAULT_DB_REL
69
+
70
+
71
+ def _connect(db_path: Path) -> sqlite3.Connection:
72
+ db_path.parent.mkdir(parents=True, exist_ok=True)
73
+ conn = sqlite3.connect(db_path)
74
+ conn.execute("PRAGMA journal_mode=WAL")
75
+ conn.execute(
76
+ """
77
+ CREATE TABLE IF NOT EXISTS calls (
78
+ line_hash TEXT PRIMARY KEY,
79
+ tool_name TEXT NOT NULL,
80
+ client_id_hash TEXT NOT NULL,
81
+ ts TEXT NOT NULL,
82
+ transport TEXT NOT NULL,
83
+ outcome TEXT NOT NULL,
84
+ ingested_at TEXT NOT NULL
85
+ )
86
+ """
87
+ )
88
+ conn.execute(
89
+ "CREATE INDEX IF NOT EXISTS idx_calls_tool ON calls(tool_name)"
90
+ )
91
+ conn.execute(
92
+ "CREATE INDEX IF NOT EXISTS idx_calls_ts ON calls(ts)"
93
+ )
94
+ conn.execute(
95
+ "CREATE INDEX IF NOT EXISTS idx_calls_outcome ON calls(outcome)"
96
+ )
97
+ conn.commit()
98
+ return conn
99
+
100
+
101
+ def _iter_lines(source: Path) -> Iterable[str]:
102
+ with source.open("r", encoding="utf-8") as fh:
103
+ for line in fh:
104
+ yield line
105
+
106
+
107
+ def ingest(
108
+ *,
109
+ consumer_root: Path | None = None,
110
+ source_override: Path | None = None,
111
+ db_override: Path | None = None,
112
+ ) -> IngestReport:
113
+ """Read the JSONL sink and upsert into SQLite. Idempotent."""
114
+ source = source_override or resolve_source(consumer_root)
115
+ db_path = db_override or resolve_db(consumer_root)
116
+
117
+ if not source.exists():
118
+ # Still create the DB so K2 has something to query against.
119
+ conn = _connect(db_path)
120
+ conn.close()
121
+ return IngestReport(
122
+ source_path=str(source),
123
+ db_path=str(db_path),
124
+ lines_read=0,
125
+ lines_skipped=0,
126
+ rows_inserted=0,
127
+ rows_already_present=0,
128
+ )
129
+
130
+ conn = _connect(db_path)
131
+ inserted = 0
132
+ already = 0
133
+ read = 0
134
+ skipped = 0
135
+ now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
136
+ try:
137
+ for raw in _iter_lines(source):
138
+ stripped = raw.strip()
139
+ if not stripped:
140
+ continue
141
+ read += 1
142
+ try:
143
+ record = json.loads(stripped)
144
+ except ValueError:
145
+ skipped += 1
146
+ continue
147
+ line_hash = hashlib.sha256(stripped.encode("utf-8")).hexdigest()
148
+ cur = conn.execute(
149
+ "INSERT OR IGNORE INTO calls "
150
+ "(line_hash, tool_name, client_id_hash, ts, transport, "
151
+ "outcome, ingested_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
152
+ (
153
+ line_hash,
154
+ str(record.get("tool_name", "")),
155
+ str(record.get("client_id_hash", "")),
156
+ str(record.get("ts", "")),
157
+ str(record.get("transport", "")),
158
+ str(record.get("outcome", "")),
159
+ now,
160
+ ),
161
+ )
162
+ if cur.rowcount == 1:
163
+ inserted += 1
164
+ else:
165
+ already += 1
166
+ conn.commit()
167
+ finally:
168
+ conn.close()
169
+ return IngestReport(
170
+ source_path=str(source),
171
+ db_path=str(db_path),
172
+ lines_read=read,
173
+ lines_skipped=skipped,
174
+ rows_inserted=inserted,
175
+ rows_already_present=already,
176
+ )
177
+
178
+
179
+ def _build_parser() -> argparse.ArgumentParser:
180
+ parser = argparse.ArgumentParser(
181
+ description="Ingest MCP telemetry JSONL into SQLite (Phase 2 K1)."
182
+ )
183
+ parser.add_argument("--consumer-root", type=Path, default=None)
184
+ parser.add_argument("--source", type=Path, default=None)
185
+ parser.add_argument("--db", type=Path, default=None)
186
+ parser.add_argument("--json", action="store_true")
187
+ return parser
188
+
189
+
190
+ def main(argv: list[str] | None = None) -> int:
191
+ args = _build_parser().parse_args(argv)
192
+ report = ingest(
193
+ consumer_root=args.consumer_root,
194
+ source_override=args.source,
195
+ db_override=args.db,
196
+ )
197
+ if args.json:
198
+ print(json.dumps(report.as_dict(), separators=(",", ":")))
199
+ else:
200
+ print(
201
+ f"✅ ingested {report.rows_inserted} new row(s) "
202
+ f"(skipped {report.lines_skipped} malformed, "
203
+ f"{report.rows_already_present} already present)"
204
+ )
205
+ print(f" source: {report.source_path}")
206
+ print(f" db: {report.db_path}")
207
+ return 0
208
+
209
+
210
+ if __name__ == "__main__":
211
+ raise SystemExit(main())
@@ -49,18 +49,20 @@ def _skip_intake_when_present() -> bool:
49
49
  Default: False — intake JSONL is always written as debug trail even
50
50
  when the `agent-memory` backend is present (see
51
51
  `road-to-memory-merge-safety.md` Phase 3).
52
+
53
+ Centralized loader (road-to-portable-dev-preferences P3): tolerance
54
+ contract handles missing file / malformed YAML / no PyYAML uniformly.
52
55
  """
53
- if not SETTINGS_FILE.is_file():
54
- return False
55
- try:
56
- import yaml # type: ignore
57
- except ImportError:
58
- return False
59
56
  try:
60
- data = yaml.safe_load(SETTINGS_FILE.read_text(encoding="utf-8")) or {}
61
- except Exception:
62
- return False
63
- mem = data.get("memory") if isinstance(data, dict) else None
57
+ from scripts._lib.agent_settings import load_agent_settings
58
+ except ImportError: # pragma: no cover — script-style invocation
59
+ import sys as _sys
60
+ from pathlib import Path as _Path
61
+ _sys.path.insert(0, str(_Path(__file__).resolve().parent))
62
+ from _lib.agent_settings import load_agent_settings # type: ignore[import-not-found]
63
+
64
+ data = load_agent_settings(project_path=SETTINGS_FILE)
65
+ mem = data.get("memory")
64
66
  if not isinstance(mem, dict):
65
67
  return False
66
68
  intake = mem.get("intake")