@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +48 -0
- package/README.md +47 -21
- package/docs/DISTRIBUTION_CHECKLIST.md +2 -2
- package/docs/catalog.md +4 -3
- package/docs/contracts/file-ownership-matrix.json +27 -0
- package/docs/contracts/mcp-discovery-phase-notice.md +60 -0
- package/docs/contracts/mcp-tool-stub-envelope.md +78 -0
- package/docs/getting-started.md +1 -1
- package/docs/setup/mcp-client-config.md +94 -13
- package/docs/setup/mcp-cloud-setup.md +32 -1
- package/docs/setup/per-ide/claude-desktop.md +32 -7
- package/package.json +1 -1
- package/scripts/_lib/script_output.py +15 -11
- package/scripts/ai_council/session.py +14 -8
- package/scripts/chat_history.py +29 -53
- package/scripts/command_suggester/settings.py +15 -13
- package/scripts/compile_router.py +13 -9
- package/scripts/compress.py +22 -19
- package/scripts/council_cli.py +9 -3
- package/scripts/mcp_parity_smoke.py +20 -2
- package/scripts/mcp_server/catalog.py +125 -0
- package/scripts/mcp_server/consumer_tool_catalog.json +275 -0
- package/scripts/mcp_server/telemetry.py +128 -0
- package/scripts/mcp_server/tools.py +474 -15
- package/scripts/mcp_telemetry_health.py +214 -0
- package/scripts/mcp_telemetry_query.py +203 -0
- package/scripts/mcp_telemetry_store.py +211 -0
- package/scripts/memory_signal.py +12 -10
- package/scripts/pack_mcp_content.py +18 -4
- package/templates/claude_desktop_config.json.template +4 -3
|
@@ -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())
|
package/scripts/memory_signal.py
CHANGED
|
@@ -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
|
-
|
|
61
|
-
except
|
|
62
|
-
|
|
63
|
-
|
|
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")
|