Files
chemenu/tools/trace_ingest.py
T
torben 18ae28f918
CI / verify (push) Failing after 32s
Release / release (push) Successful in 38s
Chemenu 2.1.0 - deterministischer Wissenskompiler
Chemenu kompiliert Rohnotizen zu einem verlinkten, quellengebundenen Wiki:
raw/ -> types/ + tools/ -> kb/ -> reports/. Was mechanisch ist, macht
tools/wikitool; was Urteil braucht, macht ein Agent unter Contracts, deren
Grenzen in Code durchgesetzt sind statt im Prompt.

Dieser Commit ist der Startpunkt der oeffentlichen Historie. Die vorherige
Entwicklung fand in einer privaten Instanz statt und ist nicht Teil dieses
Repositorys; ihre Erzaehlung steht vollstaendig in CHANGES.md, das mit 44
Eintraegen von 0.1.0 bis 2.1.0 erhalten geblieben ist.

Der mitgelieferte Korpus ist ein Testbett und eine Demo: 170 Seiten ueber den
Stack selbst - Gates, Lint, Versionierung, Suche, das Wiki-Muster. Er
dokumentiert das Werkzeug mit den eigenen Mitteln des Werkzeugs.

Lizenz: AGPL-3.0 fuer den Stack (tools/, types/), CC-BY-4.0 fuer die Inhalte.
Die Grenze zwischen beiden ist der Dateiplan, den dist export berechnet -
siehe NOTICE.
2026-09-01 16:26:14 +02:00

272 lines
9.9 KiB
Python
Executable File

