diff --git a/.github/workflows/aeon.yml b/.github/workflows/aeon.yml index 399d3f88cf1..1ba349fac77 100644 --- a/.github/workflows/aeon.yml +++ b/.github/workflows/aeon.yml @@ -92,6 +92,10 @@ on: description: 'Correlation ID for chain dispatches' required: false type: string + trigger: + description: 'What started the run: cron (Scheduler) or chain (chain-runner); empty = manual' + required: false + type: string workflow_call: inputs: skill: @@ -118,6 +122,10 @@ on: description: 'Correlation ID for chain dispatches' required: false type: string + trigger: + description: 'What started the run: cron (Scheduler) or chain (chain-runner); empty = manual' + required: false + type: string issues: types: [labeled] @@ -137,6 +145,10 @@ env: # actually see them. (Step-level env is NOT available in step `if:` clauses.) FLEET_ENDPOINT: ${{ secrets.FLEET_ENDPOINT }} FLEET_TOKEN: ${{ secrets.FLEET_TOKEN }} + # Langfuse trigger label (scripts/langfuse-otel.sh): the Scheduler passes + # trigger=cron and chain-runner trigger=chain, so a cron run, a chain step and + # a manual Run now no longer all read workflow_dispatch. + AEON_DISPATCH_TRIGGER: ${{ inputs.trigger }} concurrency: # Keyed by skill name alone, `var` was ignored entirely: dispatching the @@ -1773,11 +1785,15 @@ jobs: # below is tuned for that (composer/pi/vibe/kimi lack a native schema flag). RH="${GITHUB_WORKSPACE}/harness-adapter/run-harness" SCORE_ARGS=(--mode read-only --no-sandbox --timeout 300) + SCORE_CWD="$GITHUB_WORKSPACE" case "$HARNESS" in claude) # Runs under the gateway env sourced above; SCORE_MODEL is the cheap - # scoring model (haiku by default). - SCORE_ARGS+=(--model "$SCORE_MODEL") ;; + # scoring model (haiku by default). Lean, like the feed render: + # --no-tools drops the tool schemas and an empty scratch cwd skips the + # repo CLAUDE.md, which made every grade carry ~37k tokens. + SCORE_ARGS+=(--model "$SCORE_MODEL" --no-tools) + SCORE_CWD=$(mktemp -d "${RUNNER_TEMP:-/tmp}/scorer.XXXXXX") ;; grok) # Pin grok-4.7, the grok harness default and a model the X-account # (GROK_CREDENTIALS) login exposes (`grok models`, verified 2026-10-01). @@ -1790,7 +1806,7 @@ jobs: # RH_MODEL_ARG is the same --model the run used (empty = harness default). [ -n "${RH_MODEL_ARG:-}" ] && SCORE_ARGS+=(--model "$RH_MODEL_ARG") ;; esac - if ! RAW=$(echo "$ANALYSIS_PROMPT" | bash "$RH" "$HARNESS" "${SCORE_ARGS[@]}" 2>/dev/null); then + if ! RAW=$(cd "$SCORE_CWD" && echo "$ANALYSIS_PROMPT" | bash "$RH" "$HARNESS" "${SCORE_ARGS[@]}" 2>/dev/null); then echo "::notice::Skill scoring skipped — $HARNESS scorer unavailable (non-fatal)" exit 0 fi @@ -1977,7 +1993,10 @@ jobs: # !cancelled() (NOT a bare `mode != ''`, which GitHub implies success() onto # and would skip on a failed skill): a FAILED skill still delivers what it # queued (#912 R1). Cancellation is excluded so a killed job doesn't half-send. - if: ${{ !cancelled() && steps.work.outputs.mode != '' }} + # Default branch only, like the health-issue step: a test dispatch on a + # verify/* branch must not send real messages. Set the repo variable + # NOTIFY_ON_BRANCH=1 to see a test card; skipped payloads are dropped. + if: ${{ !cancelled() && steps.work.outputs.mode != '' && (github.ref_name == github.event.repository.default_branch || vars.NOTIFY_ON_BRANCH == '1') }} env: # #912 Phase 2: THIS step owns the channel tokens. ./notify (the queue-writer) # no longer reads them in the skill run env; notify-deliver.sh consumes them diff --git a/.github/workflows/chain-runner.yml b/.github/workflows/chain-runner.yml index 42adb6a4f63..01b1420dba8 100644 --- a/.github/workflows/chain-runner.yml +++ b/.github/workflows/chain-runner.yml @@ -101,9 +101,16 @@ jobs: expected_title+=" [dispatch: $dispatch_id]" echo " Dispatching: $skill" - if ! gh workflow run aeon.yml "${args[@]}"; then - echo "::error::gh workflow run failed for skill: $skill" - return 1 + # trigger=chain labels the run as a chain step (Langfuse trigger). An + # aeon.yml without the trigger input rejects the field before any run + # starts, so retry once without it. + local out + if ! out=$(gh workflow run aeon.yml "${args[@]}" -f trigger=chain 2>&1); then + if ! { printf '%s' "$out" | grep -qi 'unexpected inputs' && gh workflow run aeon.yml "${args[@]}"; }; then + printf '%s\n' "$out" >&2 + echo "::error::gh workflow run failed for skill: $skill" + return 1 + fi fi # Poll for the run to appear (up to 60s). Match the exact run-name diff --git a/.github/workflows/scheduler.yml b/.github/workflows/scheduler.yml index 9f26f1556d3..740b13d136f 100644 --- a/.github/workflows/scheduler.yml +++ b/.github/workflows/scheduler.yml @@ -491,12 +491,19 @@ jobs: VAR="${SKILL_VAR[$SKILL]:-}" DISPATCH_OK=true - if [ -n "$VAR" ]; then - echo "Dispatching: $SKILL (var: $VAR)${SKILL_DEPS[$SKILL]:+ [depends_on: ${SKILL_DEPS[$SKILL]}]}" - gh workflow run aeon.yml -f skill="$SKILL" -f var="$VAR" || DISPATCH_OK=false - else - echo "Dispatching: $SKILL${SKILL_DEPS[$SKILL]:+ [depends_on: ${SKILL_DEPS[$SKILL]}]}" - gh workflow run aeon.yml -f skill="$SKILL" || DISPATCH_OK=false + # trigger=cron labels the run as scheduled (Langfuse trigger). An + # aeon.yml without the trigger input (an older fork) rejects the field + # before any run starts, so retry once without it. + ARGS=(-f skill="$SKILL") + [ -n "$VAR" ] && ARGS+=(-f var="$VAR") + echo "Dispatching: $SKILL${VAR:+ (var: $VAR)}${SKILL_DEPS[$SKILL]:+ [depends_on: ${SKILL_DEPS[$SKILL]}]}" + if ! OUT=$(gh workflow run aeon.yml "${ARGS[@]}" -f trigger=cron 2>&1); then + if printf '%s' "$OUT" | grep -qi 'unexpected inputs'; then + gh workflow run aeon.yml "${ARGS[@]}" || DISPATCH_OK=false + else + printf '%s\n' "$OUT" >&2 + DISPATCH_OK=false + fi fi # Stamp state ONLY for a dispatch GitHub actually accepted. A rejected diff --git a/AGENTS.md b/AGENTS.md index 26305108c90..16dc41bdd3e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,7 +95,7 @@ At the start of every task, read `memory/MEMORY.md` for high-level context and c After completing any task, append a log entry to `memory/logs/YYYY-MM-DD.md` under a `### ` heading, as bullet points (the health loop parses this shape). **Exception: `mode: read-only` skills don't self-log** - the workflow appends that `### ` entry for you from your final output (see Capability mode), so a self-written entry would be a duplicate. ### Memory structure -- **`memory/MEMORY.md`** — Short index (~50 lines): current goals, active topics, and pointers to topic files. A table of contents, not a dumping ground. +- **`memory/MEMORY.md`** - Short index (under 8 KB, ~50 lines, no line over 300 chars): current goals, active topics, and pointers to topic files. A table of contents, not a dumping ground. - **`memory/topics/`** — Detailed notes by topic (e.g. `crypto.md`, `research.md`). When a topic outgrows a few lines in MEMORY.md, move it here and link. - **`memory/logs/`** — Daily activity logs (`YYYY-MM-DD.md`), append-only. - **`memory/issues/`** — Structured issue tracker for skill failures and degradations. **The health skill (`skill-health`) files issues; repair skills (`skill-repair`) close them.** The schema (frontmatter fields, severity, categories, lifecycle) is owned by `skills/skill-health/SKILL.md`; the end-to-end loop is documented in `docs/CORE.md`. Only active once `INDEX.md` exists. diff --git a/CLAUDE.md b/CLAUDE.md index 2f6395d0f56..2516bd5e6c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,7 @@ At the start of every task, read `memory/MEMORY.md` for high-level context and c After completing any task, append a log entry to `memory/logs/YYYY-MM-DD.md` under a `### ` heading, as bullet points (the health loop parses this shape). **Exception: `mode: read-only` skills don't self-log** - the workflow appends that `### ` entry for you from your final output (see Capability mode), so a self-written entry would be a duplicate. ### Memory structure -- **`memory/MEMORY.md`** — Short index (~50 lines): current goals, active topics, and pointers to topic files. A table of contents, not a dumping ground. +- **`memory/MEMORY.md`** - Short index (under 8 KB, ~50 lines, no line over 300 chars): current goals, active topics, and pointers to topic files. A table of contents, not a dumping ground. - **`memory/topics/`** — Detailed notes by topic (e.g. `crypto.md`, `research.md`). When a topic outgrows a few lines in MEMORY.md, move it here and link. - **`memory/logs/`** — Daily activity logs (`YYYY-MM-DD.md`), append-only. - **`memory/issues/`** — Structured issue tracker for skill failures and degradations. **The health skill (`skill-health`) files issues; repair skills (`skill-repair`) close them.** The schema (frontmatter fields, severity, categories, lifecycle) is owned by `skills/skill-health/SKILL.md`; the end-to-end loop is documented in `docs/CORE.md`. Only active once `INDEX.md` exists. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 7dab31f6046..46e94871391 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -345,6 +345,8 @@ Set the secret → channel activates. No code changes needed. Want ~1s Telegram replies instead of up-to-5-min polling? See [Telegram instant mode](../apps/webhook/README.md). +**Test runs on other branches stay quiet.** Only runs on the default branch deliver notifications; a skill dispatched on a test branch (e.g. `verify/*`) queues its message and drops it. Set the repo variable `NOTIFY_ON_BRANCH=1` to deliver from every branch while you test a card. + ## Audit log Every run writes an append-only JSON-lines record of the privileged actions that reached outside it - notify sends, `secretcurl` calls, and git pushes - and uploads it as the `audit-log-` artifact on the run (kept 30 days). It answers "what did this run actually do" without reading the whole Actions transcript. One line per action: diff --git a/docs/langfuse.md b/docs/langfuse.md index 20bc0857428..73596d496c7 100644 --- a/docs/langfuse.md +++ b/docs/langfuse.md @@ -52,7 +52,13 @@ when the run fails). It carries the trace name (`aeon::`), tag `skill:` and `component:`, and one `usage ` GENERATION per model with the run's real token counts from the envelope's `modelUsage`. Langfuse prices those from its own model table, so traces, sessions and the cost -dashboards show real tokens and dollars. Claude Code's own `total_cost_usd` is +dashboards show real tokens and dollars. Each row sends +`langfuse.observation.usage_details` with the Langfuse price keys (`input`, +`output`, `input_cache_read`, `input_cache_creation_1h`, `input_cache_creation_5m`), +so 1-hour cache writes (what Claude Code on a subscription writes) get the 1h +price, 2x input, not the 5-minute 1.25x. The 1h/5m split comes from the +envelope's `usage.cache_creation`; with more than one model each gets the run's +1h share of its writes. Claude Code's own `total_cost_usd` is kept only as `aeon.cost_usd_reported` (it misprices model ids it does not know). The json-render feed convert (`scripts/notify-jsonrender.sh`) calls `claude -p` @@ -105,7 +111,10 @@ first: rc 124 is `timeout`, `error_max_*` is `budget_cap`, API status 429 / output of a run that left no envelope) goes through the shared rule table. That text only picks the class and is never sent. The scorer root gets the same fields. Tool spans come from Claude Code and carry no class. Its metadata -carries `permission_denials`, the number of tool calls the allowlist refused. Trace metadata also carries `instance` (repo name) and `trigger` (event). +carries `permission_denials`, the number of tool calls the allowlist refused. Trace metadata also carries `instance` (repo name) and `trigger`: `cron` for a +Scheduler dispatch (it passes the `trigger=cron` input), `chain` for a chain step +(chain-runner passes `trigger=chain`), `manual` for any other Run now, else the +event name. The usage rows carry the same trace name and tags, so cost groups by skill. They are zero-length at the end of the run and carry metadata `rollup=true` (a per-run token rollup, not a model call), so per-model latency charts show only diff --git a/harness-adapter/README.md b/harness-adapter/README.md index 13285be7877..f4d56ea6091 100644 --- a/harness-adapter/README.md +++ b/harness-adapter/README.md @@ -131,6 +131,7 @@ ones by name. Proven in CI against a fake model and a fake stdio server (fast, | `--timeout ` | wall-clock guard (default 600) | | `--no-sandbox` | skip the wrapper OS sandbox on read-only runs | | `--no-compat-rules` | skip the Claude-idiom preamble on non-claude harnesses | +| `--no-tools` | claude: no tools at all (`--tools ""`), for one-shot text calls like the post-run scorer; other harnesses ignore it | ## How each layer is translated diff --git a/harness-adapter/adapters/claude.sh b/harness-adapter/adapters/claude.sh index 760804a7185..6352c7011fe 100755 --- a/harness-adapter/adapters/claude.sh +++ b/harness-adapter/adapters/claude.sh @@ -57,6 +57,8 @@ command -v claude >/dev/null 2>&1 || { ARGS=(-p - --output-format json --permission-mode default) [ -n "${RH_MODEL:-}" ] && ARGS+=(--model "$RH_MODEL") [ -n "${RH_ALLOWED_TOOLS:-}" ] && ARGS+=(--allowedTools "$RH_ALLOWED_TOOLS") +# --no-tools: no tool schemas in the prompt (a one-shot text call like the scorer). +[ "${RH_NO_TOOLS:-}" = 1 ] && ARGS+=(--tools "") if [ -n "${RH_MCP_CONFIG:-}" ] && [ -f "${RH_MCP_CONFIG:-}" ]; then ARGS+=(--mcp-config "$RH_MCP_CONFIG" --strict-mcp-config) fi diff --git a/harness-adapter/lib/otel-span.sh b/harness-adapter/lib/otel-span.sh index d59d2609524..0cc72fd799b 100644 --- a/harness-adapter/lib/otel-span.sh +++ b/harness-adapter/lib/otel-span.sh @@ -237,6 +237,10 @@ rh_trace_end() { elif $e.usage then [{m: $model, in: ($e.usage.input_tokens // 0), out: ($e.usage.output_tokens // 0), cr: ($e.usage.cache_read_input_tokens // 0), cc: ($e.usage.cache_creation_input_tokens // 0)}] else [] end) as $gens + # Cache-write TTL split. Only the top-level usage has it (modelUsage gives one + # cacheCreationInputTokens per model), so each model gets the 1h share of the run + # of its writes. Claude Code writes 1h caches, priced 2x input vs 1.25x for 5m. + | [($e.usage.cache_creation.ephemeral_1h_input_tokens // 0), ($e.usage.cache_creation.ephemeral_5m_input_tokens // 0)] as [$w1h, $w5m] | {resourceSpans: [{ resource: {attributes: ([$ra[] | s(.k; .v)] + (if $rm["service.name"] then [] else [s("service.name"; $svc)] end))}, scopeSpans: [{scope: {name: "aeon.run-harness"}, spans: ( @@ -257,17 +261,26 @@ rh_trace_end() { status: {code: (if $err then 2 else 1 end)}} + (if $parent != "" then {parentSpanId: $parent} else {} end)] + [$gens | to_entries[] | select(.key < ($gids | length)) | .key as $n | .value as $g + # langfuse.observation.usage_details wins over the gen_ai.usage.* counts + # in Langfuse and names the priced keys, so 1h cache writes get the 1h + # price (gen_ai.usage.* has one cache-write count, priced at the 5m + # rate). Without a TTL split it sends the one count as before. + | (if ($w1h + $w5m) > 0 and $g.cc > 0 + then (($g.cc * $w1h / ($w1h + $w5m)) | round) as $c1h + | {input_cache_creation_1h: $c1h, input_cache_creation_5m: ($g.cc - $c1h)} + else {input_cache_creation: $g.cc} end) as $cw # Zero-length at the end of the run: the row is a per-run token rollup, # not a model call, so giving it the whole run duration skewed the # per-model latency charts. metadata.rollup lets views filter it out. | {traceId: $tid, spanId: $gids[$n], parentSpanId: $sid, name: ("usage " + $g.m), kind: 3, startTimeUnixNano: $end, endTimeUnixNano: $end, - # Langfuse treats gen_ai.usage.input_tokens as cache-inclusive and - # subtracts the two cache counts from it; Anthropic reports input - # without cache, so send the sum to get the real input back. + # The gen_ai.usage.* counts stay for other OTLP backends. They read + # input_tokens as cache-inclusive and subtract the two cache counts; + # Anthropic reports input without cache, so send the sum. attributes: ($lfa + [s("langfuse.observation.type"; "generation"), s("gen_ai.system"; "anthropic"), s("langfuse.observation.metadata.rollup"; "true"), s("gen_ai.request.model"; $g.m), s("gen_ai.response.model"; $g.m), + s("langfuse.observation.usage_details"; {input: $g.in, output: $g.out, input_cache_read: $g.cr} + $cw | tojson), i("gen_ai.usage.input_tokens"; $g.in + $g.cr + $g.cc), i("gen_ai.usage.output_tokens"; $g.out), i("gen_ai.usage.cache_read_input_tokens"; $g.cr), i("gen_ai.usage.cache_creation_input_tokens"; $g.cc)])}] )}]}]}' > "$body" 2>/dev/null; then diff --git a/harness-adapter/run-harness b/harness-adapter/run-harness index 27e2dcbb2c5..8795d4dfcfd 100755 --- a/harness-adapter/run-harness +++ b/harness-adapter/run-harness @@ -14,6 +14,7 @@ # --timeout wall-clock guard (default 600) # --no-sandbox skip the wrapper OS sandbox on read-only runs # --no-compat-rules skip the Claude-idiom compatibility preamble on non-claude harnesses +# --no-tools claude: no tools at all (--tools ""), for one-shot text calls; others ignore it # # contract (stdout): {"result": "...", "usage": {input_tokens, output_tokens, # cache_read_input_tokens, cache_creation_input_tokens}, [session_id], [total_cost_usd]} @@ -34,7 +35,7 @@ HARNESS="$1"; shift [ -f "$HERE/adapters/$HARNESS.sh" ] || { echo "unknown harness '$HARNESS'" >&2; usage; } MODEL="" MODE="" ALLOWED="" MCP="" MAXT="" SCHEMA="" APPEND="" -TIMEOUT=600 SANDBOX=auto COMPAT=auto +TIMEOUT=600 SANDBOX=auto COMPAT=auto NOTOOLS= while [ $# -gt 0 ]; do case "$1" in --model) MODEL="$2"; shift 2 ;; @@ -47,6 +48,7 @@ while [ $# -gt 0 ]; do --timeout) TIMEOUT="$2"; shift 2 ;; --no-sandbox) SANDBOX=off; shift ;; --no-compat-rules) COMPAT=off; shift ;; + --no-tools) NOTOOLS=1; shift ;; --output-format) [ "$2" = "json" ] || { echo "only --output-format json is supported" >&2; exit 2; }; shift 2 ;; *) echo "unknown option: $1" >&2; usage ;; esac @@ -114,7 +116,7 @@ export RH_HARNESS="$HARNESS" export RH_MODEL="$MODEL" RH_MODE="$MODE" RH_ALLOWED_TOOLS="$ALLOWED" export RH_MCP_CONFIG="$MCP" RH_MAX_TURNS="$MAXT" RH_JSON_SCHEMA="$SCHEMA" export RH_APPEND_SYSTEM_PROMPT="$APPEND" RH_COMPAT_RULES="$COMPAT_RULES" -export RH_TIMEOUT="$TIMEOUT" +export RH_TIMEOUT="$TIMEOUT" RH_NO_TOOLS="$NOTOOLS" CMD=(bash "$HERE/adapters/$HARNESS.sh") diff --git a/scripts/fleet-scorecard.mjs b/scripts/fleet-scorecard.mjs index 7dc850be1b5..16ecbcb3e5d 100755 --- a/scripts/fleet-scorecard.mjs +++ b/scripts/fleet-scorecard.mjs @@ -100,7 +100,10 @@ for (const repo of repos) { // ---- pricing + formatters ---- // Anthropic list prices, $ per 1M tokens, keyed by model VERSION (a family regex // alone mispriced every row: Opus 4.5+ is 5/25, not the old 15/75). Cache write -// is 1.25x input (5-minute TTL); cache read is the listed read price. +// is 2x input, the 1-hour TTL price: Claude Code on a Claude subscription writes +// 1h caches (usage.cache_creation.ephemeral_1h_input_tokens), and token-usage.csv +// has no TTL split. The 5-minute price (1.25x) priced every write 37.5% low. +// Cache read is the listed read price. // Checked against models.dev (Anthropic rows) 2026-10-16. // Ids are normalized first (vendor prefix dropped, trailing -YYYYMMDD dropped, // dots to dashes), so claude-haiku-4-5-20251001, anthropic/claude-opus-5.5 and @@ -144,7 +147,7 @@ function priceFor(model) { if (family) row = CLAUDE_PRICES[CLAUDE_FAMILY_FALLBACK[family]]; } if (!row) return null; - return { in: row.in / 1e6, out: row.out / 1e6, cw: (row.in * 1.25) / 1e6, cr: row.cr / 1e6 }; + return { in: row.in / 1e6, out: row.out / 1e6, cw: (row.in * 2) / 1e6, cr: row.cr / 1e6 }; } const commafy = (x) => Math.trunc(x).toLocaleString('en-US'); const hum = (n) => n >= 1e9 ? `${(n / 1e9).toFixed(2)}B` : n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}K` : `${Math.trunc(n)}`; diff --git a/scripts/langfuse-otel.sh b/scripts/langfuse-otel.sh index 013cd68d6af..f407738eec2 100755 --- a/scripts/langfuse-otel.sh +++ b/scripts/langfuse-otel.sh @@ -146,7 +146,19 @@ fi if [ -n "${GITHUB_REPOSITORY:-}" ]; then _lf_attrs="${_lf_attrs},aeon.repo=${GITHUB_REPOSITORY},langfuse.trace.metadata.instance=${GITHUB_REPOSITORY#*/}" fi -if [ -n "${GITHUB_EVENT_NAME:-}" ]; then _lf_attrs="${_lf_attrs},langfuse.trace.metadata.trigger=${GITHUB_EVENT_NAME}"; fi +# trigger: the Scheduler and chain-runner start every skill with workflow_dispatch, +# so the event name alone cannot tell a cron run from a manual one. aeon.yml sets +# AEON_DISPATCH_TRIGGER from its trigger input (cron from the Scheduler, chain +# from chain-runner, empty for Run now). Only those values are used: the input +# is free text. Other workflows (messages.yml) do not set it and keep the event. +_lf_trig="${GITHUB_EVENT_NAME:-}" +if [ "$_lf_trig" = workflow_dispatch ] && [ -n "${AEON_DISPATCH_TRIGGER+x}" ]; then + case "${AEON_DISPATCH_TRIGGER:-}" in + cron | chain) _lf_trig="$AEON_DISPATCH_TRIGGER" ;; + *) _lf_trig=manual ;; + esac +fi +if [ -n "$_lf_trig" ]; then _lf_attrs="${_lf_attrs},langfuse.trace.metadata.trigger=${_lf_trig}"; fi if [ -n "${GITHUB_RUN_ATTEMPT:-}" ];then _lf_attrs="${_lf_attrs},aeon.run_attempt=${GITHUB_RUN_ATTEMPT}"; fi export OTEL_RESOURCE_ATTRIBUTES="$_lf_attrs" @@ -180,5 +192,5 @@ if [ -n "${SKILL_NAME:-}" ] && [ -f "$_lf_md" ]; then fi echo "::notice::Langfuse tracing on → ${_lf_host} (component=${AEON_OTEL_COMPONENT:-skill-run}, env=${_lf_env}, content=${LANGFUSE_LOG_CONTENT:-1})" >&2 -unset _lf_host _lf_auth _lf_attrs _lf_seed _lf_tid _lf_env _lf_default _lf_rel _lf_state _lf_md _lf_ver +unset _lf_host _lf_auth _lf_attrs _lf_seed _lf_tid _lf_env _lf_default _lf_rel _lf_state _lf_md _lf_ver _lf_trig return 0 2>/dev/null || exit 0 diff --git a/scripts/memory_prep.py b/scripts/memory_prep.py index b0558a5b033..c30228e7f37 100644 --- a/scripts/memory_prep.py +++ b/scripts/memory_prep.py @@ -18,7 +18,8 @@ Subcommands: window pre-pass: rotate old logs, then print the scan window + the exact - list of in-window log files to stdout for the model to read. + list of in-window log files to stdout for the model to read, and + MEMORY.md's size against its byte / line-length budget. stamp post-pass: write today's date to the watermark file, record a fingerprint of every recent log, and mirror the date into the MEMORY.md prose line. @@ -48,6 +49,10 @@ SCAN_CLAMP_DAYS = 14 # a gap longer than this clamps the scan window DEFAULT_LOOKBACK_DAYS = 3 # no watermark at all -> read the last 3 days MAX_IDLE_DAYS = 7 # never skip a flush when the last one is this old (pruning still matters) +# MEMORY.md loads into every skill run, so its budget is bytes, not only lines: +# a ~50-line index of 2 KB bullets still costs ~7k tokens on every run. +MEMORY_MAX_BYTES = 8192 +MEMORY_MAX_LINE = 300 # chars; a longer line is detail that belongs in a topic file SKILL = "memory-flush" _DAILY_RE = re.compile(r"^(\d{4})-(\d{2})-(\d{2})\.md$") @@ -160,6 +165,26 @@ def rotatable_months(filenames, today, threshold=ROTATE_THRESHOLD): return out +def memory_size_report(text): + """Lines for the window output: MEMORY.md bytes / lines against the budget and + the over-long lines by number, so the model trims by measurement, not by eye.""" + if text is None: + return ["MEMORY.md: missing."] + size = len(text.encode("utf-8")) + lines = text.splitlines() + long_lines = [(n, len(l)) for n, l in enumerate(lines, 1) if len(l) > MEMORY_MAX_LINE] + over = size > MEMORY_MAX_BYTES + out = ["MEMORY.md size: %d bytes, %d lines (budget %d bytes, ~50 lines, no line over %d chars)%s" % ( + size, len(lines), MEMORY_MAX_BYTES, MEMORY_MAX_LINE, + ": OVER BUDGET by %d bytes, overflow per step 3d." % (size - MEMORY_MAX_BYTES) if over else ": within budget.")] + if long_lines: + worst = sorted(long_lines, key=lambda x: -x[1])[:10] + out.append("Lines over %d chars (line: chars), longest first: %s%s" % ( + MEMORY_MAX_LINE, ", ".join("%d: %d" % w for w in worst), + " (+%d more)" % (len(long_lines) - len(worst)) if len(long_lines) > len(worst) else "")) + return out + + def stamp_memory_md(text, today): """ Return MEMORY.md text with the '*Last consolidated: *' line set to @@ -353,6 +378,7 @@ def do_window(root, today): remaining = len([e for e in entries if parse_daily(e) is not None]) out.append("Log rotation: archived %d month(s); %d daily file(s) remain (threshold %d)." % ( archived_months, remaining, ROTATE_THRESHOLD)) + out.extend(memory_size_report(memory_md)) out.append("Do NOT recompute the window or rotate logs yourself. Run " "`python3 scripts/memory_prep.py stamp` as your final step.") print("\n".join(out)) diff --git a/scripts/tests/test_fleet_scorecard.mjs b/scripts/tests/test_fleet_scorecard.mjs index 446eda5c1a3..ad25b4c902b 100644 --- a/scripts/tests/test_fleet_scorecard.mjs +++ b/scripts/tests/test_fleet_scorecard.mjs @@ -56,8 +56,8 @@ test('prices Claude rows by model version and leaves non-Claude rows unpriced', '2026-10-01,a,claude-sonnet-5,1000000,1000000,0,0', // 2 + 10 = 12 '2026-10-01,a,claude-sonnet-5-5,0,0,10000000,0', // 10M cache reads at 0.10 = 1 (Sonnet 5 reads at 0.20) '2026-10-01,a,claude-sonnet-4-6,1000000,1000000,0,0', // 3 + 15 = 18 - '2026-10-01,a,claude-haiku-4-5-20251001,1000000,1000000,1000000,1000000', // 1 + 5 + read 0.1 + write 1.25 = 7.35 - '2026-10-01,a,claude-haiku-5-5,10000000,10000000,10000000,10000000', // 10M each at a tenth of 4.5's rates = 7.35 + '2026-10-01,a,claude-haiku-4-5-20251001,1000000,1000000,1000000,1000000', // 1 + 5 + read 0.1 + 1h write 2 = 8.1 + '2026-10-01,a,claude-haiku-5-5,10000000,10000000,10000000,10000000', // 10M each at a tenth of 4.5's rates = 8.1 '2026-10-01,b,openai/gpt-5.1-codex-mini,1000000,1000000,0,0', // unpriced '2026-10-01,b,codex-default,1000000,1000000,0,0', // unpriced '2026-10-01,b,grok-4.5,1000000,1000000,0,0', // unpriced @@ -77,7 +77,7 @@ test('prices Claude rows by model version and leaves non-Claude rows unpriced', await import(new URL(`../fleet-scorecard.mjs?test=pricing-${Date.now()}`, import.meta.url)) const metrics = JSON.parse(readFileSync('/tmp/fleet-scorecard/metrics.json', 'utf8')) assert.equal(metrics.generations, 10) - assert.equal(metrics.est_cost_usd, Math.round((24 + 30 + 12 + 1 + 18 + 7.35 + 7.35) * 100) / 100) + assert.equal(metrics.est_cost_usd, Math.round((24 + 30 + 12 + 1 + 18 + 8.1 + 8.1) * 100) / 100) assert.equal(metrics.unpriced_generations, 3) assert.equal(metrics.unpriced_tokens, 6_000_000) const body = readFileSync('/tmp/fleet-scorecard/scorecard-body.md', 'utf8') diff --git a/scripts/tests/test_langfuse_otel.sh b/scripts/tests/test_langfuse_otel.sh index c8cd069385a..6d421fb7a13 100644 --- a/scripts/tests/test_langfuse_otel.sh +++ b/scripts/tests/test_langfuse_otel.sh @@ -18,7 +18,7 @@ unset LANGFUSE_PUBLIC_KEY LANGFUSE_SECRET_KEY LANGFUSE_HOST LANGFUSE_BASE_URL \ OTEL_EXPORTER_OTLP_HEADERS OTEL_LOG_USER_PROMPTS OTEL_LOG_TOOL_CONTENT \ OTEL_RESOURCE_ATTRIBUTES LANGFUSE_TRACING_ENVIRONMENT AEON_LF_VERSION \ GITHUB_REF_NAME GITHUB_EVENT_PATH GITHUB_EVENT_NAME GITHUB_WORKSPACE GITHUB_SHA \ - GITHUB_REPOSITORY 2>/dev/null || true + GITHUB_REPOSITORY AEON_DISPATCH_TRIGGER 2>/dev/null || true # 1. No keys → complete no-op (nothing exported), and returns success under -e. ( set -e @@ -167,6 +167,20 @@ r=$(attrs_for SKILL_NAME=heartbeat GITHUB_REPOSITORY=aaronjmars/aeon-agent GITHU { has "$r" "langfuse.user.id=heartbeat," && has "$r" "langfuse.trace.metadata.instance=aeon-agent" \ && has "$r" "langfuse.trace.metadata.trigger=workflow_dispatch"; } \ && pass "userId = skill; instance + trigger metadata" || bad "user/metadata ($r)" +# trigger: aeon.yml sets AEON_DISPATCH_TRIGGER from its trigger input (maybe empty). +# The trigger is the last attribute here, or followed by aeon.run_attempt in CI. +trig() { has "$1" "langfuse.trace.metadata.trigger=$2," || has "$1" "langfuse.trace.metadata.trigger=$2|"; } +r=$(attrs_for GITHUB_EVENT_NAME=workflow_dispatch AEON_DISPATCH_TRIGGER=cron) +trig "$r" cron && pass "trigger: Scheduler dispatch -> cron" || bad "trigger cron ($r)" +r=$(attrs_for GITHUB_EVENT_NAME=workflow_dispatch AEON_DISPATCH_TRIGGER=chain) +trig "$r" chain && pass "trigger: chain-runner dispatch -> chain" || bad "trigger chain ($r)" +r=$(attrs_for GITHUB_EVENT_NAME=workflow_dispatch AEON_DISPATCH_TRIGGER=) +trig "$r" manual && pass "trigger: plain Run now -> manual" || bad "trigger manual ($r)" +r=$(attrs_for GITHUB_EVENT_NAME=workflow_dispatch AEON_DISPATCH_TRIGGER='cron2,langfuse.user.id=x') +{ trig "$r" manual && ! has "$r" "cron2"; } \ + && pass "trigger: unknown input value -> manual, never copied" || bad "trigger free text ($r)" +r=$(attrs_for GITHUB_EVENT_NAME=issues AEON_DISPATCH_TRIGGER=) +trig "$r" issues && pass "trigger: non-dispatch event keeps its name" || bad "trigger issues ($r)" r=$(attrs_for AEON_OTEL_COMPONENT=feed) has "$r" "langfuse.user.id=aeon:feed," && pass "no skill → userId aeon:" || bad "feed user ($r)" r=$(attrs_for GITHUB_SHA=0123456789abcdef0123456789abcdef01234567) diff --git a/scripts/tests/test_memory_prep.py b/scripts/tests/test_memory_prep.py index 70c1b50dbfb..ce80168c452 100644 --- a/scripts/tests/test_memory_prep.py +++ b/scripts/tests/test_memory_prep.py @@ -143,6 +143,28 @@ def test_stamp_writes_state_and_mirrors(self): self.assertNotIn("never", md) +class TestMemorySize(unittest.TestCase): + def test_within_budget(self): + out = mp.memory_size_report("# M\n\n- short line\n") + self.assertEqual(len(out), 1) + self.assertIn("within budget", out[0]) + + def test_over_budget_names_long_lines(self): + text = "# M\n" + "\n".join("- " + "x" * 400 for _ in range(25)) + "\n" + out = mp.memory_size_report(text) + self.assertIn("OVER BUDGET by %d bytes" % (len(text.encode()) - mp.MEMORY_MAX_BYTES), out[0]) + self.assertIn("Lines over 300 chars", out[1]) + self.assertIn("2: 402", out[1]) + self.assertIn("(+15 more)", out[1]) + + def test_counts_bytes_not_chars(self): + text = "\u00e9" * (mp.MEMORY_MAX_BYTES // 2 + 1) # 2 bytes each in UTF-8 + self.assertIn("OVER BUDGET", mp.memory_size_report(text)[0]) + + def test_missing(self): + self.assertEqual(mp.memory_size_report(None), ["MEMORY.md: missing."]) + + class TestWindowIO(unittest.TestCase): def test_window_rotates_and_selects_in_git_repo(self): with tempfile.TemporaryDirectory() as root: diff --git a/scripts/tests/test_otel_span.sh b/scripts/tests/test_otel_span.sh index 98c8a8103da..db1ab29b043 100755 --- a/scripts/tests/test_otel_span.sh +++ b/scripts/tests/test_otel_span.sh @@ -87,7 +87,8 @@ STUB chmod +x "$TMP/cbin/curl" cat > "$TMP/claude-env.json" <<'JSON' {"result":"SENSITIVE-RESULT-TEXT","is_error":false,"num_turns":7,"total_cost_usd":0.42, - "usage":{"input_tokens":30,"output_tokens":500,"cache_read_input_tokens":9000,"cache_creation_input_tokens":1000}, + "usage":{"input_tokens":30,"output_tokens":500,"cache_read_input_tokens":9000,"cache_creation_input_tokens":1000, + "cache_creation":{"ephemeral_1h_input_tokens":750,"ephemeral_5m_input_tokens":250}}, "modelUsage":{"claude-sonnet-5-5":{"inputTokens":20,"outputTokens":450,"cacheReadInputTokens":8000,"cacheCreationInputTokens":900}, "claude-haiku-5-5":{"inputTokens":10,"outputTokens":50,"cacheReadInputTokens":1000,"cacheCreationInputTokens":100}}} JSON @@ -123,6 +124,13 @@ if [ -f "$TMP/curl/body" ]; then | .parentSpanId == $s and $a["gen_ai.usage.input_tokens"].intValue == "8920" and $a["gen_ai.usage.output_tokens"].intValue == "450" and $a["gen_ai.usage.cache_read_input_tokens"].intValue == "8000" and $a["langfuse.observation.type"].stringValue == "generation"' "$B" >/dev/null \ && ok "usage generation: cache-inclusive input, output, cache counts, parent = root" || bad "usage generation fields" + # usage_details names the Langfuse price keys: 1h writes at the 1h price, each + # model split by the run's 1h/5m ratio (75/25 here), input without cache. + ud() { jq -c --arg m "$1" '.resourceSpans[0].scopeSpans[0].spans[1:][] | select(.name == "usage " + $m) | .attributes[] | select(.key == "langfuse.observation.usage_details") | .value.stringValue | fromjson' "$B"; } + [ "$(ud claude-sonnet-5-5)" = '{"input":20,"output":450,"input_cache_read":8000,"input_cache_creation_1h":675,"input_cache_creation_5m":225}' ] \ + && ok "usage_details: 1h/5m cache-write split (sonnet)" || bad "usage_details sonnet ($(ud claude-sonnet-5-5))" + [ "$(ud claude-haiku-5-5)" = '{"input":10,"output":50,"input_cache_read":1000,"input_cache_creation_1h":75,"input_cache_creation_5m":25}' ] \ + && ok "usage_details: 1h/5m cache-write split (haiku)" || bad "usage_details haiku ($(ud claude-haiku-5-5))" jq -e '.resourceSpans[0].scopeSpans[0].spans as $s | $s[0].endTimeUnixNano as $end | [$s[1:][] | .startTimeUnixNano == $end and .endTimeUnixNano == $end and ([.attributes[] | select(.key == "langfuse.observation.metadata.rollup") | .value.stringValue] == ["true"])] | all' "$B" >/dev/null \ @@ -164,7 +172,7 @@ attrs 0 | jq -e '.["langfuse.observation.status_message"] == "timeout: rc 124" a cat > "$TMP/denied-env.json" <<'JSON' {"result":"x","is_error":true,"subtype":"error_max_turns","num_turns":40, "permission_denials":[{"tool_name":"Bash","tool_input":{"command":"SECRET-CMD"}},{"tool_name":"Write","tool_input":{}}], - "usage":{"input_tokens":1,"output_tokens":2,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}} + "usage":{"input_tokens":1,"output_tokens":2,"cache_read_input_tokens":0,"cache_creation_input_tokens":5}} JSON trace claude 0 "$TMP/denied-env.json" >/dev/null attrs 0 | jq -e '.["langfuse.observation.status_message"] == "budget_cap: error_max_turns" and .["langfuse.observation.level"] == "ERROR" @@ -173,6 +181,8 @@ attrs 0 | jq -e '.["langfuse.observation.status_message"] == "budget_cap: error_ && ok "error result: error_max_turns -> budget_cap, permission denial count" || bad "error result fields ($(attrs 0))" attrs 1 | jq -e '.["langfuse.trace.tags"] | index("status:error")' >/dev/null \ && ok "usage generation of a failed run carries status:error" || bad "usage generation status tag" +attrs 1 | jq -e '.["langfuse.observation.usage_details"] | fromjson == {"input":1,"output":2,"input_cache_read":0,"input_cache_creation":5}' >/dev/null \ + && ok "usage_details without a TTL split keeps one cache-write count" || bad "usage_details fallback ($(attrs 1))" grep -q SECRET-CMD "$TMP/curl/body" && bad "denied tool input leaked" || ok "denied tool input never sent" # error_class: structured facts first (subtype, API status), then the result # text or the raw output head (read for the class only, never sent). diff --git a/scripts/tests/test_scheduler_tick.sh b/scripts/tests/test_scheduler_tick.sh index df5c9f69fb0..2e2b7bce258 100644 --- a/scripts/tests/test_scheduler_tick.sh +++ b/scripts/tests/test_scheduler_tick.sh @@ -51,6 +51,10 @@ mkdir -p "$TMP/bin" cat > "$TMP/bin/gh" <<'EOF' #!/usr/bin/env bash echo "$*" >> "$GH_LOG" +# GH_REJECT_TRIGGER=1: an older aeon.yml with no trigger input. +if [ -n "${GH_REJECT_TRIGGER:-}" ]; then + case "$*" in *"trigger="*) echo 'could not create workflow dispatch event: HTTP 422: Unexpected inputs provided: ["trigger"]' >&2; exit 1 ;; esac +fi exit 0 EOF printf '#!/usr/bin/env bash\nexit 0\n' > "$TMP/bin/sleep" @@ -152,7 +156,7 @@ run_tick() { # run_tick [breaker-threshold] : > "$TMP/gh.log" ( cd "$TMP/work" && PATH="$TMP/bin:$PATH" GH_LOG="$TMP/gh.log" \ FAKE_NOW="$("$REAL_DATE" -u -d "$1" +%s)" GITHUB_REPOSITORY=fake/fake GH_TOKEN=x STATE_BACKEND="" \ - BREAKER_THRESHOLD_OVERRIDE="${2:-}" \ + BREAKER_THRESHOLD_OVERRIDE="${2:-}" GH_REJECT_TRIGGER="${GH_REJECT_TRIGGER:-}" \ bash --noprofile --norc -eo pipefail "$TMP/tick.sh" ) > "$TMP/tick.out" 2>&1 } dispatched() { grep -qxF -- "$1" "$TMP/gh.log"; } @@ -163,19 +167,19 @@ if run_tick 2026-07-07T06:05:00Z; then ok "tick 1 exits 0"; else bad "tick 1 fai dispatched "workflow run chain-runner.yml -f chain=morning" && ok "scheduled chain dispatched" || bad "chain morning not dispatched" grep -q 'chain=dev-loop' "$TMP/gh.log" && bad "commented schedule leaked into dev-loop" || ok "dev-loop (workflow_dispatch) not dispatched" grep -q 'skill=digest' "$TMP/gh.log" && bad "chain-covered digest also dispatched standalone" || ok "chain-covered skill not dispatched standalone" -dispatched "workflow run aeon.yml -f skill=solo" && ok "solo dispatched (commented chain does not cover it)" || bad "solo not dispatched" -SOLO_AT=$(grep -nxF "workflow run aeon.yml -f skill=solo" "$TMP/gh.log" | cut -d: -f1) -AFTER_AT=$(grep -nxF "workflow run aeon.yml -f skill=after-solo" "$TMP/gh.log" | cut -d: -f1) +dispatched "workflow run aeon.yml -f skill=solo -f trigger=cron" && ok "solo dispatched (commented chain does not cover it)" || bad "solo not dispatched" +SOLO_AT=$(grep -nxF "workflow run aeon.yml -f skill=solo -f trigger=cron" "$TMP/gh.log" | cut -d: -f1) +AFTER_AT=$(grep -nxF "workflow run aeon.yml -f skill=after-solo -f trigger=cron" "$TMP/gh.log" | cut -d: -f1) [ -n "$SOLO_AT" ] && [ -n "$AFTER_AT" ] && [ "$SOLO_AT" -lt "$AFTER_AT" ] \ && ok "depends_on read from a CRLF SKILL.md: dependent dispatched after its dependency" \ || bad "after-solo not ordered after solo: $(cat "$TMP/gh.log")" grep -qE 'skill=(dev-loop|morning|jsonrender|feature)( |$)' "$TMP/gh.log" && bad "non-skill key dispatched as a skill" || ok "chain/channel keys never dispatched as skills" grep -q 'skill=skill-repair' "$TMP/gh.log" && bad "disabled reactive handler dispatched" || ok "reactive handler with enabled: false not dispatched" -dispatched "workflow run aeon.yml -f skill=fixer -f var=broken-b" && ok "wildcard handler rotated to broken-b" \ +dispatched "workflow run aeon.yml -f skill=fixer -f var=broken-b -f trigger=cron" && ok "wildcard handler rotated to broken-b" \ || bad "fixer not dispatched for broken-b: $(grep fixer "$TMP/gh.log")" grep -q 'skill=weekly-down' "$TMP/gh.log" && bad "weekly skill probed off-cadence (cooldown up, no slot owed)" \ || ok "breaker probe waits for the weekly skill's next slot" -dispatched "workflow run aeon.yml -f skill=weekly-flaky" && ok "weekly skill still gets a quick retry for its failed slot" \ +dispatched "workflow run aeon.yml -f skill=weekly-flaky -f trigger=cron" && ok "weekly skill still gets a quick retry for its failed slot" \ || bad "weekly-flaky not quick-retried" grep -q 'skill=manual' "$TMP/gh.log" && bad "workflow_dispatch-only skill auto-retried" || ok "workflow_dispatch skill never retried" grep -q 'skill=bad-cron' "$TMP/gh.log" && bad "skill with an invalid schedule dispatched" || ok "invalid schedule is not dispatched" @@ -206,10 +210,14 @@ grep -qE 'skill=(broken-a|broken-b|weekly-down|manual)( |$)' "$TMP/gh.log" \ || ok "breaker off: quick retries are bounded, failures wait for their slot" # --- tick 4: weekly-down's next Monday slot pays the probe ---------------------- +# On an aeon.yml without the trigger input (GH_REJECT_TRIGGER): the dispatch is +# retried once without it, so the slot still runs. git -C "$TMP/work" pull -q origin main 2>/dev/null -if run_tick 2026-07-13T09:05:00Z; then ok "tick 4 exits 0"; else bad "tick 4 failed: $(tail -5 "$TMP/tick.out")"; fi -dispatched "workflow run aeon.yml -f skill=weekly-down" && ok "weekly skill probed at its own next slot" \ +if GH_REJECT_TRIGGER=1 run_tick 2026-07-13T09:05:00Z; then ok "tick 4 exits 0"; else bad "tick 4 failed: $(tail -5 "$TMP/tick.out")"; fi +dispatched "workflow run aeon.yml -f skill=weekly-down -f trigger=cron" && ok "weekly skill probed at its own next slot" \ || bad "weekly-down not probed at its slot: $(cat "$TMP/gh.log")" +dispatched "workflow run aeon.yml -f skill=weekly-down" && ok "no trigger input: retried without it" \ + || bad "no retry without the trigger field: $(cat "$TMP/gh.log")" grep -q 'skill=manual' "$TMP/gh.log" && bad "workflow_dispatch-only skill probed" || ok "workflow_dispatch skill never probed" # --- tick 5: origin rejects every push, the tick must say so ----------------------- diff --git a/skills/memory-flush/SKILL.md b/skills/memory-flush/SKILL.md index 3d3ee5728a6..e41e17fb293 100644 --- a/skills/memory-flush/SKILL.md +++ b/skills/memory-flush/SKILL.md @@ -22,6 +22,7 @@ Read `memory/MEMORY.md` for current memory state. **The scan window and log rota Run `python3 scripts/memory_prep.py window` and read its stdout. It: - computes your **scan window** from the structured watermark `memory/memory-flush-state.json` (fallback for a first-run migration: the MEMORY.md `*Last consolidated:*` line; then the last 3 days; a gap over 14 days is clamped to 14 and flagged), and prints the exact in-window log files to read; - has **already rotated** whole old months out of `memory/logs/` into `memory/logs/archive/YYYY-MM.md` (content-preserving) once the directory passed ~45 files. +- prints `MEMORY.md size` (bytes, lines, over-long lines) against the budget step 3e holds. Read exactly the files it lists. Do not recompute the window or rotate logs yourself - that work is now deterministic and unit-tested (`scripts/memory_prep.py`), so it never silently falls back to 3 days or gets skipped. This closed two old holes: entries older than 3 days were lost whenever the agent skipped runs, and a daily schedule re-scanned the same 3 days every time. @@ -50,10 +51,11 @@ a. **Open Improvement PRs section**: Run `gh pr list --state open --search "impr b. **Next Priorities section**: Cross-check each listed priority against recent logs and current repo state. Remove priorities that are already done (e.g., "Merge open PRs" if 0 open PRs exist). Add any newly urgent priorities surfaced by recent logs. c. **Lessons Learned**: Remove lessons that are now outdated or resolved (e.g., a workaround for a bug that was later fixed). d. **Overflow any section that outgrows its budget** (keeps MEMORY.md an index, not a ledger): if a section has grown past the last ~10-15 rows - the Skills Built table is the usual first offender, but the rule is general - archive the oldest rows to `memory/topics/
-history.md` (e.g. `skills-history.md`) and leave a one-line pointer to that file. Trim newest-kept, oldest-archived. +e. **Hold the byte budget** (MEMORY.md loads into every skill run, so size is cost): step 0 prints `MEMORY.md size` against the budget of **8 KB, ~50 lines, no line over 300 chars**, and lists the over-long lines by number. When it says OVER BUDGET or names long lines, cut those first: move the detail of a long bullet to its topic file (`memory/topics/lessons.md` for Lessons Learned, `memory/topics/priorities.md` for Next Priorities, or the matching topic) and keep a one-line summary with a pointer in MEMORY.md. Keep the `[source: ...]` tag with the detail you move. A flush that leaves MEMORY.md over budget must say so in its log entry. ### 4. Update memory -- Add brief entries to MEMORY.md (keep it under ~50 lines as an index). +- Add brief entries to MEMORY.md (keep it an index: under 8 KB, ~50 lines, no line over 300 chars). - If a topic needs more detail, write to `memory/topics/.md` instead (see step 6 - register it in the index). - Update tables (recent articles, recent digests) with new rows. - **Cite the log every fact came from.** End each bullet you add or change (in MEMORY.md or a topic file) with `[source: logs/YYYY-MM-DD#]`: the log file's date and the `### ` heading the entry sat under. For a table row, put the tag at the end of the last cell. When you merge new detail into an existing line, replace its tag with the newer source. Never invent a source, and do not backfill tags on untouched legacy lines. The tag is what lets a later flush (or the operator) check a fact and trace a bad entry back to the run that wrote it. @@ -86,7 +88,7 @@ If nothing was worth promoting or removing, log `MEMORY_FLUSH_OK` - but still ru ## Constraints -- Keep MEMORY.md an index (~50 lines). Detail lives in `memory/topics/`. +- Keep MEMORY.md an index: under 8 KB, ~50 lines, no line over 300 chars (step 0 measures it). Detail lives in `memory/topics/`. - Never duplicate an existing `## Heading` or an existing fact - update in place. - Pruning stale entries is as important as adding new ones. - The watermark and log rotation are owned by `scripts/memory_prep.py` (steps 0 and 8), not by hand. The model's job is judgment: what to promote, dedup, and prune.