#!/usr/bin/env python3
"""Turn a harness hook payload into a trace event.
Hooks call this, never `wikitool`. A hook fires on every tool call, and every
`wikitool` invocation is counted by the Iteration Budget Gate - wiring telemetry
through the CLI would let the act of observing a session end it. This entry
point imports the telemetry package as a library instead, which costs no budget
and needs no venv: `chemenu.telemetry` is stdlib-only on purpose.
Contract with the harness:
* stdin is the hook's JSON payload; a missing, empty or malformed payload is not
an error worth failing a tool call over.
* stdout stays empty. Claude Code and Copilot CLI both read a hook's stdout as a
decision document, so anything printed here could alter what the agent does.
`--dry-run` is the exception, and it is for humans and tests.
* the exit code is always 0. A hook that exits non-zero can block a tool call,
and an observer must never do that.
Usage from a hook:
tools/trace_ingest.py --source copilot-cli --event tool.pre
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from chemenu.telemetry import schema # noqa: E402
from chemenu.telemetry.writer import write_event # noqa: E402
# Harness event name -> our event name. The keys are what each vendor documents;
# the values are the vocabulary in schema.py. A harness that cannot report an
# event class simply has no row for it - Mistral Vibe has three.
EVENT_MAPS: dict[str, dict[str, str]] = {
"claude-code": {
"SessionStart": "session.start",
"SessionEnd": "session.end",
"UserPromptSubmit": "prompt.submitted",
"PreToolUse": "tool.pre",
"PostToolUse": "tool.post",
"PostToolUseFailure": "tool.error",
"SubagentStart": "subagent.start",
"SubagentStop": "subagent.stop",
"InstructionsLoaded": "instructions.loaded",
"PreCompact": "compaction",
"PostCompact": "compaction",
"Stop": "turn.end",
},
# Copilot CLI serves two payload dialects, chosen by how the event is
# spelled in the hook config: camelCase names give camelCase fields,
# PascalCase names give the VS Code / Claude snake_case shape. Both spellings
# are mapped so a trace stays readable whichever a config uses.
"copilot-cli": {
"sessionStart": "session.start",
"SessionStart": "session.start",
"sessionEnd": "session.end",
"SessionEnd": "session.end",
"userPromptSubmitted": "prompt.submitted",
"UserPromptSubmit": "prompt.submitted",
"preToolUse": "tool.pre",
"PreToolUse": "tool.pre",
"postToolUse": "tool.post",
"PostToolUse": "tool.post",
"postToolUseFailure": "tool.error",
"PostToolUseFailure": "tool.error",
"errorOccurred": "session.error",
"ErrorOccurred": "session.error",
"subagentStart": "subagent.start",
"subagentStop": "subagent.stop",
"SubagentStop": "subagent.stop",
"preCompact": "compaction",
"PreCompact": "compaction",
"agentStop": "turn.end",
"Stop": "turn.end",
},
"mistral-vibe": {
"pre_tool": "tool.pre",
"post_tool": "tool.post",
"post_agent": "turn.end",
},
}
# Normalised field -> the spellings the three harnesses use, in priority order.
FIELD_ALIASES: dict[str, tuple[str, ...]] = {
"tool_name": ("tool_name", "toolName", "tool"),
"tool_call_id": ("tool_call_id", "tool_use_id", "toolCallId", "toolUseId"),
"tool_input": ("tool_input", "toolArgs", "tool_args", "toolInput"),
"tool_output": ("tool_output_text", "tool_output", "tool_response", "toolResult"),
"tool_status": ("tool_status", "status", "toolStatus"),
"duration_ms": ("duration_ms", "durationMs"),
"error": ("tool_error", "error", "errorMessage"),
"error_context": ("error_context", "errorContext"),
"prompt": ("prompt", "userPrompt", "promptText", "initialPrompt", "initial_prompt"),
"message": ("last_assistant_message", "assistantMessage", "response", "message"),
"cwd": ("cwd", "workingDirectory"),
"transcript_path": ("transcript_path", "transcriptPath"),
"file_path": ("file_path", "filePath"),
"agent_type": ("agent_type", "agentType", "subagent_type", "agentName", "agent_name"),
"parent_session_id": ("parent_session_id", "parentSessionId"),
"stop_reason": ("stop_reason", "stopReason"),
"reason": ("reason",),
"trigger": ("trigger",),
"source_kind": ("source",),
}
# Values that live one level down. Copilot's postToolUse wraps the text the
# model saw in a result object rather than handing it over flat.
NESTED_ALIASES: dict[str, tuple[tuple[str, str], ...]] = {
"tool_output": (
("toolResult", "textResultForLlm"),
("tool_result", "text_result_for_llm"),
),
}
SESSION_ALIASES = ("session_id", "sessionId", "session")
# Leftover payload keys worth keeping. Anything else is vendor bookkeeping that
# would only bloat the trace - a trace is scoring material, not an archive of
# the vendor's payload.
EXTRA_SCALAR_TYPES = (str, int, float, bool)
def _first(payload: dict, names: tuple[str, ...]):
for name in names:
if name in payload and payload[name] not in (None, ""):
return payload[name]
return None
def _as_text(value) -> str:
if isinstance(value, str):
return value
return json.dumps(value, ensure_ascii=False, sort_keys=True)
def _nested(payload: dict, paths: tuple[tuple[str, str], ...]):
for outer, inner in paths:
container = payload.get(outer)
if isinstance(container, dict) and container.get(inner):
return container[inner]
return None
def _error_text(value) -> str:
"""`postToolUseFailure` hands over a string, `errorOccurred` an object."""
if isinstance(value, dict):
return value.get("message") or _as_text(value)
return _as_text(value)
def resolve_event(source: str, payload: dict, explicit: str | None) -> str | None:
"""`--event` wins: not every harness names its event inside the payload."""
if explicit:
return explicit
raw = payload.get("hook_event_name") or payload.get("hookEventName")
if not raw:
return None
return EVENT_MAPS.get(source, {}).get(raw)
def normalise(source: str, event: str, payload: dict) -> dict:
attrs: dict = {"harness_event": payload.get("hook_event_name") or payload.get("hookEventName")}
for field, aliases in FIELD_ALIASES.items():
value = _first(payload, aliases)
if value is None:
continue
if field == "error":
attrs[field] = _error_text(value)
elif field in ("tool_input", "tool_output"):
attrs[field] = _as_text(value)
else:
attrs[field] = value
for field, paths in NESTED_ALIASES.items():
nested = _nested(payload, paths)
if nested is not None:
attrs[field] = _as_text(nested)
known = set(SESSION_ALIASES) | {"hook_event_name", "hookEventName", "timestamp"}
for aliases in FIELD_ALIASES.values():
known.update(aliases)
for paths in NESTED_ALIASES.values():
known.update(outer for outer, _ in paths)
extra = {
key: value
for key, value in payload.items()
if key not in known and isinstance(value, EXTRA_SCALAR_TYPES)
}
if extra:
attrs["extra"] = extra
# A trace has to say what its harness *could* have reported, or a scorer
# cannot tell "never happened" from "not observable here".
if event == "session.start":
attrs["harness"] = source
attrs["completeness"] = list(schema.HARNESS_CAPABILITIES.get(source, ()))
return {key: value for key, value in attrs.items() if value is not None}
def build(source: str, payload: dict, explicit_event: str | None, session: str | None):
event = resolve_event(source, payload, explicit_event)
if event is None:
return None, None, None
session_id = session or _first(payload, SESSION_ALIASES) or os.environ.get(
"WIKITOOL_SESSION_ID"
)
return event, normalise(source, event, payload), session_id
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source", required=True, choices=sorted(EVENT_MAPS))
parser.add_argument(
"--event",
help="Our event name, e.g. tool.pre. Required for harnesses that do not "
"name the event in the payload (Copilot CLI).",
)
parser.add_argument("--session", help="Override the session id from the payload.")
parser.add_argument(
"--dry-run",
action="store_true",
help="Print the event instead of appending it. Never use this from a hook: "
"some harnesses read a hook's stdout as a decision.",
)
args = parser.parse_args()
raw = sys.stdin.read() if not sys.stdin.isatty() else ""
try:
payload = json.loads(raw) if raw.strip() else {}
except json.JSONDecodeError:
payload = {}
if not isinstance(payload, dict):
payload = {}
event, attrs, session = build(args.source, payload, args.event, args.session)
if event is None:
if args.dry_run:
sys.stderr.write("no event mapping for this payload\n")
return 1
return 0
if args.dry_run:
record = schema.make_event(
args.source, event, attrs, session_id=session or "dry-run", seq=1
)
errors = schema.validation_errors(record)
if errors:
sys.stderr.write("; ".join(errors) + "\n")
return 1
sys.stdout.write(json.dumps(record, ensure_ascii=False) + "\n")
return 0
write_event(args.source, event, attrs, session=session)
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except SystemExit:
raise
except BaseException: # noqa: BLE001 - an observer never breaks the observed
sys.exit(0)